diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index bba00b47c..3ea6048a0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -99,6 +99,7 @@ import { agentRuntimeStartStatus, agentRuntimeStateFromResult, agentRuntimeSteerStatus, + canArchiveGameChatStage, conversationContainsProjectSupervisorResponseStream, createAgentChatRunId, createDefaultChatMessages, @@ -108,12 +109,16 @@ import { isAgentRuntimeTerminalState, isMissingAgentRuntimeResumeCommandError, isRuntimeConfigMissingError, + latestGameChatPlayableRevision, matchingAgentRuntimeForSteer, mergeAgentRuntimeStateIntoMap, mergeGameChatRuntimeResponseMessagesIntoHistory, mergeProjectSupervisorConversation, mergeProjectSupervisorResponseStream, normalizeAgentRuntimeState, + gameChatRuntimeBelongsToLineage, + gameChatRuntimeIdentity, + projectCurrentGameChatRuntimeLineage, projectNameFromPath, projectProfessionalAgentLabel, projectSupervisorPendingRepairMatchesProfessional, @@ -122,6 +127,7 @@ import { sameAgentRuntimeRun, submitProjectSupervisorRuntimeTask, taskRowsFromManifest, + type GameChatPlayableRevision, } from './features/agent-runtime'; import { isDeveloperMode, @@ -260,37 +266,45 @@ type GameChatAutoPreviewAuthorization = { runId: string; }; -type GameChatPlayableRevision = { - runId: string; - revision: number; - validatedAt: number; +type PendingGameChatStageArchive = { + rootRuntime: AgentRuntimeState; + runtimeRecords: AgentRuntimeState[]; + manifestSnapshot: GameCreationAppManifest | null; }; -type GameChatPreviewValidationCandidate = GameChatPlayableRevision & { - eventOrder: number; - playable: boolean; -}; +function mergeGameChatRuntimeSnapshots( + previous: AgentRuntimeState[], + incoming: Iterable, +) { + const merged = new Map( + previous.map((runtime) => [gameChatRuntimeIdentity(runtime), runtime]), + ); + for (const runtime of incoming) { + if (!runtime) { + continue; + } + const key = gameChatRuntimeIdentity(runtime); + const existing = merged.get(key); + if (!existing || runtime.updatedAt >= existing.updatedAt) { + merged.set(key, runtime); + } + } + return Array.from(merged.values()); +} -const GAME_CHAT_STAGE_TASK_IDS = [ - 'design-director', - 'art-director', - 'art-asset-plan', - 'code-director', - 'code-prototype', - 'preview-readiness', - 'preview-playtest', -] as const; - -function gameChatManifestHasTerminalStageTasks( +function gameChatManifestHasTerminalPrimaryTask( manifest: GameCreationAppManifest | null, ) { - if (!manifest) { - return false; - } - return GAME_CHAT_STAGE_TASK_IDS.every((taskId) => { - const status = manifest.tasks.find((task) => task.id === taskId)?.status; - return status === 'completed' || status === 'failed'; - }); + const tasks = + manifest?.tasks.filter((task) => task.id === 'code-prototype') ?? []; + return ( + tasks.length === 1 && + (tasks[0]?.status === 'completed' || tasks[0]?.status === 'failed') + ); +} + +function gameChatStageRecordMessageId(runId: string) { + return `game-chat-stage-record:${encodeURIComponent(runId)}`; } function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) { @@ -335,99 +349,6 @@ function gameChatPlayableRevisionIsAfterAuthorization( ); } -export function latestGameChatPlayableRevision( - runtime: AgentRuntimeState | null, - runtimeByAgentId: Record, -): GameChatPlayableRevision | null { - if (!runtime?.runId) { - return null; - } - const playtestChildren = new Map(); - for (const child of Object.values(runtimeByAgentId)) { - if ( - child?.agentId !== 'preview-playtest' || - child.taskId !== 'preview-playtest' || - child.source !== 'agent-ready-task-scheduler' || - child.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || - child.parentRunId !== runtime.runId - ) { - continue; - } - playtestChildren.set( - `${child.agentId}\n${child.sessionId}\n${child.runId}`, - child, - ); - } - let latest: GameChatPreviewValidationCandidate | null = null; - let eventOrder = 0; - for (const child of playtestChildren.values()) { - for (const event of child.recentEvents ?? []) { - eventOrder += 1; - if ( - event.agentId !== child.agentId || - event.taskId !== child.taskId || - event.sessionId !== child.sessionId || - event.runId !== child.runId || - event.eventType !== 'observation' || - !Number.isSafeInteger(event.updatedAt) || - event.updatedAt < 0 || - !event.summary.startsWith('preview.validate:') || - !event.detail?.trim().startsWith('{') - ) { - continue; - } - try { - const detail = JSON.parse(event.detail) as { - passed?: unknown; - playtestPassed?: unknown; - revision?: unknown; - }; - const playable = - event.summary.startsWith('preview.validate:ok') && - detail.passed === true && - detail.playtestPassed === true; - if ( - typeof detail.passed !== 'boolean' || - typeof detail.revision !== 'number' || - !Number.isSafeInteger(detail.revision) || - detail.revision <= 0 - ) { - continue; - } - const shouldReplace = - !latest || - detail.revision > latest.revision || - (detail.revision === latest.revision && - event.updatedAt > latest.validatedAt) || - (detail.revision === latest.revision && - event.updatedAt === latest.validatedAt && - ((latest.playable && !playable) || - (latest.playable === playable && - eventOrder > latest.eventOrder))); - if (!shouldReplace) { - continue; - } - latest = { - eventOrder, - playable, - runId: runtime.runId, - revision: detail.revision, - validatedAt: event.updatedAt, - }; - } catch { - // Ignore malformed or truncated public evidence and wait for a valid revision. - } - } - } - return latest?.playable - ? { - runId: latest.runId, - revision: latest.revision, - validatedAt: latest.validatedAt, - } - : null; -} - function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthorization | null { try { window.localStorage.removeItem( @@ -656,7 +577,7 @@ export function App({ const gameChatObservedRunKeysRef = useRef(new Set()); const gameChatArchivedRunKeysRef = useRef(new Set()); const gameChatPendingStageRuntimesRef = useRef( - new Map(), + new Map(), ); const gameChatCommittedResponseStreamKeysRef = useRef(new Set()); const initialSupervisorMessageLatchRef = useRef({ @@ -1086,36 +1007,65 @@ export function App({ } function flushPendingGameChatStageRecords() { - if (!gameChatOnly || !gameChatManifestHasTerminalStageTasks(manifest)) { + if (!gameChatOnly) { return; } for (const [ archiveKey, - pendingRuntime, + pendingArchive, ] of gameChatPendingStageRuntimesRef.current) { if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { gameChatPendingStageRuntimesRef.current.delete(archiveKey); continue; } + pendingArchive.runtimeRecords = mergeGameChatRuntimeSnapshots( + pendingArchive.runtimeRecords, + Object.values(agentRuntimeById), + ); + if ( + projectSupervisorRuntimeRef.current?.runId === + pendingArchive.rootRuntime.runId && + gameChatManifestHasTerminalPrimaryTask(manifest) + ) { + pendingArchive.manifestSnapshot = manifest; + } + const lineage = projectCurrentGameChatRuntimeLineage( + pendingArchive.rootRuntime, + pendingArchive.runtimeRecords, + ); + if (!canArchiveGameChatStage(lineage, pendingArchive.manifestSnapshot)) { + continue; + } + const runtimeSnapshot = Object.fromEntries( + pendingArchive.runtimeRecords.map((runtime) => [ + gameChatRuntimeIdentity(runtime), + runtime, + ]), + ); const progress = buildGameChatProgressEvidence( - pendingRuntime, - agentRuntimeById, - manifest, + pendingArchive.rootRuntime, + runtimeSnapshot, + pendingArchive.manifestSnapshot, ); if (!progress) { continue; } const text = formatGameChatStageRecord( - pendingRuntime, + pendingArchive.rootRuntime, progress, - collectGameChatResultImages(manifest), + collectGameChatResultImages(pendingArchive.manifestSnapshot), + ); + const messageId = gameChatStageRecordMessageId( + pendingArchive.rootRuntime.runId, ); gameChatArchivedRunKeysRef.current.add(archiveKey); gameChatPendingStageRuntimesRef.current.delete(archiveKey); setMessages((current) => { if ( current.some( - (message) => message.role === 'assistant' && message.text === text, + (message) => + message.role === 'assistant' && + (message.messageId === messageId || message.text === text), ) ) { return current; @@ -1130,7 +1080,15 @@ export function App({ current.length, ); } - return [...current, { role: 'assistant', text }]; + return [ + ...current, + { + role: 'assistant', + text, + messageId, + updatedAt: pendingArchive.rootRuntime.updatedAt, + }, + ]; }); } } @@ -1141,7 +1099,7 @@ export function App({ ) { const eventMessages = gameChatRuntimeEventMessages( runtime, - agentRuntimeById, + agentRuntimeByIdRef.current, ); if (eventMessages.length === 0) { return; @@ -1174,27 +1132,36 @@ export function App({ if (!gameChatOnly || runtimeResults.length === 0) { return; } - // A professional Runtime is only part of the active game-chat turn when - // it was delegated by the current Project Supervisor run. This prevents - // a stale child Runtime (or a different app mode) from leaking into the - // project transcript after a restart. - const supervisorRunId = - projectSupervisorRuntimeRef.current?.runId ?? - runtimeResults.find( - (result) => result.state.agentId === PROJECT_SUPERVISOR_AGENT_ID, - )?.state.runId; - if (!supervisorRunId) { + const runtimeStates = runtimeResults.map((result) => + agentRuntimeStateFromResult(result), + ); + const root = + projectSupervisorRuntimeRef.current ?? + runtimeStates.find( + (candidate) => candidate.agentId === PROJECT_SUPERVISOR_AGENT_ID, + ) ?? + null; + const lineage = projectCurrentGameChatRuntimeLineage(root, [ + ...Object.values(agentRuntimeByIdRef.current), + ...runtimeStates, + ]); + if (!lineage?.main) { return; } - const messages = runtimeResults.flatMap((result) => { - const runtime = agentRuntimeStateFromResult(result); + const messages = runtimeResults.flatMap((result, index) => { + const runtime = runtimeStates[index]!; + const stream = result.responseStream; if ( - runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID || - runtime.parentRunId !== supervisorRunId + !gameChatRuntimeBelongsToLineage(lineage, runtime) || + !stream || + stream.agentId !== runtime.agentId || + stream.taskId !== runtime.taskId || + stream.sessionId !== runtime.sessionId || + stream.runId !== runtime.runId ) { return []; } - return gameChatFinalReplyMessages([result.responseStream]); + return gameChatFinalReplyMessages([stream]); }); if (messages.length === 0) { return; @@ -1243,7 +1210,22 @@ export function App({ if (gameChatArchivedRunKeysRef.current.has(archiveKey)) { return; } - gameChatPendingStageRuntimesRef.current.set(archiveKey, runtime); + const previous = gameChatPendingStageRuntimesRef.current.get(archiveKey); + gameChatPendingStageRuntimesRef.current.set(archiveKey, { + rootRuntime: + !previous || runtime.updatedAt >= previous.rootRuntime.updatedAt + ? runtime + : previous.rootRuntime, + runtimeRecords: mergeGameChatRuntimeSnapshots( + previous?.runtimeRecords ?? [], + Object.values(agentRuntimeById), + ), + manifestSnapshot: + gameChatManifestHasTerminalPrimaryTask(manifest) && + projectSupervisorRuntimeRef.current?.runId === runtime.runId + ? manifest + : (previous?.manifestSnapshot ?? null), + }); flushPendingGameChatStageRecords(); } @@ -1802,8 +1784,10 @@ export function App({ } const currentSupervisor = projectSupervisorRuntimeRef.current; let playableRevision = latestGameChatPlayableRevision( - currentSupervisor, - agentRuntimeByIdRef.current, + projectCurrentGameChatRuntimeLineage( + currentSupervisor, + agentRuntimeByIdRef.current, + ), ); if (playableRevision) { const currentRevision = await readCurrentProjectRevision(); @@ -5798,8 +5782,10 @@ export function App({ let autoPreviewAfterValidatedAt = 0; if (steerRuntime) { const playableAtSubmission = latestGameChatPlayableRevision( - runtimeAtSubmission, - agentRuntimeByIdRef.current, + projectCurrentGameChatRuntimeLineage( + runtimeAtSubmission, + agentRuntimeByIdRef.current, + ), ); autoPreviewAfterRevision = Math.max( gameChatPreviewRevisionRef.current ?? 0, diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts new file mode 100644 index 000000000..f159be714 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts @@ -0,0 +1,445 @@ +import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { PROJECT_SUPERVISOR_AGENT_ID } from '../../app/constants'; +import type { + AgentRuntimeEventRecord, + AgentRuntimeState, +} from '../../app/types'; + +export const GAME_CHAT_SUPERVISOR_SOURCE = + 'project-supervisor-game-chat' as const; +export const GAME_CHAT_PRIMARY_TASK_ID = 'code-prototype' as const; +export const GAME_CHAT_DYNAMIC_ART_AGENT_IDS: ReadonlySet = new Set([ + 'art-director', + 'art-asset-plan', +]); + +const GAME_CHAT_MAIN_SOURCE = 'agent-ready-task-scheduler'; +const GAME_CHAT_DYNAMIC_ART_SOURCES = new Set([ + 'agent-delegate', + 'agent-delegate-retry', +]); +const GAME_CHAT_TERMINAL_STATES = new Set(['completed', 'failed', 'cancelled']); +const GAME_CHAT_RECONCILIATION_STATE = 'needs-reconciliation'; + +export type GameChatRuntimeLineage = { + root: AgentRuntimeState; + main: AgentRuntimeState | null; + dynamicArtChildren: AgentRuntimeState[]; + hasConflict: boolean; +}; + +export type GameChatPrimaryProgress = { + completed: 0 | 1; + total: 1; + status: + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'needs-reconciliation'; +}; + +export type GameChatPlayableRevision = { + runId: string; + mainRunId: string; + revision: number; + validatedAt: number; +}; + +type OrderedRuntimeEvent = { + event: AgentRuntimeEventRecord; + order: number; +}; + +type PreviewValidationCandidate = GameChatPlayableRevision & { + order: number; + playable: boolean; +}; + +function hasIdentity(value: string | null | undefined) { + return typeof value === 'string' && value.trim().length > 0; +} + +export function gameChatRuntimeIdentity(runtime: AgentRuntimeState) { + return [runtime.agentId, runtime.sessionId, runtime.runId].join('\u001f'); +} + +export function sameGameChatRuntimeIdentity( + left: AgentRuntimeState, + right: AgentRuntimeState, +) { + return gameChatRuntimeIdentity(left) === gameChatRuntimeIdentity(right); +} + +function distinctRuntimeRecords( + runtimeRecords: + | Iterable + | Record, +) { + const records = + Symbol.iterator in Object(runtimeRecords) + ? Array.from( + runtimeRecords as Iterable, + ) + : Object.values(runtimeRecords); + const distinct = new Map(); + for (const runtime of records) { + if (!runtime) { + continue; + } + const key = gameChatRuntimeIdentity(runtime); + const previous = distinct.get(key); + if (!previous || runtime.updatedAt >= previous.updatedAt) { + distinct.set(key, runtime); + } + } + return Array.from(distinct.values()); +} + +export function isGameChatSupervisorRoot( + runtime: AgentRuntimeState | null | undefined, +): boolean { + return Boolean( + runtime && + runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID && + runtime.taskId === PROJECT_SUPERVISOR_AGENT_ID && + runtime.source === GAME_CHAT_SUPERVISOR_SOURCE && + hasIdentity(runtime.sessionId) && + hasIdentity(runtime.runId) && + !hasIdentity(runtime.parentAgentId) && + !hasIdentity(runtime.parentRunId), + ); +} + +export function isCurrentGameChatMainRuntime( + root: AgentRuntimeState | null | undefined, + candidate: AgentRuntimeState | null | undefined, +): boolean { + if (!root || !isGameChatSupervisorRoot(root) || !candidate) { + return false; + } + return Boolean( + candidate.agentId === GAME_CHAT_PRIMARY_TASK_ID && + candidate.taskId === GAME_CHAT_PRIMARY_TASK_ID && + candidate.source === GAME_CHAT_MAIN_SOURCE && + candidate.parentAgentId === PROJECT_SUPERVISOR_AGENT_ID && + candidate.parentRunId === root.runId && + hasIdentity(candidate.sessionId) && + hasIdentity(candidate.runId), + ); +} + +export function isCurrentGameChatDynamicArtRuntime( + main: AgentRuntimeState | null | undefined, + candidate: AgentRuntimeState | null | undefined, +): boolean { + return Boolean( + main && + main.agentId === GAME_CHAT_PRIMARY_TASK_ID && + main.taskId === GAME_CHAT_PRIMARY_TASK_ID && + main.source === GAME_CHAT_MAIN_SOURCE && + candidate && + GAME_CHAT_DYNAMIC_ART_AGENT_IDS.has(candidate.agentId) && + candidate.taskId === candidate.agentId && + GAME_CHAT_DYNAMIC_ART_SOURCES.has(candidate.source) && + candidate.parentAgentId === GAME_CHAT_PRIMARY_TASK_ID && + candidate.parentRunId === main.runId && + hasIdentity(candidate.sessionId) && + hasIdentity(candidate.runId), + ); +} + +export function isGameChatRuntimeTerminalState( + runtime: AgentRuntimeState | null | undefined, +) { + return Boolean( + runtime && + (GAME_CHAT_TERMINAL_STATES.has(runtime.status) || + GAME_CHAT_TERMINAL_STATES.has(runtime.phase)), + ); +} + +export function isGameChatRuntimeInReconciliation( + runtime: AgentRuntimeState | null | undefined, +) { + return Boolean( + runtime && + (runtime.status === GAME_CHAT_RECONCILIATION_STATE || + runtime.phase === GAME_CHAT_RECONCILIATION_STATE), + ); +} + +export function projectCurrentGameChatRuntimeLineage( + root: AgentRuntimeState | null | undefined, + runtimeRecords: + | Iterable + | Record, +): GameChatRuntimeLineage | null { + if (!root || !isGameChatSupervisorRoot(root)) { + return null; + } + const records = distinctRuntimeRecords(runtimeRecords); + const mainCandidates = records.filter((candidate) => + isCurrentGameChatMainRuntime(root, candidate), + ); + const main = mainCandidates.length === 1 ? mainCandidates[0]! : null; + const dynamicArtChildren = main + ? records + .filter((candidate) => + isCurrentGameChatDynamicArtRuntime(main, candidate), + ) + .sort( + (left, right) => + left.updatedAt - right.updatedAt || + left.agentId.localeCompare(right.agentId) || + left.runId.localeCompare(right.runId), + ) + : []; + const activeArtChildren = dynamicArtChildren.filter( + (candidate) => !isGameChatRuntimeTerminalState(candidate), + ); + return { + root, + main, + dynamicArtChildren, + hasConflict: mainCandidates.length > 1 || activeArtChildren.length > 1, + }; +} + +export function gameChatLineageCollaboratingRuntimes( + lineage: GameChatRuntimeLineage | null, +) { + return lineage?.main ? [lineage.main, ...lineage.dynamicArtChildren] : []; +} + +export function gameChatRuntimeBelongsToLineage( + lineage: GameChatRuntimeLineage | null, + candidate: AgentRuntimeState | null | undefined, +) { + return Boolean( + candidate && + gameChatLineageCollaboratingRuntimes(lineage).some((runtime) => + sameGameChatRuntimeIdentity(runtime, candidate), + ), + ); +} + +function manifestPrimaryTaskStatus(manifest: GameCreationAppManifest | null) { + const tasks = + manifest?.tasks.filter((task) => task.id === GAME_CHAT_PRIMARY_TASK_ID) ?? + []; + return tasks.length === 1 ? tasks[0]!.status : null; +} + +export function projectGameChatPrimaryProgress( + manifest: GameCreationAppManifest | null, + lineage: GameChatRuntimeLineage | null, +): GameChatPrimaryProgress { + if (!lineage) { + return { completed: 0, total: 1, status: 'pending' }; + } + if (lineage.hasConflict || isGameChatRuntimeInReconciliation(lineage.root)) { + return { completed: 0, total: 1, status: 'needs-reconciliation' }; + } + if (!lineage.main) { + return { completed: 0, total: 1, status: 'pending' }; + } + const relatedRuntimes = gameChatLineageCollaboratingRuntimes(lineage); + if (relatedRuntimes.some(isGameChatRuntimeInReconciliation)) { + return { completed: 0, total: 1, status: 'needs-reconciliation' }; + } + const main = lineage.main; + if ( + main.status === 'failed' || + main.phase === 'failed' || + main.status === 'cancelled' || + main.phase === 'cancelled' + ) { + return { completed: 0, total: 1, status: 'failed' }; + } + if (!isGameChatRuntimeTerminalState(main)) { + return { completed: 0, total: 1, status: 'running' }; + } + const manifestStatus = manifestPrimaryTaskStatus(manifest); + if (manifestStatus === 'completed') { + return { completed: 1, total: 1, status: 'completed' }; + } + if (manifestStatus === 'failed') { + return { completed: 0, total: 1, status: 'failed' }; + } + return { completed: 0, total: 1, status: 'pending' }; +} + +function runtimeOwnOrderedEvents(runtime: AgentRuntimeState) { + return (runtime.recentEvents ?? []).flatMap((event, order) => + event.agentId === runtime.agentId && + event.taskId === runtime.taskId && + event.sessionId === runtime.sessionId && + event.runId === runtime.runId && + event.source === runtime.source && + Number.isSafeInteger(event.updatedAt) && + event.updatedAt >= 0 + ? [{ event, order }] + : [], + ); +} + +function staticSmokeObservationPassed(summary: string) { + if ( + !summary.startsWith('command.run_limited:') || + !/(?:^|[\s·])game\.static_smoke(?:$|[\s,。;])/u.test(summary) + ) { + return null; + } + return summary.startsWith('command.run_limited:ok') ? true : false; +} + +function orderedEventAtOrBefore( + left: OrderedRuntimeEvent, + right: OrderedRuntimeEvent, +) { + return ( + left.event.updatedAt < right.event.updatedAt || + (left.event.updatedAt === right.event.updatedAt && + left.order <= right.order) + ); +} + +function shouldReplacePreviewCandidate( + current: PreviewValidationCandidate | null, + candidate: PreviewValidationCandidate, +) { + if (!current) { + return true; + } + if (candidate.revision !== current.revision) { + return candidate.revision > current.revision; + } + if (candidate.validatedAt !== current.validatedAt) { + return candidate.validatedAt > current.validatedAt; + } + if (candidate.playable !== current.playable) { + return !candidate.playable; + } + return candidate.order > current.order; +} + +export function latestGameChatPlayableRevision( + lineage: GameChatRuntimeLineage | null, +): GameChatPlayableRevision | null { + if (!lineage?.main || lineage.hasConflict) { + return null; + } + const orderedEvents = runtimeOwnOrderedEvents(lineage.main); + const staticSmokeEvents = orderedEvents.filter( + ({ event }) => + event.eventType === 'observation' && + staticSmokeObservationPassed(event.summary) !== null, + ); + const latestStaticSmoke = + staticSmokeEvents.reduce( + (latest, candidate) => { + if ( + !latest || + candidate.event.updatedAt > latest.event.updatedAt || + (candidate.event.updatedAt === latest.event.updatedAt && + candidate.order > latest.order) + ) { + return candidate; + } + return latest; + }, + null, + ); + if ( + !latestStaticSmoke || + staticSmokeObservationPassed(latestStaticSmoke.event.summary) !== true + ) { + return null; + } + + let latestPreview: PreviewValidationCandidate | null = null; + for (const orderedEvent of orderedEvents) { + const { event, order } = orderedEvent; + if ( + event.eventType !== 'observation' || + !event.summary.startsWith('preview.validate:') || + !event.detail?.trim().startsWith('{') + ) { + continue; + } + try { + const detail = JSON.parse(event.detail) as { + passed?: unknown; + playtestPassed?: unknown; + revision?: unknown; + }; + if ( + typeof detail.revision !== 'number' || + !Number.isSafeInteger(detail.revision) || + detail.revision <= 0 + ) { + continue; + } + const candidate: PreviewValidationCandidate = { + runId: lineage.root.runId, + mainRunId: lineage.main.runId, + revision: detail.revision, + validatedAt: event.updatedAt, + order, + playable: + event.summary.startsWith('preview.validate:ok') && + detail.passed === true && + detail.playtestPassed === true, + }; + if (shouldReplacePreviewCandidate(latestPreview, candidate)) { + latestPreview = candidate; + } + } catch { + // Malformed public evidence cannot establish a playable revision. + } + } + if (!latestPreview?.playable) { + return null; + } + const previewEvent = orderedEvents.find( + ({ event, order }) => + event.updatedAt === latestPreview?.validatedAt && + order === latestPreview.order, + ); + if ( + !previewEvent || + !orderedEventAtOrBefore(latestStaticSmoke, previewEvent) + ) { + return null; + } + return { + runId: latestPreview.runId, + mainRunId: latestPreview.mainRunId, + revision: latestPreview.revision, + validatedAt: latestPreview.validatedAt, + }; +} + +export function canArchiveGameChatStage( + lineage: GameChatRuntimeLineage | null, + manifest: GameCreationAppManifest | null, +) { + if (!lineage?.main || lineage.hasConflict) { + return false; + } + const relatedRuntimes = [ + lineage.root, + lineage.main, + ...lineage.dynamicArtChildren, + ]; + if ( + relatedRuntimes.some(isGameChatRuntimeInReconciliation) || + relatedRuntimes.some((runtime) => !isGameChatRuntimeTerminalState(runtime)) + ) { + return false; + } + return ['completed', 'failed'].includes( + manifestPrimaryTaskStatus(manifest) ?? '', + ); +} diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts index 268e61dbf..870d429f9 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/index.ts @@ -1,2 +1,3 @@ +export * from './gameChatRuntimeProjection'; export * from './model'; export * from './panels'; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 0f8329aba..0841d6d36 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -20,6 +20,12 @@ import type { LocalConversationMessageRecord, TauriInvoke, } from '../../app/types'; +import { + GAME_CHAT_SUPERVISOR_SOURCE, + gameChatLineageCollaboratingRuntimes, + isGameChatSupervisorRoot, + projectCurrentGameChatRuntimeLineage, +} from './gameChatRuntimeProjection'; const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX = 'runtime-public-status-'; const AGENT_RUNTIME_TASK_MESSAGE_ID_PREFIX = 'runtime-task-'; @@ -1412,14 +1418,22 @@ export function projectSupervisorCollaboratingAgentRuntimes( if (!supervisorRuntime?.runId) { return []; } + if (supervisorRuntime.source === GAME_CHAT_SUPERVISOR_SOURCE) { + return isGameChatSupervisorRoot(supervisorRuntime) + ? gameChatLineageCollaboratingRuntimes( + projectCurrentGameChatRuntimeLineage( + supervisorRuntime, + runtimeByAgentId, + ), + ) + : []; + } const runtimesByAgentId = new Map(); for (const runtime of Object.values(runtimeByAgentId)) { - const isVisibleChildSource = - ['agent-delegate', 'agent-delegate-retry'].includes( - runtime?.source ?? '', - ) || - (supervisorRuntime.source === 'project-supervisor-game-chat' && - runtime?.source === 'agent-ready-task-scheduler'); + const isVisibleChildSource = [ + 'agent-delegate', + 'agent-delegate-retry', + ].includes(runtime?.source ?? ''); if ( !runtime || runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID || diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx index e7db17e8b..7f30723d5 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx @@ -46,6 +46,10 @@ import { projectSupervisorPendingActionPresentation, projectSupervisorRuntimeStatusLabel, } from './model'; +import { + isCurrentGameChatDynamicArtRuntime, + projectCurrentGameChatRuntimeLineage, +} from './gameChatRuntimeProjection'; export function AgentGoalStatusPanel({ goal, @@ -721,6 +725,10 @@ export function ProjectSupervisorRuntimePanel({ runtime, runtimeByAgentId, ); + const gameChatLineage = projectCurrentGameChatRuntimeLineage( + runtime, + runtimeByAgentId, + ); const visibleSnapshotKey = [ runtime?.runId ?? '', runtime?.status ?? '', @@ -975,8 +983,14 @@ export function ProjectSupervisorRuntimePanel({ professionalRuntime.pendingToolAction ?? null; const professionalResult = professionalResultsByAgentId[professionalRuntime.agentId]; + const dynamicArtRetryUnsupported = + isCurrentGameChatDynamicArtRuntime( + gameChatLineage?.main, + professionalRuntime, + ); const canRetryProfessional = !supervisorIsTerminal && + !dynamicArtRetryUnsupported && !professionalPendingAction && (professionalRuntime.status === 'failed' || professionalRuntime.phase === 'failed') && @@ -1169,7 +1183,9 @@ export function ProjectSupervisorRuntimePanel({ (professionalRuntime.status === 'failed' || professionalRuntime.phase === 'failed') ? ( - 请先重试项目总控,再由新总控继续安排此任务 + {dynamicArtRetryUnsupported + ? '请继续 game-chat 对话,由下一轮程序原型 Agent 重新审计缺口后委派' + : '请先重试项目总控,再由新总控继续安排此任务'} ) : null} 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 a78af79e3..28d9095b9 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 @@ -24,9 +24,13 @@ import type { } from '../../app/types'; import { formatAgentRuntimeEvent, + GAME_CHAT_DYNAMIC_ART_AGENT_IDS, + GAME_CHAT_PRIMARY_TASK_ID, isAgentRuntimeTerminalState, isMudPointInsufficientRuntimeError, MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, + projectCurrentGameChatRuntimeLineage, + projectGameChatPrimaryProgress, projectNameFromPath, projectProfessionalAgentLabel, projectRuntimePlanProgress, @@ -62,13 +66,8 @@ export type GameChatRuntimeEvent = { const GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX = 'game-chat-runtime-event:'; const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:'; const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([ - 'design-director', - 'art-director', - 'art-asset-plan', - 'code-director', - 'code-prototype', - 'preview-readiness', - 'preview-playtest', + GAME_CHAT_PRIMARY_TASK_ID, + ...GAME_CHAT_DYNAMIC_ART_AGENT_IDS, ]); const GAME_CHAT_INTERNAL_RUNTIME_EVENT_TYPES = new Set([ 'tool.request', @@ -329,18 +328,10 @@ export function gameChatMudPointInterruptionText( runtime: AgentRuntimeState, runtimeByAgentId: Record, ) { - const childRuntimes = Object.values(runtimeByAgentId).filter( - (childRuntime): childRuntime is AgentRuntimeState => - childRuntime !== undefined && - childRuntime.parentAgentId === runtime.agentId && - childRuntime.parentRunId === runtime.runId && - [ - 'agent-delegate', - 'agent-delegate-retry', - 'agent-ready-task-scheduler', - ].includes(childRuntime.source), - ); - const relatedRuntimes = [runtime, ...childRuntimes]; + const relatedRuntimes = [ + runtime, + ...projectSupervisorCollaboratingAgentRuntimes(runtime, runtimeByAgentId), + ]; return relatedRuntimes.some( (relatedRuntime) => relatedRuntime.error && @@ -442,9 +433,11 @@ function runtimeEvidenceEvents( function latestEvidenceEvent( events: ReturnType, - predicate: (event: AgentRuntimeEventRecord) => boolean, + predicate: ( + item: ReturnType[number], + ) => boolean, ) { - return events.find(({ event }) => predicate(event)) ?? null; + return events.find(predicate) ?? null; } export function formatGameChatRuntimeText(text: string) { @@ -463,29 +456,20 @@ export function buildGameChatProgressEvidence( if (!runtime?.runId) { return null; } - const fastPathTaskIds = new Set([ - 'design-director', - 'art-director', - 'art-asset-plan', - 'code-director', - 'code-prototype', - 'preview-readiness', - 'preview-playtest', - ]); - const tasks = - manifest?.tasks.filter((task) => fastPathTaskIds.has(task.id)) ?? []; - const completedTasks = tasks.filter( - (task) => task.status === 'completed', - ).length; - const runningTasks = tasks.filter((task) => - ['running', 'waiting-for-confirmation'].includes(task.status), - ).length; - const failedTasks = tasks.filter((task) => task.status === 'failed').length; + const lineage = projectCurrentGameChatRuntimeLineage( + runtime, + runtimeByAgentId, + ); + const primaryProgress = projectGameChatPrimaryProgress(manifest, lineage); const plan = projectRuntimePlanProgress(runtime); const taskParts = [ - tasks.length > 0 ? `任务图 ${completedTasks}/${tasks.length}` : null, - runningTasks > 0 ? `进行中 ${runningTasks}` : null, - failedTasks > 0 ? `失败 ${failedTasks}` : null, + primaryProgress.status === 'completed' + ? '主阶段 1/1' + : primaryProgress.status === 'failed' + ? '主阶段失败' + : primaryProgress.status === 'needs-reconciliation' + ? '主阶段待核对' + : '主阶段 0/1', plan.total > 0 ? `计划 ${plan.completed}/${plan.total}` : null, ].filter(Boolean); const activeAgents = projectSupervisorCollaboratingAgentRuntimes( @@ -504,33 +488,39 @@ export function buildGameChatProgressEvidence( return compactProgressText(parts.join(' · '), 140); }); const events = runtimeEvidenceEvents(runtime, runtimeByAgentId); + const mainRuntime = lineage?.main ?? null; const preview = latestEvidenceEvent( events, - (event) => + ({ event, source }) => + source === mainRuntime && event.eventType === 'observation' && event.summary.startsWith('preview.validate:'), ); const staticCheck = latestEvidenceEvent( events, - (event) => + ({ event, source }) => + source === mainRuntime && event.eventType === 'observation' && - event.summary.startsWith('command.run_limited:'), + event.summary.startsWith('command.run_limited:') && + /(?:^|[\s·])game\.static_smoke(?:$|[\s,。;])/u.test(event.summary), ); const delegate = latestEvidenceEvent( events, - (event) => + ({ event, source }) => + source === mainRuntime && event.eventType === 'action' && event.summary === '调用工具 agent.delegate', ); const imageInspection = latestEvidenceEvent( events, - (event) => + ({ event }) => event.eventType === 'observation' && event.summary.startsWith('image.inspect:'), ); const mutation = latestEvidenceEvent( events, - (event) => + ({ event, source }) => + source === mainRuntime && event.eventType === 'observation' && (event.summary.startsWith('file.patch:') || event.summary.startsWith('file.write:')), diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 76f7831a5..f92dcfbc8 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test, vi } from 'vitest'; +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; + import type { + AgentRuntimeEventRecord, AgentRuntimeResponseStream, AgentRuntimeResult, AgentRuntimeState, @@ -15,10 +18,17 @@ import { projectRuntimeVisibleCurrentWork, projectRuntimeVisibleError, projectSupervisorChatRuntimeStatus, + projectSupervisorCollaboratingAgentRuntimes, projectSupervisorResponseStreamIdentity, projectSupervisorVisibleConversationText, submitProjectSupervisorRuntimeTask, } from '../src/features/agent-runtime/model'; +import { + canArchiveGameChatStage, + latestGameChatPlayableRevision, + projectCurrentGameChatRuntimeLineage, + projectGameChatPrimaryProgress, +} from '../src/features/agent-runtime/gameChatRuntimeProjection'; describe('Runtime-owned public statuses', () => { test('keeps backend status messages visible without treating them as client-authored conversation', () => { @@ -669,3 +679,407 @@ describe('Agent Runtime Provider 状态投影', () => { 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( + 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('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('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); + }); +}); 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 9d419224a..cb344178a 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 @@ -2,15 +2,17 @@ import type { ProjectResourceCanvasLayout, ProjectResourceCanvasPosition, } from '../../../../packages/shared/src/contracts/gameCreationApp'; -import { - consumeInitialGameChatMessage, - latestGameChatPlayableRevision, -} from '../../src/App'; +import { consumeInitialGameChatMessage } from '../../src/App'; import type { AgentRuntimeEventRecord, AgentRuntimeState, } from '../../src/app/types'; -import { MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE } from '../../src/features/agent-runtime/model'; +import { + latestGameChatPlayableRevision as projectLatestGameChatPlayableRevision, + MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, + ProjectSupervisorRuntimePanel, + projectCurrentGameChatRuntimeLineage, +} from '../../src/features/agent-runtime'; import { buildGameChatProgressEvidence, collectGameChatResultImages, @@ -108,6 +110,7 @@ function gameChatRuntimeEvent({ updatedAt, eventId = `${agentId}-${runId}-${updatedAt}-${eventType}`, publicText = summary, + source, }: { agentId?: string; taskId?: string; @@ -120,6 +123,7 @@ function gameChatRuntimeEvent({ detail?: string | null; eventId?: string; publicText?: string | null; + source?: string; updatedAt: number; }): AgentRuntimeEventRecord { return { @@ -129,9 +133,10 @@ function gameChatRuntimeEvent({ sessionId, runId, source: - agentId === 'project-supervisor' + source ?? + (agentId === 'project-supervisor' ? 'project-supervisor-game-chat' - : 'agent-delegate', + : 'agent-delegate'), eventId, eventType, status, @@ -171,21 +176,43 @@ function gameChatRuntimeState( }; } -const GAME_CHAT_STAGE_TASK_IDS = [ - 'design-director', - 'art-director', - 'art-asset-plan', - 'code-director', - 'code-prototype', - 'preview-readiness', - 'preview-playtest', -] as const; - -function isGameChatStageTask(taskId: string) { - return (GAME_CHAT_STAGE_TASK_IDS as readonly string[]).includes(taskId); +function latestGameChatPlayableRevision( + root: AgentRuntimeState, + runtimeByAgentId: Record, +) { + return projectLatestGameChatPlayableRevision( + projectCurrentGameChatRuntimeLineage(root, runtimeByAgentId), + ); } -function gameChatPreviewPlaytestRuntime({ +function gameChatMainRuntimeWithEvents( + root: AgentRuntimeState, + events: AgentRuntimeEventRecord[], +) { + const main = gameChatRuntimeState({ + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: `${root.runId}-main-session`, + runId: `${root.runId}-main-run`, + source: 'agent-ready-task-scheduler', + parentAgentId: 'project-supervisor', + parentRunId: root.runId, + status: 'running', + phase: 'execution', + updatedAt: root.updatedAt, + }); + main.recentEvents = events.map((event) => ({ + ...event, + agentId: main.agentId, + taskId: main.taskId, + sessionId: main.sessionId, + runId: main.runId, + source: main.source, + })); + return main; +} + +function gameChatPlayableMainRuntime({ parentRunId, revision, updatedAt, @@ -194,11 +221,11 @@ function gameChatPreviewPlaytestRuntime({ revision: number; updatedAt: number; }): AgentRuntimeState { - const runId = `preview-playtest-${parentRunId}-${revision}`; - const sessionId = `preview-playtest-session-${revision}`; + const runId = `code-prototype-${parentRunId}-${revision}`; + const sessionId = `code-prototype-session-${revision}`; return gameChatRuntimeState({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId, runId, source: 'agent-ready-task-scheduler', @@ -206,15 +233,28 @@ function gameChatPreviewPlaytestRuntime({ parentRunId, status: 'completed', phase: 'completed', - currentTask: '执行浏览器试玩验证', + currentTask: '完成单主静态自检与浏览器试玩验证', currentGoal: '确认当前 revision 可以试玩', - currentAction: '浏览器试玩验证已通过', + currentAction: '静态自检与浏览器试玩验证已通过', recentEvents: [ gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId, runId, + source: 'agent-ready-task-scheduler', + eventType: 'observation', + status: 'completed', + phase: 'tool-observation', + summary: 'command.run_limited:ok · game.static_smoke 已完成', + updatedAt: Math.max(0, updatedAt - 1), + }), + gameChatRuntimeEvent({ + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId, + runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'completed', phase: 'tool-observation', @@ -429,7 +469,7 @@ async function renderGameChatAutoPreviewDriver({ }; const emitValidation = async (revision: number, validatedAt: number) => { harness.setProjectRevision(revision); - const runtime = gameChatPreviewPlaytestRuntime({ + const runtime = gameChatPlayableMainRuntime({ parentRunId, revision, updatedAt: validatedAt, @@ -3713,30 +3753,54 @@ export function registerProjectSupervisorSurfaceTests() { duplicate, ], }); - const currentChild = gameChatRuntimeState({ - agentId: 'design-foundation', - taskId: 'design-foundation', - sessionId: 'design-session', - runId: 'design-run', + const currentMain = gameChatRuntimeState({ + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'code-session', + runId: 'code-run', source: 'agent-ready-task-scheduler', parentAgentId: 'project-supervisor', parentRunId: 'active-parent-run', updatedAt: 30, recentEvents: [ gameChatRuntimeEvent({ - agentId: 'design-foundation', - runId: 'design-run', - summary: '当前子 Agent 事件', + agentId: 'code-prototype', + sessionId: 'code-session', + runId: 'code-run', + source: 'agent-ready-task-scheduler', + summary: '当前主 Agent 事件', updatedAt: 30, }), gameChatRuntimeEvent({ - agentId: 'design-foundation', - runId: 'previous-design-run', - summary: '子 Agent 旧 run 事件', + agentId: 'code-prototype', + sessionId: 'code-session', + runId: 'previous-code-run', + source: 'agent-ready-task-scheduler', + summary: '主 Agent 旧 run 事件', updatedAt: 60, }), ], }); + const currentArtChild = gameChatRuntimeState({ + agentId: 'art-asset-plan', + taskId: 'art-asset-plan', + sessionId: 'current-art-session', + runId: 'current-art-run', + source: 'agent-delegate', + parentAgentId: 'code-prototype', + parentRunId: 'code-run', + updatedAt: 25, + recentEvents: [ + gameChatRuntimeEvent({ + agentId: 'art-asset-plan', + sessionId: 'current-art-session', + runId: 'current-art-run', + source: 'agent-delegate', + summary: '当前美术 child 事件', + updatedAt: 25, + }), + ], + }); const staleChild = gameChatRuntimeState({ agentId: 'art-asset-plan', taskId: 'art-asset-plan', @@ -3764,17 +3828,20 @@ export function registerProjectSupervisorSurfaceTests() { ]); const events = collectGameChatRuntimeEvents(supervisor, { - 'design-foundation': currentChild, - 'art-asset-plan': staleChild, + 'code-prototype': currentMain, + 'current-art-asset-plan': currentArtChild, + 'stale-art-asset-plan': staleChild, }); expect(events.map((item) => item.event.summary)).toEqual([ - '当前子 Agent 事件', + '当前主 Agent 事件', + '当前美术 child 事件', '总控事件 2', '总控事件 1', ]); expect(events.map((item) => item.agentLabel)).toEqual([ - '玩法策划 Agent', + '程序原型 Agent', + '美术资源计划 Agent', '项目总控 Agent', '项目总控 Agent', ]); @@ -3847,20 +3914,22 @@ export function registerProjectSupervisorSurfaceTests() { }), ], }); - const designRuntime = gameChatRuntimeState({ - agentId: 'design-director', - taskId: 'design-director', - sessionId: 'design-director-session', - runId: 'design-director-child-run', - source: 'agent-delegate', + const mainRuntime = gameChatRuntimeState({ + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'code-prototype-session', + runId: 'code-prototype-main-run', + source: 'agent-ready-task-scheduler', parentAgentId: 'project-supervisor', parentRunId: runId, recentEvents: [ gameChatRuntimeEvent({ - agentId: 'design-director', - runId: 'design-director-child-run', + agentId: 'code-prototype', + sessionId: 'code-prototype-session', + runId: 'code-prototype-main-run', + source: 'agent-ready-task-scheduler', eventType: 'turn.started', - summary: 'Design Agent started and must stay visible', + summary: 'Code prototype Agent started and must stay visible', updatedAt: 32, }), ], @@ -3868,14 +3937,16 @@ export function registerProjectSupervisorSurfaceTests() { }); const messages = gameChatRuntimeEventMessages(runtime, { - 'design-director': designRuntime, + 'code-prototype': mainRuntime, }); expect(messages.map((message) => message.updatedAt)).toEqual([ 32, 40, 60, 80, ]); expect(messages.map((message) => message.text)).toEqual([ - expect.stringContaining('Design Agent started and must stay visible'), + expect.stringContaining( + 'Code prototype Agent started and must stay visible', + ), expect.stringContaining('Generated prototype progress'), expect.stringContaining('code agent repair collision'), expect.stringContaining('preview.validate:ok'), @@ -3973,17 +4044,13 @@ export function registerProjectSupervisorSurfaceTests() { requestKind: 'tool-plan', }, ]); - expect(messages).toHaveLength(7); + expect(messages).toHaveLength(3); expect(messages.map((message) => message.text)).toEqual([ - expect.stringContaining('玩法方向已完成'), expect.stringContaining('视觉方向已完成'), expect.stringContaining('平台美术图集已生成并登记'), - expect.stringContaining('程序方案已完成'), expect.stringContaining('代码原型已完成'), - expect.stringContaining('预览就绪检查已完成'), - expect.stringContaining('试玩验证已完成'), ]); - expect(messages[0]?.messageId).toContain('design-director'); + expect(messages[0]?.messageId).toContain('art-director'); expect(messages[0]?.messageId).toContain('game-chat-final-reply:'); expect(messages.every((message) => message.agentId)).toBe(true); const hydrated = mergeGameChatFinalReplyMessagesIntoHistory( @@ -3997,6 +4064,99 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(1); }); + it('hides generic retry for exact game-chat dynamic art children and shows cross-round recovery guidance', () => { + const root = gameChatRuntimeState({ + runId: 'game-chat-dynamic-art-retry-root', + status: 'running', + phase: 'waiting-for-delegate-receipts', + }); + const main = gameChatRuntimeState({ + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'game-chat-dynamic-art-main-session', + runId: 'game-chat-dynamic-art-main-run', + source: 'agent-ready-task-scheduler', + parentAgentId: 'project-supervisor', + parentRunId: root.runId, + status: 'running', + phase: 'waiting-for-delegate-receipts', + }); + const artDirector = gameChatRuntimeState({ + agentId: 'art-director', + taskId: 'art-director', + sessionId: 'game-chat-dynamic-art-director-session', + runId: 'game-chat-dynamic-art-director-run', + source: 'agent-delegate', + parentAgentId: 'code-prototype', + parentRunId: main.runId, + status: 'failed', + phase: 'failed', + currentAction: '动态美术委派失败', + }); + const legacyRetry = gameChatRuntimeState({ + agentId: 'art-asset-plan', + taskId: 'art-asset-plan', + sessionId: 'game-chat-dynamic-art-retry-session', + runId: 'game-chat-dynamic-art-retry-run', + source: 'agent-delegate-retry', + parentAgentId: 'code-prototype', + parentRunId: main.runId, + status: 'failed', + phase: 'failed', + currentAction: '遗留 retry 只作诊断展示', + }); + const wrongParentArt = gameChatRuntimeState({ + agentId: 'art-director', + taskId: 'art-director', + sessionId: 'game-chat-wrong-parent-art-session', + runId: 'game-chat-wrong-parent-art-run', + source: 'agent-delegate', + parentAgentId: 'project-supervisor', + parentRunId: root.runId, + status: 'failed', + phase: 'failed', + currentAction: '旧 root 直属美术不得显示', + }); + const callbacks = { + onProfessionalRetry: vi.fn(async () => 'unexpected retry'), + onProfessionalToolAction: vi.fn(async () => {}), + onSupervisorRetry: vi.fn(async () => 'unexpected supervisor retry'), + onToolAction: vi.fn(), + onUserInput: vi.fn(), + }; + const renderPanel = (runtime: AgentRuntimeState) => + React.createElement(ProjectSupervisorRuntimePanel, { + runtime, + error: '', + runtimeByAgentId: { + 'code-prototype': main, + 'art-director': artDirector, + 'art-asset-plan': legacyRetry, + 'wrong-parent-art': wrongParentArt, + }, + controlBusy: false, + professionalResultsByAgentId: {}, + ...callbacks, + }); + const rendered = render(renderPanel(root)); + const list = screen.getByLabelText('专业 Agent 实时状态'); + + expect( + within(list).queryByRole('button', { name: '在当前项目重试' }), + ).toBeNull(); + expect(list.textContent).not.toContain('旧 root 直属美术不得显示'); + expect(callbacks.onProfessionalRetry).not.toHaveBeenCalled(); + + rendered.rerender( + renderPanel({ ...root, status: 'completed', phase: 'completed' }), + ); + expect( + within(screen.getByLabelText('专业 Agent 实时状态')).getAllByText( + '请继续 game-chat 对话,由下一轮程序原型 Agent 重新审计缺口后委派', + ), + ).toHaveLength(2); + }); + 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'; @@ -4007,20 +4167,31 @@ export function registerProjectSupervisorSurfaceTests() { phase: 'execution', updatedAt: 100, }); + const mainRunId = `code-prototype-${runId}`; const makeRuntimeResult = ( agentId: string, status: 'ready' | 'committed', text: string, updatedAt: number, ) => { + const currentMain = agentId === 'code-prototype'; + const currentDynamicArt = ['art-director', 'art-asset-plan'].includes( + agentId, + ); const state = gameChatRuntimeState({ agentId, taskId: agentId, - sessionId: 'supervisor-session-active', - runId: `${agentId}-${runId}`, - source: 'agent-delegate', - parentAgentId: 'project-supervisor', - parentRunId: runId, + sessionId: `${agentId}-session-active`, + runId: currentMain ? mainRunId : `${agentId}-${runId}`, + source: currentMain + ? 'agent-ready-task-scheduler' + : currentDynamicArt + ? 'agent-delegate' + : 'agent-ready-task-scheduler', + parentAgentId: currentDynamicArt + ? 'code-prototype' + : 'project-supervisor', + parentRunId: currentDynamicArt ? mainRunId : runId, status: 'completed', phase: 'completed', updatedAt, @@ -4033,7 +4204,7 @@ export function registerProjectSupervisorSurfaceTests() { schemaVersion: 'game-creator-runtime-response-stream.v1', agentId, taskId: agentId, - sessionId: 'supervisor-session-active', + sessionId: state.sessionId, runId: state.runId, requestKind: 'final-reply', requestSlot: 'final-reply-loop-1-revision-1', @@ -4107,8 +4278,8 @@ export function registerProjectSupervisorSurfaceTests() { expect(screen.getByText(/视觉方向已完成/)).not.toBeNull(); expect(screen.getByText(/平台美术图集已生成并登记/)).not.toBeNull(); expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); - expect(screen.getByText(/预览就绪已完成/)).not.toBeNull(); - expect(screen.getByText(/试玩验证已完成/)).not.toBeNull(); + expect(screen.queryByText(/预览就绪已完成/)).toBeNull(); + expect(screen.queryByText(/试玩验证已完成/)).toBeNull(); }); const finalReplyAppends = () => invoke.mock.calls.filter( @@ -4117,14 +4288,14 @@ export function registerProjectSupervisorSurfaceTests() { String(args?.messageId ?? '').startsWith('game-chat-final-reply:'), ); await waitFor(() => { - expect(finalReplyAppends()).toHaveLength(7); + expect(finalReplyAppends()).toHaveLength(3); }); rendered.unmount(); rendered = renderRelease(); await waitFor(() => { expect(screen.getByText(/代码原型已完成/)).not.toBeNull(); }); - expect(finalReplyAppends()).toHaveLength(7); + expect(finalReplyAppends()).toHaveLength(3); rendered.unmount(); }); @@ -4273,7 +4444,16 @@ export function registerProjectSupervisorSurfaceTests() { ], }); - const progress = buildGameChatProgressEvidence(runtime, {}, null); + const progress = buildGameChatProgressEvidence( + runtime, + { + 'code-prototype': gameChatMainRuntimeWithEvents( + runtime, + runtime.recentEvents ?? [], + ), + }, + null, + ); expect(progress?.evidence).toEqual([ expect.objectContaining({ @@ -4300,7 +4480,16 @@ export function registerProjectSupervisorSurfaceTests() { ], }); - const progress = buildGameChatProgressEvidence(runtime, {}, null); + const progress = buildGameChatProgressEvidence( + runtime, + { + 'code-prototype': gameChatMainRuntimeWithEvents( + runtime, + runtime.recentEvents ?? [], + ), + }, + null, + ); expect(progress?.evidence).toEqual([ expect.objectContaining({ @@ -4332,7 +4521,16 @@ export function registerProjectSupervisorSurfaceTests() { }); expect( - buildGameChatProgressEvidence(runtime, {}, null)?.evidence, + buildGameChatProgressEvidence( + runtime, + { + 'code-prototype': gameChatMainRuntimeWithEvents( + runtime, + runtime.recentEvents ?? [], + ), + }, + null, + )?.evidence, ).toEqual([expect.objectContaining({ label, tone })]); } }); @@ -4359,7 +4557,16 @@ export function registerProjectSupervisorSurfaceTests() { ], }); - const progress = buildGameChatProgressEvidence(runtime, {}, null); + const progress = buildGameChatProgressEvidence( + runtime, + { + 'code-prototype': gameChatMainRuntimeWithEvents( + runtime, + runtime.recentEvents ?? [], + ), + }, + null, + ); expect(progress?.evidence).toEqual([ expect.objectContaining({ @@ -4386,7 +4593,16 @@ export function registerProjectSupervisorSurfaceTests() { ], }); - const progress = buildGameChatProgressEvidence(runtime, {}, null); + const progress = buildGameChatProgressEvidence( + runtime, + { + 'code-prototype': gameChatMainRuntimeWithEvents( + runtime, + runtime.recentEvents ?? [], + ), + }, + null, + ); expect(progress?.evidence).toEqual([ expect.objectContaining({ @@ -4398,17 +4614,18 @@ export function registerProjectSupervisorSurfaceTests() { it('derives a playable revision only from a passed preview validation with a positive integer revision', () => { const runId = 'game-chat-playable-revision-run'; - const invalidPlaytest = gameChatPreviewPlaytestRuntime({ + const invalidPlaytest = gameChatPlayableMainRuntime({ parentRunId: runId, revision: 1, updatedAt: 3000, }); const invalidEvidence = [ gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:failed', detail: JSON.stringify({ @@ -4419,10 +4636,11 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 8000, }), gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:ok · 验证未通过', detail: JSON.stringify({ @@ -4433,20 +4651,22 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 7000, }), gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:ok · 缺少 revision', detail: JSON.stringify({ passed: true, playtestPassed: true }), updatedAt: 6000, }), gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:ok · revision 非正整数', detail: JSON.stringify({ @@ -4457,10 +4677,11 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 5000, }), gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: invalidPlaytest.sessionId, runId: invalidPlaytest.runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', summary: 'preview.validate:ok · revision 不是整数', detail: JSON.stringify({ @@ -4478,21 +4699,22 @@ export function registerProjectSupervisorSurfaceTests() { expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'preview-playtest': invalidPlaytest, + 'code-prototype': invalidPlaytest, }), ).toBeNull(); - const previewPlaytest = gameChatPreviewPlaytestRuntime({ + const previewPlaytest = gameChatPlayableMainRuntime({ parentRunId: runId, revision: 9, - updatedAt: 9000, + updatedAt: 8000, }); previewPlaytest.recentEvents?.push( gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: previewPlaytest.sessionId, runId: previewPlaytest.runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'completed', phase: 'tool-observation', @@ -4506,10 +4728,11 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: 8500, }), gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: previewPlaytest.sessionId, runId: previewPlaytest.runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'completed', phase: 'tool-observation', @@ -4525,23 +4748,25 @@ export function registerProjectSupervisorSurfaceTests() { ); expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'preview-playtest': previewPlaytest, + 'code-prototype': previewPlaytest, }), - ).toEqual({ runId, revision: 10, validatedAt: 8800 }); + ).toEqual( + expect.objectContaining({ runId, revision: 10, validatedAt: 8800 }), + ); - const staleParentPlaytest = gameChatPreviewPlaytestRuntime({ + const staleParentPlaytest = gameChatPlayableMainRuntime({ parentRunId: 'different-parent-run', revision: 11, updatedAt: 11000, }); expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'preview-playtest': staleParentPlaytest, + 'code-prototype': staleParentPlaytest, }), ).toBeNull(); const wrongSourcePlaytest = { - ...gameChatPreviewPlaytestRuntime({ + ...gameChatPlayableMainRuntime({ parentRunId: runId, revision: 12, updatedAt: 12000, @@ -4550,26 +4775,22 @@ export function registerProjectSupervisorSurfaceTests() { }; expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'preview-playtest': wrongSourcePlaytest, + 'code-prototype': wrongSourcePlaytest, }), ).toBeNull(); - const passedRevision = gameChatPreviewPlaytestRuntime({ + const higherFailedRevision = gameChatPlayableMainRuntime({ parentRunId: runId, revision: 20, updatedAt: 20000, }); - const higherFailedRevision = gameChatPreviewPlaytestRuntime({ - parentRunId: runId, - revision: 21, - updatedAt: 21000, - }); - higherFailedRevision.recentEvents = [ + higherFailedRevision.recentEvents?.push( gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: higherFailedRevision.sessionId, runId: higherFailedRevision.runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'failed', phase: 'tool-observation', @@ -4581,25 +4802,25 @@ export function registerProjectSupervisorSurfaceTests() { }), updatedAt: 21000, }), - ]; + ); expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'preview-playtest-passed': passedRevision, - 'preview-playtest-higher-failed': higherFailedRevision, + 'code-prototype': higherFailedRevision, }), ).toBeNull(); - const sameRevisionLaterFailed = gameChatPreviewPlaytestRuntime({ + const sameRevisionLaterFailed = gameChatPlayableMainRuntime({ parentRunId: runId, revision: 22, updatedAt: 22000, }); sameRevisionLaterFailed.recentEvents?.push( gameChatRuntimeEvent({ - agentId: 'preview-playtest', - taskId: 'preview-playtest', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: sameRevisionLaterFailed.sessionId, runId: sameRevisionLaterFailed.runId, + source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'failed', phase: 'tool-observation', @@ -4614,7 +4835,7 @@ export function registerProjectSupervisorSurfaceTests() { ); expect( latestGameChatPlayableRevision(runtimeWithOnlyInvalidEvidence, { - 'preview-playtest': sameRevisionLaterFailed, + 'code-prototype': sameRevisionLaterFailed, }), ).toBeNull(); }); @@ -4771,8 +4992,8 @@ export function registerProjectSupervisorSurfaceTests() { ], }); const childRuntime = gameChatRuntimeState({ - agentId: 'code-director', - taskId: 'code-director', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: 'game-chat-stalled-code-session', runId: 'game-chat-stalled-code-run', source: 'agent-ready-task-scheduler', @@ -4781,8 +5002,8 @@ export function registerProjectSupervisorSurfaceTests() { updatedAt: now - 5 * 60 * 1000 - 10 * 1000, recentEvents: [ gameChatRuntimeEvent({ - agentId: 'code-director', - taskId: 'code-director', + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: 'game-chat-stalled-code-session', runId: 'game-chat-stalled-code-run', summary: '程序 Agent 最近进度', @@ -4919,9 +5140,9 @@ export function registerProjectSupervisorSurfaceTests() { label: 'child provider retry', runtime: baseRuntime, runtimeByAgentId: { - 'code-director': gameChatRuntimeState({ - agentId: 'code-director', - taskId: 'code-director', + 'code-prototype': gameChatRuntimeState({ + agentId: 'code-prototype', + taskId: 'code-prototype', sessionId: 'game-chat-waiting-code-session', runId: 'game-chat-waiting-code-run', source: 'agent-ready-task-scheduler', @@ -4957,9 +5178,9 @@ export function registerProjectSupervisorSurfaceTests() { taskId: 'art-director', sessionId: 'game-chat-waiting-art-session', runId: 'game-chat-waiting-art-run', - source: 'agent-ready-task-scheduler', - parentAgentId: 'project-supervisor', - parentRunId: runId, + source: 'agent-delegate', + parentAgentId: 'code-prototype', + parentRunId: 'game-chat-stalled-code-run', phase: 'waiting-for-provider-retry', updatedAt: now - 10 * 1000, }); @@ -5032,6 +5253,7 @@ export function registerProjectSupervisorSurfaceTests() { taskId: 'code-director', sessionId: 'game-chat-terminal-duration-code-session', runId: 'game-chat-terminal-duration-code-run', + source: 'agent-ready-task-scheduler', eventType: 'turn.completed', status: 'completed', phase: 'completed', @@ -5043,7 +5265,7 @@ export function registerProjectSupervisorSurfaceTests() { renderGameChatStatus({ runtime, - runtimeByAgentId: { 'code-director': childRuntime }, + runtimeByAgentId: { 'code-prototype': childRuntime }, }); expect(screen.getByLabelText('最新状态').textContent).toContain( '已持续 9 分 0 秒', @@ -5094,21 +5316,36 @@ export function registerProjectSupervisorSurfaceTests() { error: '专业 Agent 执行失败', updatedAt: 9100, }); + const mainRuntime = gameChatRuntimeState({ + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'game-chat-mud-point-main-session', + runId: 'game-chat-mud-point-main-run', + source: 'agent-ready-task-scheduler', + parentAgentId: 'project-supervisor', + parentRunId: rootRunId, + status: 'failed', + phase: 'failed', + updatedAt: 9050, + }); const artRuntime = gameChatRuntimeState({ agentId: 'art-asset-plan', taskId: 'art-asset-plan', sessionId: 'game-chat-mud-point-art-session', runId: 'game-chat-mud-point-art-run', source: 'agent-delegate', - parentAgentId: 'project-supervisor', - parentRunId: rootRunId, + parentAgentId: 'code-prototype', + parentRunId: mainRuntime.runId, status: 'failed', phase: 'failed', error: '平台图片生成任务失败:可消费泥点不足:需要 10,扣除退款占用后可用 2;operationId=private-operation-id', updatedAt: 9000, }); - const runtimeByAgentId = { 'art-asset-plan': artRuntime }; + const runtimeByAgentId = { + 'code-prototype': mainRuntime, + 'art-asset-plan': artRuntime, + }; renderGameChatStatus({ runtime, runtimeByAgentId }); @@ -5131,13 +5368,13 @@ export function registerProjectSupervisorSurfaceTests() { expect(stageRecord).not.toContain('private-operation-id'); }); - it('counts only the seven first-playable tasks in game-chat progress', () => { + it('counts only the current single main stage in game-chat progress', () => { const manifest = createGameCreationAppManifest( 'game-chat-progress-total', 'game-chat-progress-total', ); manifest.tasks = manifest.tasks.map((task) => - isGameChatStageTask(task.id) + task.id === 'code-prototype' ? { ...task, status: 'completed' as const } : task, ); @@ -5147,23 +5384,43 @@ export function registerProjectSupervisorSurfaceTests() { phase: 'execution', updatedAt: 9000, }); + const mainRuntime = gameChatRuntimeState({ + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'game-chat-progress-main-session', + runId: 'game-chat-progress-main-run', + source: 'agent-ready-task-scheduler', + parentAgentId: 'project-supervisor', + parentRunId: runtime.runId, + status: 'completed', + phase: 'completed', + updatedAt: 8999, + }); - renderGameChatStatus({ runtime, manifest }); + renderGameChatStatus({ + runtime, + runtimeByAgentId: { 'code-prototype': mainRuntime }, + manifest, + }); const status = screen.getByLabelText('最新状态'); - expect(status.textContent).toContain('任务图 7/7'); + expect(status.textContent).toContain('主阶段 1/1'); expect(status.textContent).not.toContain('publish-strategy'); expect(status.textContent).not.toContain('publish-package'); fireEvent.click(within(status).getByRole('button', { name: '运行详情' })); const progress = screen.getByLabelText('Supervisor 进度播报'); - expect(progress.textContent).toContain('任务图 7/7'); + expect(progress.textContent).toContain('主阶段 1/1'); }); it('keeps one live progress card while persisting every public game-chat output as a message', async () => { const projectPath = '/tmp/game-chat-progress-broadcast'; const supervisorRunId = 'game-chat-progress-run'; const previewFailure = gameChatRuntimeEvent({ - runId: supervisorRunId, + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'code-prototype-progress-session', + runId: 'code-prototype-progress-run', + source: 'agent-ready-task-scheduler', eventType: 'observation', status: 'failed', phase: 'tool-observation', @@ -5196,7 +5453,7 @@ export function registerProjectSupervisorSurfaceTests() { { index: 2, title: '修复并复测', status: 'pending' }, ], activePlanStepIndex: 1, - recentEvents: [previewFailure], + recentEvents: [], updatedAt: 7200, }; let professionalRuntimes: Array> = []; @@ -5230,6 +5487,7 @@ export function registerProjectSupervisorSurfaceTests() { }, ], activePlanStepIndex: 0, + recentEvents: [previewFailure], updatedAt: 7300, }) as AgentRuntimeState; professionalRuntimes = [codePrototypeRuntime]; @@ -5329,7 +5587,7 @@ export function registerProjectSupervisorSurfaceTests() { ); const status = await screen.findByLabelText('最新状态'); - expect(status.textContent).toContain('任务图 3/7 · 进行中 1 · 计划 1/3'); + expect(status.textContent).toContain('主阶段 0/1 · 计划 1/3'); expect(status.textContent).toContain('核对首版试玩诊断'); expect(status.textContent).toContain('1 个专业 Agent 活跃'); const runtimeEventAppends = () => @@ -5349,9 +5607,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(progress.getAttribute('data-run-id')).toBe(supervisorRunId); expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); expect(within(progress).queryByText(/第 4 轮/u)).toBeNull(); - expect( - within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 1/3'), - ).not.toBeNull(); + expect(within(progress).getByText('主阶段 0/1 · 计划 1/3')).not.toBeNull(); expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull(); expect(within(progress).getByText('活跃专业 Agent')).not.toBeNull(); expect( @@ -5364,7 +5620,11 @@ export function registerProjectSupervisorSurfaceTests() { ), ).not.toBeNull(); const delegateDecision = gameChatRuntimeEvent({ - runId: supervisorRunId, + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: codePrototypeRuntime.sessionId, + runId: codePrototypeRuntime.runId, + source: 'agent-ready-task-scheduler', eventType: 'action', phase: 'tool-action', summary: '调用工具 agent.delegate', @@ -5402,13 +5662,20 @@ export function registerProjectSupervisorSurfaceTests() { }, }, }); + const updatedMainRuntime = { + ...codePrototypeRuntime, + recentEvents: [previewFailure, delegateDecision], + updatedAt: 7501, + }; + professionalRuntimes = [updatedMainRuntime]; + harness.emitAgentRuntime(updatedMainRuntime); }); await waitFor(() => { expect(within(progress).getByText('本轮生成进度')).not.toBeNull(); expect(within(progress).queryByText(/第 5 轮/u)).toBeNull(); expect( - within(progress).getByText('任务图 3/7 · 进行中 1 · 计划 2/3'), + within(progress).getByText('主阶段 0/1 · 计划 2/3'), ).not.toBeNull(); expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull(); expect(within(progress).getByText('返工决定')).not.toBeNull(); @@ -5634,13 +5901,17 @@ export function registerProjectSupervisorSurfaceTests() { it('persists one terminal game-chat stage record and keeps it in the next round', async () => { const projectPath = '/tmp/game-chat-stage-record'; - const harness = createProjectSupervisorRuntimeHarness({ projectPath }); + let professionalRuntimes: AgentRuntimeState[] = []; + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + runtimeMapLoader: async () => professionalRuntimes, + }); const manifest = createGameCreationAppManifest( 'game-chat-stage-record', 'game-chat-stage-record', ); manifest.tasks = manifest.tasks.map((task) => - isGameChatStageTask(task.id) + task.id === 'code-prototype' ? { ...task, status: 'completed' as const } : task, ); @@ -5758,28 +6029,28 @@ export function registerProjectSupervisorSurfaceTests() { ); const firstRunId = String(startCall?.[1]?.runId ?? ''); expect(firstRunId).not.toBe(''); - const previewPassed = gameChatRuntimeEvent({ - runId: firstRunId, - eventType: 'observation', - summary: 'preview.validate:ok · 浏览器验证已通过', - detail: JSON.stringify({ - diagnosticsCount: 0, - passed: true, - playtestPassed: true, - revision: 9, - }), + const mainRuntime = gameChatPlayableMainRuntime({ + parentRunId: firstRunId, + revision: 9, updatedAt: 9100, }); const rework = gameChatRuntimeEvent({ - runId: firstRunId, + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: mainRuntime.sessionId, + runId: mainRuntime.runId, + source: 'agent-ready-task-scheduler', eventType: 'action', summary: '调用工具 agent.delegate', detail: '根据上一版试玩诊断安排程序 Agent 完成返工', updatedAt: 9000, }); + mainRuntime.recentEvents = [rework, ...(mainRuntime.recentEvents ?? [])]; + professionalRuntimes = [mainRuntime]; const terminalRuntime = harness.runtimeState({ runId: firstRunId, runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', status: 'idle', phase: 'completed', loopIteration: 6, @@ -5789,7 +6060,7 @@ export function registerProjectSupervisorSurfaceTests() { { index: 1, title: '通过试玩', status: 'completed' }, ], activePlanStepIndex: null, - recentEvents: [rework, previewPassed], + recentEvents: [], updatedAt: 9200, }); harness.appendSupervisorMessage({ @@ -5805,6 +6076,7 @@ export function registerProjectSupervisorSurfaceTests() { throw new Error('missing game creator runtime update listener'); } act(() => { + harness.emitAgentRuntime(mainRuntime); updateHandler({ payload: { projectPath, @@ -5814,7 +6086,7 @@ export function registerProjectSupervisorSurfaceTests() { phase: 'completed', runtime: { ...harness.runtimeResult(terminalRuntime), - recentEvents: [rework, previewPassed], + recentEvents: [], }, }, }); @@ -5863,7 +6135,11 @@ export function registerProjectSupervisorSurfaceTests() { it('defers the terminal game-chat stage record until the refreshed manifest is terminal', async () => { const projectPath = '/tmp/game-chat-stage-record-manifest-race'; - const harness = createProjectSupervisorRuntimeHarness({ projectPath }); + let professionalRuntimes: AgentRuntimeState[] = []; + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + runtimeMapLoader: async () => professionalRuntimes, + }); const pendingManifest = createGameCreationAppManifest( 'game-chat-stage-record-manifest-race', 'game-chat-stage-record-manifest-race', @@ -5873,18 +6149,9 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-stage-record-manifest-race', ); terminalManifest.tasks = terminalManifest.tasks.map((task) => - task.id === 'preview-playtest' + task.id === 'code-prototype' ? { ...task, status: 'failed' as const } - : [ - 'design-director', - 'art-director', - 'art-asset-plan', - 'code-director', - 'code-prototype', - 'preview-readiness', - ].includes(task.id) - ? { ...task, status: 'completed' as const } - : task, + : task, ); let manifestReady = false; const invoke = vi.fn( @@ -5969,13 +6236,28 @@ export function registerProjectSupervisorSurfaceTests() { ); const runId = String(startCall?.[1]?.runId ?? ''); expect(runId).not.toBe(''); + const failedMain = gameChatRuntimeState({ + agentId: 'code-prototype', + taskId: 'code-prototype', + sessionId: 'manifest-race-main-session', + runId: 'manifest-race-main-run', + source: 'agent-ready-task-scheduler', + parentAgentId: 'project-supervisor', + parentRunId: runId, + status: 'failed', + phase: 'failed', + updatedAt: 9199, + }); + professionalRuntimes = [failedMain]; act(() => { + harness.emitAgentRuntime(failedMain); harness.emitRuntime( harness.runtimeState({ runId, + source: 'project-supervisor-game-chat', status: 'failed', phase: 'failed', - currentAction: 'preview-playtest failed', + currentAction: 'code-prototype failed', recentEvents: [ gameChatRuntimeEvent({ runId, @@ -6020,43 +6302,37 @@ export function registerProjectSupervisorSurfaceTests() { (stageRecordAppends()[0]?.[1] as { message?: { content?: string } }) ?.message?.content ?? '', ); - expect(stageRecord).toContain('6/7'); + expect(stageRecord).toContain('主阶段失败'); }); it('archives a terminal game-chat run restored during initial hydration exactly once', async () => { const projectPath = '/tmp/game-chat-stage-record-initial-terminal'; - const harness = createProjectSupervisorRuntimeHarness({ projectPath }); + const runId = 'game-chat-stage-record-initial-terminal-run'; + const mainRuntime = gameChatPlayableMainRuntime({ + parentRunId: runId, + revision: 11, + updatedAt: 9100, + }); + const harness = createProjectSupervisorRuntimeHarness({ + projectPath, + runtimeMapLoader: async () => [mainRuntime], + }); const manifest = createGameCreationAppManifest( 'game-chat-stage-record-initial-terminal', 'game-chat-stage-record-initial-terminal', ); manifest.tasks = manifest.tasks.map((task) => - isGameChatStageTask(task.id) + task.id === 'code-prototype' ? { ...task, status: 'completed' as const } : task, ); - const runId = 'game-chat-stage-record-initial-terminal-run'; const terminalRuntime = harness.runtimeState({ runId, + source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', currentAction: 'preview complete', - recentEvents: [ - gameChatRuntimeEvent({ - runId, - eventType: 'observation', - status: 'completed', - phase: 'tool-observation', - summary: 'preview.validate:ok · 试玩验证已通过', - detail: JSON.stringify({ - diagnosticsCount: 0, - passed: true, - playtestPassed: true, - revision: 11, - }), - updatedAt: 9100, - }), - ], + recentEvents: [], updatedAt: 9200, }); const invoke = vi.fn( @@ -6140,6 +6416,11 @@ export function registerProjectSupervisorSurfaceTests() { it('does not duplicate a historical terminal game-chat stage record during restart hydration', async () => { const projectPath = '/tmp/game-chat-stage-record-restart-hydration'; const runId = 'game-chat-stage-record-restart-run'; + const mainRuntime = gameChatPlayableMainRuntime({ + parentRunId: runId, + revision: 12, + updatedAt: 9100, + }); const terminalRuntime = gameChatRuntimeState({ sessionId: 'supervisor-session-active', runId, @@ -6153,13 +6434,13 @@ export function registerProjectSupervisorSurfaceTests() { 'game-chat-stage-record-restart-hydration', ); manifest.tasks = manifest.tasks.map((task) => - isGameChatStageTask(task.id) + task.id === 'code-prototype' ? { ...task, status: 'completed' as const } : task, ); const progress = buildGameChatProgressEvidence( terminalRuntime, - {}, + { 'code-prototype': mainRuntime }, manifest, ); if (!progress) { @@ -6173,6 +6454,7 @@ export function registerProjectSupervisorSurfaceTests() { const harness = createProjectSupervisorRuntimeHarness({ projectPath, initialRuntime: terminalRuntime, + runtimeMapLoader: async () => [mainRuntime], projectMessages: [ { role: 'assistant', @@ -6254,6 +6536,7 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ sessionId: terminalRuntime.sessionId, runId, + source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', currentAction: 'preview complete', @@ -6377,7 +6660,7 @@ export function registerProjectSupervisorSurfaceTests() { playableVersionReady = true; harness.setProjectRevision(1); - const firstPlayableRevision = gameChatPreviewPlaytestRuntime({ + const firstPlayableRevision = gameChatPlayableMainRuntime({ parentRunId: acceptedRunId, revision: 1, updatedAt: 3900, @@ -6387,6 +6670,7 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunId, runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', status: 'running', phase: 'verification', currentTask: '验证首个可玩版本', @@ -6435,12 +6719,13 @@ export function registerProjectSupervisorSurfaceTests() { initialRuntime: { sessionId: 'supervisor-session-active', runId, + source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', updatedAt: 9200, }, runtimeMapLoader: async () => [ - gameChatPreviewPlaytestRuntime({ + gameChatPlayableMainRuntime({ parentRunId: runId, revision, updatedAt: 9100, @@ -6649,7 +6934,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(acceptedRunIds).toHaveLength(1); }); harness.setProjectRevision(1); - const firstPreviewPassed = gameChatPreviewPlaytestRuntime({ + const firstPreviewPassed = gameChatPlayableMainRuntime({ parentRunId: acceptedRunIds[0], revision: 1, updatedAt: 3900, @@ -6659,6 +6944,7 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunIds[0], runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', status: 'running', phase: 'verification', currentTask: '验证第一版可玩原型', @@ -6688,6 +6974,7 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunIds[0], runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', currentTask: '第一版已经完成', @@ -6724,7 +7011,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(firstFrame.src).toBe(firstFrameUrl); harness.setProjectRevision(2); - const secondPreviewPassed = gameChatPreviewPlaytestRuntime({ + const secondPreviewPassed = gameChatPlayableMainRuntime({ parentRunId: acceptedRunIds[1], revision: 2, updatedAt: 7900, @@ -6734,6 +7021,7 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunIds[1], runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', status: 'completed', phase: 'completed', currentTask: '第二版已经完成', @@ -6893,7 +7181,7 @@ export function registerProjectSupervisorSurfaceTests() { ); await act(async () => { driver.harness.emitAgentRuntime( - gameChatPreviewPlaytestRuntime({ + gameChatPlayableMainRuntime({ parentRunId: driver.parentRunId, revision: 1, updatedAt: 1000, @@ -7085,7 +7373,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(acceptedRunId).toMatch(/^project-supervisor-task-/); }); harness.setProjectRevision(3); - const playableRevision = gameChatPreviewPlaytestRuntime({ + const playableRevision = gameChatPlayableMainRuntime({ parentRunId: acceptedRunId, revision: 3, updatedAt: 3900, @@ -7095,6 +7383,7 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunId, runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', status: 'running', phase: 'verification', updatedAt: 4000, @@ -7129,6 +7418,7 @@ export function registerProjectSupervisorSurfaceTests() { ); const previousIdleRuntime = harness.runtimeState({ runId: 'game-chat-previous-idle', + source: 'project-supervisor-game-chat', status: 'idle', phase: 'idle', updatedAt: 1000, @@ -7194,6 +7484,7 @@ export function registerProjectSupervisorSurfaceTests() { const state = harness.runtimeState({ runId: acceptedRunId, runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', status: allowCompletion ? 'completed' : 'running', phase: allowCompletion ? 'completed' : 'execution', currentTask: '生成 accepted run 首版游戏', @@ -7267,7 +7558,7 @@ export function registerProjectSupervisorSurfaceTests() { harness.setProjectRevision(4); await act(async () => { harness.emitAgentRuntime( - gameChatPreviewPlaytestRuntime({ + gameChatPlayableMainRuntime({ parentRunId: acceptedRunId, revision: 4, updatedAt: 4100, @@ -7393,7 +7684,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(acceptedRunId).toMatch(/^project-supervisor-task-/); }); harness.setProjectRevision(5); - const deniedPlayableRevision = gameChatPreviewPlaytestRuntime({ + const deniedPlayableRevision = gameChatPlayableMainRuntime({ parentRunId: acceptedRunId, revision: 5, updatedAt: 3900, @@ -7403,6 +7694,7 @@ export function registerProjectSupervisorSurfaceTests() { harness.runtimeState({ runId: acceptedRunId, runProfile: 'autonomous-game-build', + source: 'project-supervisor-game-chat', status: 'running', phase: 'verification', updatedAt: 4000, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 88702efe2..d6d029433 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-08-11 game-chat 单主前端投影统一使用 source-aware lineage + +- 投影身份:game-chat 前端只认 `project-supervisor` 且 source=`project-supervisor-game-chat` 的无父根 Run;其唯一 main 必须是 parent 指向该根、source=`agent-ready-task-scheduler` 的 `code-prototype`;动态美术 child 只允许 parent 指向该 main 的 `art-director | art-asset-plan`,source 为 `agent-delegate | agent-delegate-retry`。旧根直属美术、固定 DAG 节点、错误 source/parent/run 和并发冲突一律不进入当前轮投影。 +- 消费者边界:game-chat 主进度固定为 `0/1`、`1/1`、失败或待核对;专业 final-reply 同时要求角色 allowlist 与精确 run 谱系;可玩 revision 只由当前 main 的成功 `game.static_smoke` 之后、`passed=true + playtestPassed=true` 的结构化 `preview.validate` 建立,同 revision 后续失败优先。完整 16 任务 DAG 的投影与试玩 owner 不变。 +- retry 投影:动态美术 child,包括遗留 `agent-delegate-retry`,只作诊断展示且不提供通用 retry 控件;根终态时恢复提示固定引导用户继续 game-chat,由下一轮 main 重新审计缺口后首次委派。普通 DAG child 和非美术委派 retry 不受影响。 +- 归档边界:`【Supervisor 阶段记录】` 只有在 root、唯一 main、当前动态美术 children 与 manifest `code-prototype` 全部终态且无冲突/reconciliation 时才可写入。等待期间按完整 `agent/session/run` 身份保存 root-scoped Runtime 快照,避免 current-by-agent map 被新 root 覆盖后把新旧证据串线;消息 ID 固定绑定 root run,重载与 hydration 幂等。 +- 范围:M0B-2 只改前端纯投影、接线、文案与回归,不修改后端 DTO/schema/delivery/route,不迁移历史 manifest,也不提前实现 M1~M3。 + ## 2026-08-11 game-chat 动态美术 child 通用 retry 失败关闭 - 裁决:game-chat 动态美术 child(`art-director` / `art-asset-plan`,parent 为单主 `code-prototype`、root 绑定 `project-supervisor-game-chat` source)不允许走通用 Agent Runtime retry。retry 入口识别该类 child 后,在创建 successor run、delegationId、task journal 或其它 durable 副作用前直接返回类型化错误,引导用户继续 game-chat 对话:下一轮 main `code-prototype` 重新完成 `asset.list` 审计后,按仍存在的缺口建立新的 durable 美术委派。委派去重以 parent run 为键,跨轮自然放行;新委派产生新的 delegationId 与 durable delivery,沿用现行严格 lineage 判定,不新增 delivery 换绑、续发或可变机制。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index cc8d1fcfe..eeec3acaa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,12 @@ # 踩坑与排障记录 +## 2026-08-11 game-chat 阶段归档不能只保存 current-by-agent map + +- 现象:前端 Runtime map 以 Agent ID 保存当前记录。新一轮仍会复用 `code-prototype`、`art-director`、`art-asset-plan` 这些 Agent ID;若旧 root 已终态但 main/美术 child 或 manifest 仍在收口,直接从 live map 归档会在新 root 接管后丢失旧后代,或把新轮证据误接到旧阶段记录。 +- 原因:Agent ID 只是当前视图索引,不是跨 root 的运行身份。game-chat 的正式投影身份至少需要 `agentId + sessionId + runId + source + parentAgentId + parentRunId`,而阶段记录还必须绑定 source-bound root。 +- 修复:根进入终态时按完整 `agent/session/run` 身份保存 root-scoped Runtime 快照,后续只合并同一稳定身份的更新;manifest 快照只在该 root 仍为当前 root 时捕获。归档前重新执行严格 lineage、全终态、单 main/单 active art 与 reconciliation 门禁,并用 root run 派生稳定 message ID。 +- 防回归:测试必须覆盖 root 先终态、main 或 child 仍 active、main 仍等待 delegate receipts、manifest hydration 滞后、initial hydration 和历史阶段记录去重。完整 DAG 继续使用自己的投影,不能把 game-chat selector 反向推广为全局任务图真相。 + > 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。 ## 记录格式 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3707b31a7..d612969e8 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -34,7 +34,7 @@ - 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。game-chat 的自主链路中,Supervisor 持久化意图后只有 `code-prototype` 是主 Agent;它可能临时委派一个受限美术 child,后者只写 `assets/**`,回执返回同一主 Run 后由主 Agent 接入与验收。界面聚合当前 Supervisor 父 run、单主 Agent 及其直接美术 child 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。 - 公开消息硬门:模型仍负责 Supervisor / 专业 Agent 回复的业务语义,Runtime 不根据 tool 或 Provider 事件自行补写业务结论;但用户直接投递的 Project Supervisor 根后台任务必须先落为不可执行的 `preparing / public-status-pending`,再以 `runtime-public-status-*` 稳定 message ID 把“任务已接收,正在启动处理”写入项目 conversation,成功后才转为 `pending / queued`;恢复预检只读,只能在验证到同 run accepted 消息后把该任务临时分类为可恢复,真实 resume 持有 Agent 锁后才可持久提升为 `pending / queued`;写入失败则落为 `failed / public-status-write-failed`,不得继续执行。这些 Runtime 公开状态只供 UI 展示,prompt 构建器必须按稳定前缀排除。根 Supervisor 通过正式失败 / 预算耗尽收束或 game-chat 绝对硬期限进入 reconciliation 时,必须在 task、event、state 等其它终态投影之前先幂等写入一条脱敏、用户可理解的失败消息;专业 Agent 命中该全局硬期限时,也必须通过权威 Run Profile 和根 task 将同一根终态写入项目 conversation,同时保留 child 私有 Session 状态;状态文件本身写坏也不能导致零公开结果。当前 Runtime 自称根 agent/run 时,其 session 和两个 parent 字段必须与权威根 task 一致;任一身份冲突必须失败关闭,不得以另一 session 派生第二条项目终态。前端把该前缀识别为 Runtime-owned,同秒时排在触发它的 Supervisor 用户消息之后,不二次持久化;仅根 Supervisor 的 `turn.started / turn.failed / turn.budget_exhausted` 只保留在 Runtime 详情和进度投影中,不能再生成第二条聊天消息,专业 Agent 的公开启动事件仍可见。该硬门不改变 final-reply 的唯一性;非 Supervisor 专业 Agent 的失败消息继续留在对应 Agent Session,不把私有诊断写进项目 conversation。 - 启动恢复和续跑边界:本条取代上一条中“只有 accepted 才可恢复”的窄口径。若进程在 Supervisor 用户消息已持久、accepted 未持久之间崩溃,只读 preflight 可以把该 `preparing` 识别为可恢复,但不改写 task/conversation;真实 resume 持有 Agent 锁后必须先幂等补写 accepted,再提升为 `pending / queued`。用户消息或 accepted conversation 已落盘而辅助审计失败时,以 conversation 为公开真相继续入队,不留下“已接收但永不执行”的任务;根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。receipt / isolated-join 等带 parent 的 Supervisor continuation 不再另写 Session 终态,只保留单一后端公开事件;`runtime-task-*` 与 `runtime-public-status-*` 共享同 run 的不透明关联摘要,秒级时间戳下多个连续任务必须按实际 run 对应的 `user -> accepted -> terminal` 顺序交错展示。 -- Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。运行详情弹窗在项目或 run 身份切换的同次提交中同步关闭,不能由延迟 effect 关闭用户在新 run 状态可见后刚打开的弹窗。 +- Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡。game-chat 客户端只从当前 source-bound 根、唯一 `code-prototype` scheduler main、该 main 的动态美术 child、Supervisor 结构化计划和对应持久事件确定性整理,主阶段分母固定为 `0/1` 或 `1/1`;不再读取完整 DAG 的固定七节点,也不把旧根直属美术、错误 parent/source 或 `preview-playtest` child 混入当前轮。播报显示任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据;同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性。任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。运行详情弹窗在项目或 run 身份切换的同次提交中同步关闭,不能由延迟 effect 关闭用户在新 run 状态可见后刚打开的弹窗。 - ready-task 启动活性:`background_task.queued`、`autonomous_ready_task.scheduled`、Runner heartbeat 或执行锁已移交都不等于 child 已启动。实际持有执行权的 Runner 必须在释放项目写锁后同步写入 child 的 running task、`turn.started` 与 started journal,再把已启动 state 和 per-Agent 执行锁交给已确认开始轮询的独立 execution worker;同步启动或 worker 接管失败时,要在仍持有执行锁期间依次把 child 和 manifest Graph 节点明确落为 failed,再释放锁并让 parent 收到调度错误。`autonomous_ready_task.scheduled` 只作诊断审计,其写入失败不能阻断 durable child 启动;external client 只 wake Runner,不在客户端抢占执行。Supervisor 进度卡通过 durable `startedAt`(旧 Run 从完整 task journal 恢复,最新 task-record fallback 保持 0)显示真实持续时间,并以父 Run 与当前关联专业 Agent 的最大事件时间计算运行态活跃度:运行超过 5 分钟无新事件时显示“运行中 · 疑似停滞”和静默时长;等待用户、等待确认、Provider retry、视觉资产、进程会话、pausing 与 paused 不误报。父 Run terminal 后,持续时间冻结在父 Run 自身最后活动,不随 child 晚到收口事件增长。消息时间统一校验为 JavaScript 可表示的 Date;越界值显示“时间未知”且不写无效 `datetime`。实时回复只显示 response stream 自己的 `updatedAt`,缺失时同样显示“时间未知”,不能借用其它 Runtime 活动时间或随前端时钟漂移。该提示只提供可观测性,不改变 Runtime/manifest 正式状态。 - ready-task manifest 漂移:父 Supervisor 必须分别判断“能否调度新节点”和“是否存在必须等待的工作”。派生视觉需要父规划修复时不再调度新 child,但当前最新且活跃的根 Run 下,只要存在确定性 runId、scheduler source、正确父绑定且 durable journal 为 queued/running 的 ready child,父 Run 就保持 `waiting-for-manifest-tasks`,不能因旧 hydration 快照把 manifest running 覆盖成 pending 而提前 fixed-graph-stalled。game-chat child 可在相同严格身份下容忍 pending 漂移;正式产物、Canvas、revision、`game.static_smoke` 与 `preview.validate` 门禁不放宽。GUI/CLI、旧父 Run、终态、确认/用户输入/reconciliation、伪造绑定或非确定性 runId 全部失败关闭;更新根 Run 后旧 child 不得继续维持新 DAG 或投影完成。 - Supervisor 持久决策与单主条件美术:game-chat 的关键词、用户是否报告“美术未接入”、占位状态和当前资产探测只形成 `advisoryOnly=true` 的补充上下文,不得直接重置 Graph、预完成美术节点、选择复用/生成分支或继承历史试玩类型。当前根 Run 没有持久化 Supervisor 决策时,scheduler 不启动任何 child;Supervisor Provider 只通过 auto-safe 的 `agent.route_manifest` 提交 `game-chat-workflow-decision.v2`:`intentSummary` 是 Supervisor 自行理解并持久化的用户意图,`strategy=audit-existing-first` 只是固定安全执行策略,两者不得混用。此动作不能审计、生成、委派或替代后续判断,也不能把整体视觉重做解释成整套美术的强制重生成;成功后 Runtime 只启动唯一 `code-prototype` 主 Agent。升级恢复时严格校验 v1 sidecar 的旧 fingerprint,并从完成合同绑定的有效任务恢复 `intentSummary`;旧 `code-director` coverage/route 只作为迁移输入,不作为当前完成证据,必须由同一根 Run 的 `code-prototype` 重新 `asset.list` 后原位替换为单主合同。确定性 `code-prototype` Run 仅兼容已知 canonical task 文本版本,其余 task/binding/root 身份继续失败关闭;升级前已运行的 fixed-graph 美术 child 不再具备任何 mutation 或生图权限。主 Agent 必须以当前正式资产、Canvas 登记、私有图集合同、四张语义切片和 art manifest 判断真实缺口;完整覆盖时直接接入,不得生成或扣费。只有可证实缺失 `art-spec` 或核心 spritesheet 时,主 Agent 才可对相应 `art-director` 或 `art-asset-plan` 建立一条 durable 委派;每次最多一个活跃美术 child,child 仅可写 `assets/**`,不得修改 `game/**` 或接入/验收游戏。若两个槽位都缺失,必须先完成 `art-director`,由同一主 Run 认领其 `EvidenceReady` delivery 后,才能委派依赖规范图的 `art-asset-plan`;失败或未就绪 delivery 不得消耗不可重试的图集委派槽位。主 Agent 认领必要回执后继续同一 Run 完成素材接入、原玩法语义校验、`game.static_smoke` 与桌面/移动 `preview.validate`。绝对硬截止对嵌套美术 child 继续核验 `root -> code-prototype -> agent-delegate` 完整身份并保留未知外部生成的 reconciliation 证据。Runtime 只负责校验根/父子身份、当前 revision、路径、Canvas 登记、缺口/路由 fingerprint、写入范围及完成证据;纯“继续”仍走既有正式 continuation 合同,普通美术措辞不得借用更老项目的具体试玩场景。不得以增加 loop 预算、伪造 revision、机械改写 manifest 或重放历史图片 action 代替 Supervisor 决策和程序侧审计。 @@ -42,12 +42,12 @@ - ready-task 对账取消续跑:未知工具结果仍停在 `needs-reconciliation` 且禁止自动重放;人工核对后显式取消原 child,保留 cancel tombstone,旧 child 和旧父 Run 按真实终态收口。若随后创建同 Session、同 Supervisor source、同有效任务语义的 continuation,新完成合同只对同时具有历史 `failed / needs-reconciliation`、最终 `cancelled` 和 durable tombstone 的 ready-task,把当前 manifest 对应 failed 节点恢复为 pending,并由 scheduler 创建全新 child Run。manifest 的读取、failed 筛选、每任务一次的 child journal 索引、证据重验和写回必须位于同一项目写锁域;较新的无 child 根 Run 只有在 durable journal 精确表明为旧 failed Graph 在进入调度前即失败时才能跨过,scheduler 自身失败必须阻断借用更老 tombstone。普通失败、无 tombstone、不同 source/Session/任务语义或证据冲突均保持失败关闭;不得复活旧 pending action、补造 observation 或把取消任务标成 completed。 - 完成门静态分析预算:Canvas 视觉门必须先做只会提前拒绝的词法预检。经典或模块脚本同时不含大小写精确的 `import` 与 `export` 字节序列时,不运行模块依赖语义分析;纯 `export ... from` / `export * from` 仍须进入正式模块图分析。当前脚本不含目标文件名或任一已绑定 DOM 图片元素 ID 时,先低成本解码 `\\xNN`、`\\uNNNN`、`\\u{...}`、简单转义和续行;解码后仍无候选才不运行完整 Canvas alias / 函数可达性分析,解码不确定则保守进入 Oxc。存在任一候选时仍执行原 parser、semantic binding、解码后的 computed 属性/StringLiteral 路径、可达 `drawImage`、可见 Canvas、路径大小写和动态 namespace 写入门禁;HTML 中存在某个绑定元素不得使所有无关 JavaScript 单元进入重分析,禁止把词法命中当作通过条件。 - Provider 故障展示:Provider retry 的“是否可重试”继续使用 `upstream-5xx` 等稳定类别判断,但 durable retry record 保留安全的精确 `upstream-` 身份。等待态必须从真实 record 显示 HTTP 状态、`nextAttempt/maxRetries` 与当前持久退避剩余秒数,例如“Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 8 秒后重试”;不得以动画或前端自增计时伪造 attempt。重试耗尽的 Runtime 私有错误只保存 `kind/httpStatus/fingerprint/chars/retryAttempt/maxRetries/retryState`,前端和持久 conversation 仅在字段顺序、范围、状态一致且无尾随正文时派生“上游服务返回 HTTP 503;自动重试已耗尽(3/3)”;其它错误使用固定安全摘要。Provider 响应正文、URL/query、凭据、本地绝对路径、fingerprint、字符数和 `[redacted ...]` 占位符均不得进入用户可见消息。 -- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待唯一 `code-prototype` 主 Run 及其所有必要美术委派都已形成真实终态,再把本轮、主 Agent 进度、是否复用/补齐素材、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。父 run 先终态而 child 或 manifest 仍在 hydration 时不得以陈旧快照提前归档,要暂存终态 Runtime 并在状态刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 +- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待唯一 `code-prototype` 主 Run、该 main 的全部动态美术 child 与 manifest 中 `code-prototype` 都形成终态,且全链没有冲突或 `needs-reconciliation`,再把本轮、主 Agent 进度、是否复用/补齐素材、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。父 run 先终态而 child 或 manifest 仍在 hydration 时不得以陈旧快照提前归档;客户端以完整 `agent/session/run` 身份保留 root-scoped Runtime 快照并在状态刷新后重试,不能继续依赖会被新 run 覆盖的 current-by-agent map。页面初始 hydration 若直接读到缺少阶段记录的真实终态全链,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”使用稳定 message ID 最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。 - 图片成果:当前 manifest 新增或恢复已登记的 PNG / JPEG / WebP 资源时,聊天消息流同步显示 Runtime-owned “Supervisor 成果图片”卡,最多展示最新 4 张并随 manifest 原位更新。图片必须通过现有 `read_local_project_image_preview` 读取,只允许当前授权项目中 `assets/` 下的已登记资源,继续执行 `file.read` auto 权限、真实格式、大小、尺寸、普通文件、祖先目录和项目根边界校验;前端只接受返回路径、媒体类型和 `data:` 前缀与请求完全一致的结果。缩略图点击后使用独立模态查看器,支持按钮与滚轮缩放、指针拖拽、双击 / 按钮复位、Esc / 按钮 / 遮罩关闭,移动端占满视口;不得在聊天卡下方追加展开区。图片卡不写入 conversation,不解析 assistant 文本中的任意 Markdown / 绝对路径,也不开放 `.agent` 验收截图读取。 - Run 接管:External Runner 模式下首次提交可能返回“旧 canonical state + 新 `acceptedRunId`”;页面必须以 `acceptedRunId` 作为本轮权威身份,在 state 尚未切换时显示“已投递,正在同步 Agent Runner”,并允许该 run 的 Tauri event 或轮询结果接管。不得把旧 idle state 当作本轮结果、过滤新 run 事件,自动预览授权也必须绑定 `acceptedRunId`。 - 运行容器:当前项目没有由 Tauri 客户端 `PreviewRegistry` 返回的有效 `running` 预览时,页面只渲染聊天,不显示游戏区域或占位文案,顶部运行状态必须明确显示“预览未启动”,不得再使用含义不明的“未启动”;预览运行后自动显示 iframe,桌面端按“游戏 2 / 聊天 1”分栏,移动端改为上下布局。预览停止、失败或切换项目后立即移除 iframe。运行容器继续只接受当前授权项目的 `http://127.0.0.1:*`,复用现有 CSP、iframe sandbox、autoplay、fullscreen 和 gamepad 约束;远程 URL、`file://`、手填地址或陈旧 manifest 状态均不得显示。 - 预览进程归属:External Runner 与 Tauri 客户端位于不同进程,双方 `PreviewRegistry`、server 子进程句柄和 running 状态严格进程内隔离;Runner 为 `preview.validate` 持有或回收的预览进程不能作为用户可见预览,也不能据此伪造 Tauri registry 的 running 状态。game-chat 用户可见 preview 必须由 Tauri 客户端启动、持有和停止,iframe 只使用同一 Tauri registry 返回的 loopback URL。 -- 自动启动门禁:客户端只接受当前 accepted Supervisor 父 run 下真实 `preview-playtest` scheduler child 的结构化 `preview.validate` 事件,同 revision 采用最新事件且同时间失败优先;成功证据的 revision 必须精确等于当前项目原子 sidecar revision。客户端向 Tauri `preview.start` 传入 `expectedRevision`,后端在取得项目写锁后再次比对再启动,以闭合检查 / 启动 TOCTOU。same-run steer 的一次性授权使用带 revision / validation cursor 和唯一 generation ID 的 v2 记录,旧事件和旧异步 attempt 不得消费后续授权。旧 attempt 返回后的补偿清理按完整 preview identity 原子停止 registry server,不能让项目 stop policy 或写锁竞争造成后台 server 泄漏;若同项目新 server 已接管,则不能把其持久状态覆盖为 stopped。 +- 自动启动门禁:客户端只接受当前 accepted、source-bound Supervisor 父 run 下唯一 `code-prototype` scheduler main 本人的证据序列:该 main 先成功执行 `game.static_smoke`,随后产生结构化 `preview.validate`,且 detail 同时满足 `passed=true`、`playtestPassed=true` 和正整数 revision。同 revision 采用最新事件且失败优先;错误 main/source/parent、旧固定 `preview-playtest` child、根 Supervisor、动态美术 child 和 smoke 之前的 preview 都不能建立可玩 revision。成功证据的 revision 必须精确等于当前项目原子 sidecar revision。客户端向 Tauri `preview.start` 传入 `expectedRevision`,后端在取得项目写锁后再次比对再启动,以闭合检查 / 启动 TOCTOU。same-run steer 的一次性授权使用带 revision / validation cursor 和唯一 generation ID 的 v2 记录,旧事件和旧异步 attempt 不得消费后续授权。旧 attempt 返回后的补偿清理按完整 preview identity 原子停止 registry server,不能让项目 stop policy 或写锁竞争造成后台 server 泄漏;若同项目新 server 已接管,则不能把其持久状态覆盖为 stopped。 - 固定试玩契约:`generic-v1` 初始状态必须为 `ready` 且 `level > 0`;点击 start 后 sequence 必须推进、phase 必须进入 `playing`,并先持续观察 2 秒、取得至少 8 个实际样本,期间保持 `playing`,以确认玩家获得正常操作机会。随后必须点击唯一可见、启用且真实可交互的 `data-playtest-id="primary-action"` 控件;该控件必须映射游戏的真实主要玩法操作,并以 sequence 相对点击前严格推进证明操作已被接受。玩家获得这次正常操作机会之前进入 `won | lost` 属于过早结束并失败;操作被接受后的单次 `lost` 是合法游戏结局,但不能成为所有受控尝试的唯一结果;若主要操作后仍为 `playing`,则继续观察 3 秒并取得至少 12 个实际样本,`won` 可提前证明非失败推进。点击 restart 后 sequence 必须再次推进并恢复到 `ready | playing`,随后持续观察 3 秒且取得至少 12 个实际样本。若首轮结果为 `lost`,重开稳定后必须再执行一次必要的 start、2 秒 / 8 样本操作机会和真实 primary-action;第二次必须进入或保持 `playing`(再观察 3 秒 / 12 样本且不得转为 `lost`)或进入 `won`,两次都固定 `lost` 代表无法正常推进的恶性 bug,必须失败。各观察窗口内 sequence 不得回退,restart 窗口只能保持 `ready | playing`;样本数门槛不能替代时长门槛,窗口末端必须强制再读取一次有效状态,不能只在前段快速取得足够样本后提前通过。控件 selector、观察时长、最少样本数、终态边界、非失败推进、末端覆盖、sequence 单调 / 严格推进规则及完整 required assertions 都进入 scenario fingerprint。读取旧 fingerprint 回执和检查 plan liveness 时,把合同升级造成的 fingerprint 不匹配视为 stale missing,允许同一 run 重新执行 `preview.validate` 自愈;身份、路径、digest 或内容完整性篡改仍失败关闭。最终完成门每次按当前合同重算 fingerprint,并严格拒绝旧 fingerprint、旧 assertion 集或仅保存历史 `passed=true` 的证据。 - 试玩证据展示:game-chat 的进度卡、可玩 revision 和自动预览只接受结构化 `preview.validate` detail 同时满足 `passed=true` 与 `playtestPassed=true`;工具 summary 中的 `:ok` 不能作为兜底。`image.inspect` 的 `status=ok` 只代表工具执行成功,不代表视觉验收通过;结构化 `passed=null` 或缺少布尔结论时,UI 必须以中性“截图分析完成”展示,只有显式 `passed=true` 才能显示“截图检查通过”。 - 一次性自动预览授权:用户在该入口成功提交本轮自主生成需求,即视为对“当前项目 + 当前 Supervisor 父 run”的一次 `preview.start` 授权。授权以仅含项目路径与 accepted parent runId 的客户端本地记录持久化,App / WebView 重启后仍可恢复,但项目或 run 身份不匹配时不得使用。只有当前 accepted parent run 成功完成 `preview.validate` 且给出有效 revision 后,客户端才可消费授权,由 Tauri 首次启动并自动展示该 revision 的用户可见预览;一次授权最多成功启动一个 Tauri preview server,并必须继续走现有权限、项目写锁、审计和客户端 `PreviewRegistry` 链路。项目或 Agent 策略的显式 deny 始终优先,不得被此授权绕过。启动成功、显式 deny、非瞬时失败、父 run 在首版验证前终止或切换项目后授权失效;`preview.start` 恰逢项目写锁竞争属于瞬时失败,不消费授权,释放写锁后由同一轮询链路重试。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index f919a943b..e12c20e3c 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -1428,6 +1428,7 @@ M0 文档 PR 本身最低验证:Markdown 结构与三张 Mermaid 图可解析 | owner 产物验证 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs` | M0-3 / M0A-2 让四个 pre-code fixed owner 复用 canonical 路径映射,由 Runtime 内部验固定产物;Provider 不见验证工具,code / preview 节点继续真实 smoke | | approvedGddRef | 当前仓库无匹配实现 | M2 从 command 到 task/run/completion/context 全链新增 | | game-chat retry 边界 | `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs:7-39,120-134`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/lifecycle_control.rs:740-772,885-905` | M0-4 / 工作包 M0B-1 统一 delegate/retry assets-only lineage | +| game-chat 前端投影 | `apps/ai-game-creator-shell/src/features/agent-runtime/gameChatRuntimeProjection.ts`、`src/features/agent-runtime/model.ts`、`src/features/project-workspace/SupervisorChatOnlyView.tsx`、`src/App.tsx` | M0-4 / 工作包 M0B-2 只认 root → 单主 `code-prototype` → 动态美术 child 的 source-aware lineage;进度、final-reply、可玩 revision、retry 控件与阶段归档共用该身份边界 | ## 23. 分期门禁与完成定义 @@ -1450,6 +1451,8 @@ M0A-1 是 M1 详细设计与技术 spike 的输入,不是功能交付。M1 可 `M0B-1` 先封闭 game-chat 动态美术 delegate/retry 的安全边界:首次 `agent-delegate` 继续按严格 delivery/route/Canvas lineage 只写 `assets/**`;该类 child 的通用 retry 在入队前返回 `kind=game-chat-dynamic-art-retry-unsupported`,遗留或伪造的 `agent-delegate-retry` 只读且全部 mutation 失败关闭。恢复只走跨轮:用户继续 game-chat 对话,由下一轮 main `code-prototype` 重新审计并按仍存在的缺口创建新的首次委派;同一 main run 对同一 target 的第二次委派继续拒绝,新一轮以新 parent run、新 delegationId、targetRunId 与 delivery 全链恢复。`M0B-2` 再以 source-aware lineage 修复单主进度、最终回复、可玩 revision 与归档投影。M0-4 不阻塞 M1 策划闭环开工,但阻塞 M3 game-chat 接入和“M0 全部完成”。M0-1~M0-4 全部合入并通过各自门禁后,才能标记“M0 全部完成”。 +2026-08-11 的 M0B-2 落地固定为 presentation-only:新增纯投影模块,结构身份严格限定为 `project-supervisor-game-chat` root → `agent-ready-task-scheduler` 的唯一 `code-prototype` main → 该 main 下 `agent-delegate | agent-delegate-retry` 的 `art-director | art-asset-plan`;retry source 只作遗留诊断展示,不恢复通用 retry 控件。game-chat 进度只显示主阶段 `0/1`、`1/1`、失败或待核对;final-reply 除角色 allowlist 外还必须匹配精确 run 谱系;可玩 revision 只由当前 main 成功 smoke 后的结构化双视口试玩通过建立,同 revision 后续失败优先;阶段记录须等待 root、main、动态 child 和 manifest 主任务终态且无 reconciliation,并使用 root-scoped Runtime 快照与稳定 root-run message ID 幂等归档。该工作包不修改后端 DTO/schema/delivery/route,不迁移历史 manifest,也不实现 M1~M3。 + ## 24. 最终不变量摘要 - 策划与构建是两个不同 profile/source 的 run,由用户动作连接,不由 Agent 自行升级。