diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs index 3386b80b7..57ecd9c8d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs @@ -131,6 +131,8 @@ pub(crate) struct PlanningSessionCommandResultV2 { pub result: Option, pub current_artifact: Option, pub replayed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation: Option>, } #[derive(Clone, Debug, Serialize)] @@ -705,6 +707,7 @@ where result: Some(result), current_artifact: current_planning_artifact_v2(root)?, replayed: true, + conversation: None, }); } let session_id = start.session.session_id.clone(); @@ -765,6 +768,7 @@ where }), current_artifact: current_planning_artifact_v2(root)?, replayed: false, + conversation: None, }); } }; @@ -828,6 +832,7 @@ where }), current_artifact: current_planning_artifact_v2(root)?, replayed: false, + conversation: None, }); } } @@ -878,6 +883,7 @@ where result: Some(persisted.result), current_artifact: persisted.current_artifact, replayed: false, + conversation: None, }) } Err(detail) => { @@ -909,6 +915,7 @@ where }), current_artifact: current_planning_artifact_v2(root)?, replayed: false, + conversation: None, }) } } @@ -1021,6 +1028,7 @@ pub(crate) fn hydrate_planning_session_v2( result: None, current_artifact: current_planning_artifact_v2(&root)?, replayed: false, + conversation: Some(read_planning_messages_v2(&root)?), })) } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 80481f51b..f3a0472a4 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -227,6 +227,15 @@ import { } from './features/project-workspace/memoryCommands'; import { pendingCommandDetail } from './features/project-workspace/pendingCommandPresentation'; import { planningStateNeedsRuntimeRefresh } from './features/project-workspace/planningLane'; +import { + type PlanningApprovalCommandResultV2, + planningMessagesToChatMessages, + planningResultDisplayText, + type PlanningSessionCommandResultV2, + type PlanningSessionStreamEventV2, + planningSessionToPlanGddState, + planningSessionToRuntime, +} from './features/project-workspace/planningSessionV2'; import { isAgentTraceFilePath, isProjectPolicyConfirmableCommandId, @@ -459,6 +468,9 @@ export function App({ onMakeGameFromApprovedGdd, }: AppProps = {}) { const { setTitle: setWindowTitle } = useWindowChrome(); + const [planningV2Active, setPlanningV2Active] = useState(planningStartMode); + const planningV2ActiveRef = useRef(planningStartMode); + planningV2ActiveRef.current = planningV2Active; // 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于 // Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。 // 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。 @@ -466,7 +478,8 @@ export function App({ DIRECT_CODEX_PRODUCT_RUNTIME && projectSupervisorOnly && !supervisorChatOnly && - !planningStartMode; + !planningStartMode && + !planningV2Active; const [devMode] = useState(() => projectSupervisorOnly ? false : isDeveloperMode(), ); @@ -610,6 +623,17 @@ export function App({ ); const planGddStateRef = useRef(null); planGddStateRef.current = planGddState; + const [planningV2Session, setPlanningV2Session] = + useState(null); + const planningV2SessionRef = useRef( + null, + ); + planningV2SessionRef.current = planningV2Session; + const [planningV2TransientReply, setPlanningV2TransientReply] = useState(''); + const planningV2TurnRef = useRef<{ + projectPath: string; + clientTurnId: string; + } | null>(null); const planGddHydrateSequenceRef = useRef(0); const [planGddHydrateBusy, setPlanGddHydrateBusy] = useState(false); const [planGddDecisionBusy, setPlanGddDecisionBusy] = useState(false); @@ -625,6 +649,84 @@ export function App({ >(), ); + function applyPlanningV2CommandResult( + result: PlanningSessionCommandResultV2, + clientTurnId?: string, + ) { + planningV2ActiveRef.current = true; + setPlanningV2Active(true); + planningV2SessionRef.current = result; + setPlanningV2Session(result); + projectSupervisorSessionIdRef.current = result.session.sessionId; + setProjectSupervisorSessionId(result.session.sessionId); + setPlanGddState( + planningSessionToPlanGddState(result.session, result.currentArtifact), + ); + updateProjectSupervisorRuntime(planningSessionToRuntime(result.session)); + const sessionError = result.session.lastError?.summary ?? ''; + setProjectSupervisorRuntimeError(sessionError); + if (result.conversation) { + const conversationMessages = planningMessagesToChatMessages( + result.conversation, + ); + setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); + setMessages(conversationMessages); + savedConversationProjectPathRef.current = localProjectPathRef.current; + savedConversationCountRef.current = conversationMessages.length; + latestMessagesRef.current = conversationMessages; + } else if (result.result && clientTurnId) { + const displayText = planningResultDisplayText( + result.result, + result.currentArtifact, + ); + if (displayText.trim()) { + const messageId = `planning-v2:${clientTurnId}:assistant`; + setMessages((current) => { + const existingIndex = current.findIndex( + (message) => message.messageId === messageId, + ); + const nextMessage: ChatMessage = { + role: 'assistant', + text: displayText, + runtimeOwned: true, + messageId, + updatedAt: Date.now(), + }; + if (existingIndex < 0) { + return [...current, nextMessage]; + } + return current.map((message, index) => + index === existingIndex ? nextMessage : message, + ); + }); + } + } + } + + async function hydratePlanningV2Session(nextProjectPath: string) { + const invoke = resolveTauriInvoke(); + if (!invoke || !nextProjectPath.trim()) { + return null; + } + const sessionId = planningV2SessionRef.current?.session.sessionId; + const result = await invoke( + 'hydrate_planning_session_v2', + { + projectPath: nextProjectPath, + ...(sessionId ? { sessionId } : {}), + }, + ); + if (!result) { + if (planningStartMode) { + planningV2ActiveRef.current = true; + setPlanningV2Active(true); + } + return null; + } + applyPlanningV2CommandResult(result); + return result; + } + const hydratePlanGddState = useCallback( async (nextProjectPath?: string) => { const targetProjectPath = @@ -634,6 +736,42 @@ export function App({ setPlanGddState(null); return; } + if ( + projectSupervisorOnly && + !planningV2ActiveRef.current && + !planningStartMode + ) { + try { + const existingV2 = + await invoke( + 'hydrate_planning_session_v2', + { projectPath: targetProjectPath }, + ); + if (existingV2) { + applyPlanningV2CommandResult(existingV2); + return; + } + } catch { + // Fall through to the legacy read for projects without V2 authority. + } + } + if (planningV2ActiveRef.current || planningStartMode) { + const requestSequence = ++planGddHydrateSequenceRef.current; + setPlanGddHydrateBusy(true); + setPlanGddError(null); + try { + await hydratePlanningV2Session(targetProjectPath); + } catch (error) { + if (requestSequence === planGddHydrateSequenceRef.current) { + setPlanGddError(String(error)); + } + } finally { + if (requestSequence === planGddHydrateSequenceRef.current) { + setPlanGddHydrateBusy(false); + } + } + return; + } const requestSequence = ++planGddHydrateSequenceRef.current; setPlanGddHydrateBusy(true); setPlanGddError(null); @@ -668,6 +806,9 @@ export function App({ } } }, + // V2 helpers intentionally read current refs/setters; keeping this callback + // stable prevents hydrate effects from running on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps [projectPath], ); @@ -703,6 +844,33 @@ export function App({ setPlanGddDecisionBusy(true); setPlanGddError(null); try { + if (planningV2ActiveRef.current || planningStartMode) { + const planningSessionId = current.session?.sessionId; + if (!planningSessionId) { + throw new Error('Planning V2 Session 不存在'); + } + const approval = await invoke( + 'decide_planning_artifact_v2', + { + projectPath: targetProjectPath, + sessionId: planningSessionId, + artifactId: pending.gddRef.gddId, + version: pending.gddRef.version, + fingerprint: pending.gddRef.fingerprint, + decisionId: responseId, + action, + comment, + }, + ); + applyPlanningV2CommandResult({ + session: approval.session, + result: null, + currentArtifact: approval.currentArtifact, + replayed: approval.replayed, + }); + setPlanGddError(null); + return; + } await invokeDiagnostic(invoke, 'decide_game_creator_plan_gdd', { projectPath: targetProjectPath, gddId: pending.gddRef.gddId, @@ -728,6 +896,8 @@ export function App({ setPlanGddDecisionBusy(false); } }, + // V2 decision handling also reads the current session through refs. + // eslint-disable-next-line react-hooks/exhaustive-deps [hydratePlanGddState, localProject, projectPath], ); @@ -1084,12 +1254,18 @@ export function App({ projectSupervisorRuntimeRef.current = null; projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; + planningV2SessionRef.current = null; + planningV2TurnRef.current = null; projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); setProjectSupervisorRuntime(null); setProjectSupervisorExpectedRunId(null); setProjectSupervisorResponseStream(null); setProjectSupervisorRuntimeError(''); + setPlanningV2Session(null); + setPlanningV2TransientReply(''); + setPlanningV2Active(planningStartMode); + planningV2ActiveRef.current = planningStartMode; } function syncTerminalProjectSupervisorConversation( @@ -1183,7 +1359,10 @@ export function App({ } localProjectPathRef.current = initialProjectPath; void loadProjectConversation(initialProjectPath).finally(() => { - if (localProjectPathRef.current === initialProjectPath) { + if ( + localProjectPathRef.current === initialProjectPath && + !planningStartMode + ) { void refreshAgentRunTrace(initialProjectPath); } }); @@ -1327,7 +1506,7 @@ export function App({ useEffect(() => { const listen = window.__TAURI__?.event?.listen; const invoke = resolveTauriInvoke(); - if (!listen || directCodexProductRuntime) { + if (!listen || directCodexProductRuntime || planningV2Active) { return; } let cleanup: (() => void) | null = null; @@ -1429,11 +1608,60 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ directCodexProductRuntime, + planningV2Active, refreshManifest, updateProjectSupervisorResponseStream, updateProjectSupervisorRuntime, ]); + useEffect(() => { + const listen = window.__TAURI__?.event?.listen; + if (!listen || !planningV2Active) { + return; + } + let cleanup: (() => void) | null = null; + let disposed = false; + void listen( + 'planning-session-v2-stream', + (event) => { + const payload = event.payload; + const trackedTurn = planningV2TurnRef.current; + if (!trackedTurn || payload.clientTurnId !== trackedTurn.clientTurnId) { + return; + } + if (payload.status === 'started' || payload.status === 'delta') { + setPlanningV2TransientReply( + payload.accumulatedText || payload.deltaText, + ); + setProjectSupervisorRuntimeError(''); + } + if (payload.status === 'failed' && payload.error?.summary) { + setProjectSupervisorRuntimeError(payload.error.summary); + } + }, + ) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + }) + .catch((error) => { + if (!disposed) { + setProjectSupervisorRuntimeError( + `Planning V2 实时回复不可用:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }); + return () => { + disposed = true; + cleanup?.(); + }; + }, [planningV2Active]); + useEffect(() => { const listen = window.__TAURI__?.event?.listen; if (!listen) { @@ -1475,6 +1703,7 @@ export function App({ if ( !invoke || directCodexProductRuntime || + planningV2Active || !nextProjectPath || !sessionId || !trackedRunId || @@ -1570,6 +1799,7 @@ export function App({ }, [ localProject?.projectPath, directCodexProductRuntime, + planningV2Active, projectSupervisorSessionId, projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, @@ -1584,6 +1814,7 @@ export function App({ if ( !projectSupervisorOnly || directCodexProductRuntime || + planningV2Active || !invoke || !nextProjectPath || !supervisorRunId @@ -1664,6 +1895,7 @@ export function App({ }, [ localProject?.projectPath, directCodexProductRuntime, + planningV2Active, projectSupervisorOnly, projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, @@ -1673,7 +1905,10 @@ export function App({ useLayoutEffect(() => { if ( (!supervisorChatOnly && - !(projectSupervisorOnly && directCodexProductRuntime)) || + !( + projectSupervisorOnly && + (directCodexProductRuntime || planningV2Active) + )) || !supervisorChatShouldFollowLatestRef.current ) { return; @@ -1692,6 +1927,7 @@ export function App({ directCodexTransientReply, directCodexTransientReplyUpdatedAt, directCodexProductRuntime, + planningV2Active, projectSupervisorOnly, supervisorChatOnly, ]); @@ -2639,6 +2875,57 @@ export function App({ const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; try { + // V2 projects do not have a Supervisor run or legacy conversation. Probe the + // V2 authority first; a missing V2 session returns null and preserves the + // existing open-project behavior for older projects. + let planningV2: PlanningSessionCommandResultV2 | null = null; + if (projectSupervisorOnly || planningStartMode) { + try { + planningV2 = await invoke( + 'hydrate_planning_session_v2', + { projectPath: nextProjectPath }, + ); + } catch (error) { + if (planningStartMode) { + throw error; + } + // Older projects/binaries may not expose V2 yet; continue with the + // legacy read path unless the caller explicitly entered planning V2. + } + } + if (planningStartMode || (projectSupervisorOnly && planningV2)) { + if ( + projectSupervisorHistoryLoadVersionRef.current !== loadVersion || + localProjectPathRef.current !== nextProjectPath + ) { + return; + } + planningV2ActiveRef.current = true; + setPlanningV2Active(true); + if (planningV2) { + applyPlanningV2CommandResult(planningV2); + } else { + planningV2SessionRef.current = null; + setPlanningV2Session(null); + setPlanGddState(null); + projectSupervisorSessionIdRef.current = null; + setProjectSupervisorSessionId(null); + updateProjectSupervisorRuntime(null); + setMessages(createDefaultChatMessages()); + savedConversationProjectPathRef.current = nextProjectPath; + savedConversationCountRef.current = 0; + latestMessagesRef.current = []; + } + setProjectSupervisorRuntimeError( + planningV2?.session.lastError?.summary ?? '', + ); + setWorkspaceStatus((workspaceStatus) => + workspaceStatus === '等待确认' + ? `已打开:${nextProjectPath}` + : workspaceStatus, + ); + return; + } const resumeError = directCodexProductRuntime ? '' : await resumeProjectSupervisorRuntimeTasksIfNeeded( @@ -5388,12 +5675,101 @@ export function App({ } } + async function executePlanningV2Turn( + nextProjectPath: string, + prompt: string, + clientTurnId = createAgentChatRunId('planning-v2-turn'), + ) { + const invoke = resolveTauriInvoke(); + const normalizedPrompt = prompt.trim(); + if (!invoke) { + setProjectSupervisorRuntimeError('需要在 Tauri App 内运行。'); + return; + } + if (!nextProjectPath.trim() || !normalizedPrompt) { + return; + } + const currentSessionId = planningV2SessionRef.current?.session.sessionId; + planningV2TurnRef.current = { + projectPath: nextProjectPath, + clientTurnId, + }; + setChatAgentBusy(true); + setProjectSupervisorRuntimeError(''); + setPlanningV2TransientReply(''); + try { + const result = currentSessionId + ? await invoke( + 'continue_planning_session_v2', + { + projectPath: nextProjectPath, + sessionId: currentSessionId, + clientTurnId, + input: { text: normalizedPrompt }, + }, + ) + : await invoke( + 'start_planning_session_v2', + { + projectPath: nextProjectPath, + clientTurnId, + prompt: normalizedPrompt, + mode: 'gdd', + }, + ); + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + applyPlanningV2CommandResult(result, clientTurnId); + setCommandLog((current) => [ + ...current, + currentSessionId ? 'planning.v2.continue' : 'planning.v2.start', + ]); + } catch (error) { + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + const message = error instanceof Error ? error.message : String(error); + if (isRuntimeConfigMissingError(message)) { + requestRuntimeConfigOpen(); + } + setProjectSupervisorRuntimeError(message); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: message, + runtimeOwned: true, + messageId: `planning-v2:${clientTurnId}:error`, + updatedAt: Date.now(), + }, + ]); + } finally { + planningV2TurnRef.current = null; + setPlanningV2TransientReply(''); + setChatAgentBusy(false); + } + } + async function executeChatAgentReply({ prompt, clientTurnId: directConversationTurnId, creationType, attachments, }: ExecuteChatAgentReplyInput) { + if (planningV2ActiveRef.current || planningStartMode) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { + setProjectSupervisorRuntimeError('请先初始化本地项目'); + return; + } + await executePlanningV2Turn( + nextProjectPath, + prompt, + directConversationTurnId ?? createAgentChatRunId('planning-v2-turn'), + ); + return; + } // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. if (directCodexProductRuntime) { @@ -6235,6 +6611,27 @@ export function App({ answers: Record, ) { const nextProjectPath = resolveChatProjectPath(localProject); + if (planningV2ActiveRef.current || planningStartMode) { + const current = planningV2SessionRef.current; + const currentQuestion = current?.session.currentQuestion; + const answer = Object.values(answers) + .map((value) => value.trim()) + .find((value) => value.length > 0); + if ( + !nextProjectPath || + !current || + !currentQuestion || + request.sessionId !== current.session.sessionId || + request.questions[0]?.id !== currentQuestion.id || + !answer || + chatAgentBusy + ) { + setProjectSupervisorRuntimeError('待回答问题已变更,请刷新策划状态'); + return; + } + await executePlanningV2Turn(nextProjectPath, answer, responseId); + return; + } const runtime = projectSupervisorRuntimeRef.current; const sessionId = projectSupervisorSessionIdRef.current; const currentRequest = runtime?.userInputRequest; @@ -10829,6 +11226,26 @@ export function App({ void loadProjectConversation(nextProjectPath, false, 'replace'); return; } + if (planningV2ActiveRef.current || planningStartMode) { + if (!prompt || chatAgentBusy) { + return; + } + supervisorChatShouldFollowLatestRef.current = true; + const clientTurnId = createAgentChatRunId('planning-v2-turn'); + setChatInput(''); + setMessages((current) => [ + ...current, + { + role: 'user', + text: prompt, + runtimeOwned: true, + messageId: `planning-v2:${clientTurnId}:user`, + updatedAt: Date.now(), + }, + ]); + void executeChatAgentReply({ prompt, clientTurnId }); + return; + } if (supervisorChatOnly || directCodexProductRuntime) { supervisorChatShouldFollowLatestRef.current = true; } @@ -10892,9 +11309,11 @@ export function App({ runtimeConfigOpen={runtimeConfigOpen} runtimeError={projectSupervisorRuntimeError} transientReply={ - directCodexProductRuntime - ? directCodexTransientReply - : projectSupervisorTransientReply + planningV2Active + ? planningV2TransientReply + : directCodexProductRuntime + ? directCodexTransientReply + : projectSupervisorTransientReply } hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} @@ -10931,9 +11350,11 @@ export function App({ pendingCommand={directCodexProductRuntime ? pendingCommand : null} projectPath={localProject?.projectPath ?? projectPath} transientReply={ - directCodexProductRuntime - ? directCodexTransientReply - : projectSupervisorTransientReply + planningV2Active + ? planningV2TransientReply + : directCodexProductRuntime + ? directCodexTransientReply + : projectSupervisorTransientReply } visibleMessages={visibleMessages} visibleProfessionalAgentCards={visibleProfessionalAgentCards} @@ -10947,6 +11368,7 @@ export function App({ planGddError={planGddError} onPlanGddRefresh={() => void hydratePlanGddState()} onPlanGddDecision={decidePlanGdd} + planningLane={planningV2Active} onMakeGameFromApprovedGdd={ onMakeGameFromApprovedGdd ? () => diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 6ff949606..a8d3a729f 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -185,6 +185,7 @@ export interface PlanGddStateViewV1 { | 'rejected' | 'recovery_required'; clarificationRound: number; + questionLimit?: number | null; repairDepth: number; accumulatedAgentMillis: number; activeRunId: string | null; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 94cfb07e0..4049914b1 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -141,14 +141,16 @@ export function PlanGddStageProgress({ state.versions[state.versions.length - 1]?.gddRef.version ?? null; const answeredRounds = state.session?.clarificationRound ?? 0; - // `clarificationRound` 是 0-indexed 的「已答轮数」:`static_delegate_lineage_counters` - // 排除目标自身、只数祖先里的澄清跳数,后端判上限用的也是 `current_round + 1`。所以直接 - // 按「轮次 X/3」渲染会整体差一格——问最后一轮时显示「轮次 2/3」,字面暗示还剩一轮。 - // 等待回答时 `latestDelegationId` 就是当前那条 delivery,+1 恰好是正在问的轮次;其余 - // 状态(含 `awaitingAnswerFor` 为 null 的恢复态)退回「已完成」表述,不去猜当前轮。 + const questionLimit = state.session?.questionLimit ?? 3; + const questionLimitLabel = + questionLimit === null ? '不限' : String(questionLimit); + // `clarificationRound` 是已完成的问询数;等待回答时加一表示当前正在展示的问题, + // 避免把「已答 N 个」误显示成当前第 N 轮。旧状态没有 questionLimit 时沿用旧 UI 的 3 轮 + // 文案,V2 状态直接使用会话快照里的策略值。 const roundLabel = state.session?.awaitingAnswerFor - ? `第 ${answeredRounds + 1} 轮 / 共 3 轮` - : `已完成 ${answeredRounds}/3 轮澄清`; + ? `第 ${answeredRounds + 1} 轮 / 共 ${questionLimitLabel} 轮` + : `已完成 ${answeredRounds}/${questionLimitLabel} 轮澄清`; + const processingSeconds = (state.session?.accumulatedAgentMillis ?? 0) / 1000; // 批准之后审批卡整张收掉,交付出口就落在这条标题栏上:GDD 的 Markdown 已经在项目 // 里(提交时渲染、审批回执重渲染带上 approved 头),这里只是把它指出来并交给系统 // 打开。恢复态不给出口——那时权威投影还没收敛,路径上的内容可能不是用户批的那版。 @@ -170,6 +172,9 @@ export function PlanGddStageProgress({ ? '当前版本:草稿' : `当前版本:v${latestVersion}`} + {processingSeconds > 0 ? ( + {`处理耗时:${processingSeconds.toFixed(1)} 秒`} + ) : null} {deliveredGdd ? (
void; onPlanGddDecision: ( action: PlanGddDecisionAction, @@ -98,11 +99,14 @@ export function ProjectSupervisorView({ planGddHydrateBusy, planGddDecisionBusy, planGddError, + planningLane = false, onPlanGddRefresh, onPlanGddDecision, onMakeGameFromApprovedGdd, ...runtimePanelProps }: ProjectSupervisorViewProps) { + const planningSurfaceActive = + planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime); const submitLabel = needsUserInput ? '等待回答' : runtimePanelProps.controlBusy @@ -117,7 +121,7 @@ export function ProjectSupervisorView({
) : null}
- {directCodex ? null : isPlanningLaneRuntime( - runtimePanelProps.runtime, - ) ? ( + {directCodex ? null : planningSurfaceActive ? ( ; +}; + +export type PlanningSessionV2 = { + schemaVersion: 'planning-session.v2'; + engine: 'planning-session-v2'; + sessionId: string; + projectId: string; + mode: string; + status: string; + turnIndex: number; + questionCount: number; + questionLimit: number | null; + revisionCount: number; + currentArtifactVersion: number | null; + currentQuestion: PlanningQuestionV2 | null; + capabilities: { tools: string[]; skills: string[] }; + processingSeconds: number; + createdAtUtc: string; + updatedAtUtc: string; + lastError: { code: string; summary: string } | null; +}; + +export type PlanningMessageV2 = { + schemaVersion: 'planning-message.v2'; + messageId: string; + clientTurnId: string; + turnIndex: number; + atUtc: string; + role: 'user' | 'assistant' | 'system' | 'tool'; + kind: string; + payload: Record; +}; + +export type PlanningTurnResultV2 = { + schemaVersion: 'planning-turn-result.v2'; + kind: 'question' | 'artifact' | 'assistant_text' | 'error' | string; + payload: Record; +}; + +export type PlanningGddPayloadV2 = { + schemaVersion: string; + projectId: string; + gddId: string; + version: number; + createdAtUtc: string; + game: { + title: string; + oneLiner: string; + genre: { primary: string; fusion: string | null }; + artStyle: { + visualType: string; + keywords: string[]; + moodAndColor: string; + mvpArtBoundary: string; + }; + pillars: Array<{ + name: string; + playerFeel: string; + mechanism: string; + decisionState: string; + basis: null; + }>; + coreLoop: string[]; + targetUsers: { + coreUsers: string; + preferences: string; + sessionLength: string; + referenceGames: string[]; + }; + platformFacts: { + runtime: string; + viewports: string[]; + inputs: string[]; + preview: string; + }; + mvpSystems: Array<{ + system: string; + minimalFunction: string; + whyRequired: string; + verifyMethod: string; + decisionState: string; + basis: null; + }>; + outOfScope: string[]; + creatorTips: { + doFirst: string; + deferForNow: string; + howToVerify: string; + expandWhen: string; + }; + }; + decisions: Array<{ + id: string; + topic: string; + state: 'confirmed' | 'default_pending' | 'prototype_pending' | string; + answerSource: + | 'user_option' + | 'user_freeform' + | 'user_revision' + | 'default' + | string; + round: number; + answerSummary: string; + basis: null; + }>; + prototypeValidationItems: Array<{ + id: string; + question: string; + microPrototype: string; + observation: string; + passCriterion: string; + }>; + fingerprint: string; +}; + +export type PlanningArtifactV2 = { + artifactId: string; + kind: 'gdd' | string; + version: number; + status: string; + fingerprint: string; + payload: PlanningGddPayloadV2; +}; + +export type PlanningSessionCommandResultV2 = { + session: PlanningSessionV2; + result: PlanningTurnResultV2 | null; + currentArtifact: PlanningArtifactV2 | null; + replayed: boolean; + conversation?: PlanningMessageV2[]; +}; + +export type PlanningApprovalCommandResultV2 = { + approval: { + schemaVersion: string; + projectId: string; + sessionId: string; + artifactId: string; + version: number; + fingerprint: string; + decisionId: string; + action: PlanGddDecisionAction; + comment: string | null; + decidedAtUtc: string; + receiptFingerprint: string; + }; + session: PlanningSessionV2; + currentArtifact: PlanningArtifactV2 | null; + replayed: boolean; +}; + +export type PlanningSessionStreamEventV2 = { + sessionId: string; + clientTurnId: string; + status: 'started' | 'delta' | 'completed' | 'failed' | string; + deltaText: string; + accumulatedText: string; + finishReason: string | null; + result: PlanningTurnResultV2 | null; + error: { code: string; summary: string } | null; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function resultQuestion(result: PlanningTurnResultV2 | null) { + const payload = asRecord(result?.payload); + const question = asRecord(payload?.question); + if (!question) { + return null; + } + const options = Array.isArray(question.options) + ? question.options + .map((option) => { + const item = asRecord(option); + return item && + typeof item.label === 'string' && + typeof item.description === 'string' + ? { label: item.label, description: item.description } + : null; + }) + .filter((option): option is { label: string; description: string } => + Boolean(option), + ) + : []; + if ( + typeof question.id !== 'string' || + typeof question.header !== 'string' || + typeof question.question !== 'string' || + options.length === 0 + ) { + return null; + } + return { + id: question.id, + header: question.header, + question: question.question, + options, + } satisfies PlanningQuestionV2; +} + +export function planningResultText(result: PlanningTurnResultV2 | null) { + const text = result?.payload?.text; + return typeof text === 'string' ? text : ''; +} + +function questionDisplayText(question: PlanningQuestionV2) { + const options = question.options + .map( + (option, index) => `${String.fromCharCode(65 + index)}. ${option.label}`, + ) + .join('\n'); + return `${question.header}\n${question.question}\n${options}`; +} + +function artifactDisplayText(artifact: PlanningArtifactV2) { + const title = artifact.payload.game.title.trim(); + const oneLiner = artifact.payload.game.oneLiner.trim(); + return `已生成 GDD v${artifact.version}${title ? `:${title}` : ''}${ + oneLiner ? `\n${oneLiner}` : '' + }`; +} + +export function planningResultDisplayText( + result: PlanningTurnResultV2 | null, + artifact: PlanningArtifactV2 | null, +) { + const question = resultQuestion(result); + if (question) { + return questionDisplayText(question); + } + if ( + artifact && + (result?.kind === 'artifact' || + result?.payload?.kind === 'gdd' || + typeof result?.payload?.artifactId === 'string') + ) { + return artifactDisplayText(artifact); + } + if (result?.kind === 'error') { + const payload = asRecord(result.payload); + const summary = + typeof payload?.summary === 'string' ? payload.summary.trim() : ''; + return summary + ? `后台任务失败:${summary}` + : '后台任务失败:策划回合未完成'; + } + return planningResultText(result); +} + +function messageDisplayText(message: PlanningMessageV2) { + const payload = asRecord(message.payload); + if (typeof payload?.text === 'string') { + return payload.text; + } + if (message.kind === 'question') { + const question = resultQuestion({ + schemaVersion: 'planning-turn-result.v2', + kind: 'question', + payload: payload ?? {}, + }); + return question ? questionDisplayText(question) : ''; + } + if (message.kind === 'artifact') { + const artifact = asRecord(payload); + const nested = asRecord(artifact?.payload); + const game = asRecord(nested?.game); + const title = typeof game?.title === 'string' ? game.title.trim() : ''; + const version = + typeof artifact?.version === 'number' ? artifact.version : null; + return `已生成 GDD${version === null ? '' : ` v${version}`}${title ? `:${title}` : ''}`; + } + if (message.kind === 'error') { + const summary = typeof payload?.summary === 'string' ? payload.summary : ''; + return summary + ? `后台任务失败:${summary}` + : '后台任务失败:策划回合未完成'; + } + return ''; +} + +function timestampToMillis(value: string) { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Date.now(); +} + +export function planningMessagesToChatMessages( + messages: PlanningMessageV2[] | undefined, +): ChatMessage[] { + return (messages ?? []) + .filter( + ( + message, + ): message is PlanningMessageV2 & { role: 'user' | 'assistant' } => + message.role === 'user' || message.role === 'assistant', + ) + .map((message) => { + const text = messageDisplayText(message); + return { + role: message.role, + text, + runtimeOwned: true, + messageId: `planning-v2:${message.messageId}`, + updatedAt: timestampToMillis(message.atUtc), + } satisfies ChatMessage; + }) + .filter((message) => message.text.trim().length > 0); +} + +type PlanningSessionPhase = NonNullable['phase']; + +function sessionPhase(session: PlanningSessionV2): PlanningSessionPhase { + switch (session.status) { + case 'awaiting_user': + return 'awaiting_user_input'; + case 'awaiting_approval': + return 'awaiting_gdd_approval'; + case 'revision_requested': + return 'revision_requested'; + case 'approved': + return 'approved'; + case 'rejected': + return 'rejected'; + case 'provider_failed': + case 'stopped': + return 'recovery_required'; + default: + return 'collecting'; + } +} + +function gddState(status: string): PlanGddStateViewV1['state'] { + switch (status) { + case 'approved': + return 'approved'; + case 'rejected': + return 'rejected'; + case 'revision_requested': + return 'revision_requested'; + case 'ready_for_approval': + return 'ready_for_approval'; + default: + return 'draft'; + } +} + +function gddDisplayFromArtifact(artifact: PlanningArtifactV2) { + const payload = artifact.payload; + return { + schemaVersion: payload.schemaVersion, + projectId: payload.projectId, + gddId: payload.gddId, + version: payload.version, + submissionId: `planning-v2-submission-${payload.gddId}`, + approvalRequestId: `planning-v2-approval-${payload.gddId}-v${payload.version}`, + actionFingerprint: artifact.fingerprint, + agentId: 'planning-agent-v2', + source: PROJECT_SUPERVISOR_PLAN_SOURCE, + runProfile: 'standard', + runProfileBindingFingerprint: '', + rootAgentId: PROJECT_SUPERVISOR_AGENT_ID, + rootRunId: `planning-v2-${payload.projectId}`, + delegationId: '', + sessionId: `planning-v2-${payload.projectId}`, + sourceSessionRevision: 0, + sourceSessionFingerprint: '', + createdByRunId: `planning-v2-${payload.gddId}`, + createdAtUtc: payload.createdAtUtc, + fingerprint: payload.fingerprint, + game: payload.game, + decisions: payload.decisions.map((decision) => ({ + ...decision, + state: decision.state as + | 'confirmed' + | 'default_pending' + | 'prototype_pending', + answerSource: decision.answerSource as + | 'user_option' + | 'user_freeform' + | 'user_revision' + | 'default', + })), + prototypeValidationItems: payload.prototypeValidationItems, + } satisfies NonNullable; +} + +export function planningSessionToPlanGddState( + session: PlanningSessionV2, + artifact: PlanningArtifactV2 | null, +): PlanGddStateViewV1 { + const artifactStatus = artifact?.status ?? ''; + const displayGdd = artifact ? gddDisplayFromArtifact(artifact) : null; + const awaitingQuestion = session.currentQuestion; + const questionCount = Math.max(0, Math.trunc(session.questionCount)); + const clarificationRound = awaitingQuestion + ? Math.max(0, questionCount - 1) + : questionCount; + const sessionView = { + sessionId: session.sessionId, + sessionRevision: Math.max(1, session.turnIndex), + sessionFingerprint: '', + phase: sessionPhase(session), + clarificationRound, + questionLimit: session.questionLimit, + repairDepth: session.revisionCount, + accumulatedAgentMillis: Math.round(session.processingSeconds * 1000), + activeRunId: session.status === 'planning' ? session.sessionId : null, + awaitingAnswerFor: awaitingQuestion + ? { + delegationId: `planning-v2-${session.sessionId}`, + requestId: `planning-v2-question-${session.turnIndex}`, + questionId: awaitingQuestion.id, + round: clarificationRound, + } + : null, + decisionStateCounts: { + confirmed: + displayGdd?.decisions.filter( + (decision) => decision.state === 'confirmed', + ).length ?? 0, + defaultPending: + displayGdd?.decisions.filter( + (decision) => decision.state === 'default_pending', + ).length ?? 0, + prototypePending: + displayGdd?.decisions.filter( + (decision) => decision.state === 'prototype_pending', + ).length ?? 0, + }, + } satisfies NonNullable; + const state = artifact + ? gddState(artifactStatus) + : session.status === 'approved' + ? 'approved' + : session.status === 'rejected' + ? 'rejected' + : 'draft'; + const gddRef = artifact + ? { + gddId: artifact.artifactId, + version: artifact.version, + fingerprint: artifact.fingerprint, + } + : null; + const approvalRequestId = gddRef + ? `planning-v2-approval-${gddRef.gddId}-v${gddRef.version}` + : ''; + return { + schemaVersion: 'plan-gdd-state-view.v1', + projectId: session.projectId, + gddId: artifact?.artifactId ?? null, + state, + session: sessionView, + versions: gddRef + ? [ + { + gddRef, + status: + state === 'approved' + ? 'approved' + : state === 'rejected' + ? 'rejected' + : state === 'revision_requested' + ? 'revision_requested' + : 'ready_for_approval', + approvalRequestId, + createdAtUtc: + artifact?.payload.createdAtUtc ?? session.updatedAtUtc, + decision: null, + }, + ] + : [], + displayGdd, + pendingApproval: + artifact && gddRef && state === 'ready_for_approval' + ? { + gddRef, + pendingActionId: `planning-v2-action-${artifact.artifactId}-v${artifact.version}`, + actionFingerprint: artifact.fingerprint, + approvalRequestId, + sessionId: session.sessionId, + runId: session.sessionId, + } + : null, + approvedGddRef: state === 'approved' ? gddRef : null, + recoveryPending: false, + }; +} + +export function planningSessionToRuntime( + session: PlanningSessionV2, +): AgentRuntimeState { + const isQuestion = + session.status === 'awaiting_user' && session.currentQuestion; + const status = + session.status === 'planning' + ? 'running' + : isQuestion + ? 'waiting-for-user-input' + : session.status === 'approved' || session.status === 'rejected' + ? 'completed' + : 'idle'; + const phase = isQuestion + ? 'waiting-for-user-input' + : session.status === 'planning' + ? 'planning' + : status === 'completed' + ? 'completed' + : 'idle'; + const question = session.currentQuestion; + const userInputRequest: AgentRuntimeUserInputRequest | null = question + ? { + schemaVersion: 'planning-session-v2-user-input.v1', + requestId: `planning-v2-question-${session.turnIndex}`, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + taskId: session.sessionId, + sessionId: session.sessionId, + runId: session.sessionId, + actionId: `planning-v2-question-${session.turnIndex}`, + status: 'pending', + questions: [question], + allowFreeform: true, + responseId: null, + requestedAt: timestampToMillis(session.updatedAtUtc), + updatedAt: timestampToMillis(session.updatedAtUtc), + } + : null; + return { + schemaVersion: 'planning-session-v2-runtime.v1', + agentId: PROJECT_SUPERVISOR_AGENT_ID, + taskId: session.sessionId, + sessionId: session.sessionId, + runId: session.sessionId, + source: PROJECT_SUPERVISOR_PLAN_SOURCE, + runProfile: 'standard', + status, + phase, + currentTask: '立项策划', + currentAction: session.status === 'planning' ? '正在整理策划方案' : '', + waitingOn: isQuestion ? '等待用户回答策划问题' : '', + nextStep: isQuestion ? '提交回答后继续策划' : '', + plan: [], + observations: [], + allowedTools: [], + pendingToolAction: null, + userInputRequest, + taskQueue: { + total: session.status === 'planning' ? 1 : 0, + pending: 0, + running: session.status === 'planning' ? 1 : 0, + waitingForConfirmation: 0, + waitingForUserInput: isQuestion ? 1 : 0, + paused: 0, + cancelled: 0, + completed: status === 'completed' ? 1 : 0, + failed: 0, + latestRunId: session.sessionId, + updatedAt: timestampToMillis(session.updatedAtUtc), + }, + lastResponse: null, + error: session.lastError?.summary ?? null, + startedAt: timestampToMillis(session.createdAtUtc), + updatedAt: timestampToMillis(session.updatedAtUtc), + }; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index ccb369a12..2d4f412e5 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -458,6 +458,8 @@ function createProjectSupervisorRuntimeHarness({ initialProjectRevision = 0, runtimeMapLoader, expectedRunProfile = 'autonomous-game-build', + planningV2Result = null, + planningV2StartResult = null, }: { projectPath?: string; sessionId?: string; @@ -469,6 +471,8 @@ function createProjectSupervisorRuntimeHarness({ initialProjectRevision?: number; runtimeMapLoader?: () => Promise>>; expectedRunProfile?: 'standard' | 'autonomous-game-build'; + planningV2Result?: Record | null; + planningV2StartResult?: Record | null; } = {}) { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -516,6 +520,8 @@ function createProjectSupervisorRuntimeHarness({ let currentPlanGddState: PlanGddStateViewV1 | null = null; let planGddDecisionError: string | null = null; let planGddHydrateCount = 0; + let currentPlanningV2Result = planningV2Result; + let currentPlanningV2StartResult = planningV2StartResult; const planGddDecisionCalls: Array> = []; let runtimeUpdateHandler: | ((event: { @@ -809,6 +815,29 @@ function createProjectSupervisorRuntimeHarness({ } return currentPlanGddState; } + if (command === 'hydrate_planning_session_v2') { + return currentPlanningV2Result; + } + if ( + command === 'start_planning_session_v2' || + command === 'continue_planning_session_v2' + ) { + const nextResult = + command === 'start_planning_session_v2' + ? (currentPlanningV2StartResult ?? currentPlanningV2Result) + : currentPlanningV2Result; + if (!nextResult) { + throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED'); + } + currentPlanningV2Result = nextResult; + return nextResult; + } + if (command === 'decide_planning_artifact_v2') { + if (!currentPlanningV2Result) { + throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED'); + } + return currentPlanningV2Result; + } if (command === 'decide_game_creator_plan_gdd') { planGddDecisionCalls.push({ ...(args ?? {}) }); if (planGddDecisionError) { @@ -885,6 +914,12 @@ function createProjectSupervisorRuntimeHarness({ setPlanGddState(state: PlanGddStateViewV1 | null) { currentPlanGddState = state; }, + setPlanningV2Result(state: Record | null) { + currentPlanningV2Result = state; + }, + setPlanningV2StartResult(state: Record | null) { + currentPlanningV2StartResult = state; + }, failNextPlanGddDecision(message: string) { planGddDecisionError = message; }, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index aadd02977..c76d78419 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1,11 +1,9 @@ -import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../src/app/constants'; import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model'; import { useHomeProjectCreation } from '../../src/features/app-shell/useHomeProjectCreation'; import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher'; import type { LauncherView } from '../../src/view/layout'; import { act, - agentRuntimeUserInputRequest, App, cleanup, createGameCreationAppManifest, @@ -29,6 +27,33 @@ import { within, } from './harness'; +function emptyPlanningV2StartResult(projectId = 'local-project-draft') { + return { + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: 'home-planning-v2-session', + projectId, + mode: 'gdd', + status: 'planning', + turnIndex: 1, + questionCount: 0, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: null, + currentQuestion: null, + capabilities: { tools: [], skills: [] }, + processingSeconds: 0.5, + createdAtUtc: '2026-09-03T00:00:00Z', + updatedAtUtc: '2026-09-03T00:00:01Z', + lastError: null, + }, + result: null, + currentArtifact: null, + replayed: false, + }; +} + function ApprovedGddStartHarness() { const [, setLauncherView] = React.useState( 'project-development', @@ -1700,11 +1725,11 @@ export function registerHomeProjectCreationTests() { }); it.each([ - ['做方案', false, 'standard', PROJECT_SUPERVISOR_PLAN_SOURCE], - ['做方案', true, 'standard', PROJECT_SUPERVISOR_PLAN_SOURCE], + ['做方案', false], + ['做方案', true], ] as const)( - 'routes %s %s creation to the expected root run', - async (modeLabel, automatic, runProfile, source) => { + 'routes %s %s creation to Planning Session V2', + async (modeLabel, automatic) => { const projectPath = `/tmp/home-${modeLabel}-${automatic ? 'enter' : 'submit'}`; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1712,7 +1737,7 @@ export function registerHomeProjectCreationTests() { ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, - expectedRunProfile: runProfile, + planningV2StartResult: emptyPlanningV2StartResult(), }); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -1752,29 +1777,23 @@ export function registerHomeProjectCreationTests() { } else { fireEvent.click( screen.getByRole('button', { - name: source ? '进入立项策划' : '开启创作', + name: '进入立项策划', }), ); } await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', + 'start_planning_session_v2', expect.objectContaining({ projectPath, - runProfile, - ...(source ? { source } : {}), + mode: 'gdd', }), ); }); const startCall = invoke.mock.calls.find( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ([command]) => command === 'start_planning_session_v2', ); - if (source) { - expect(startCall?.[1]).toMatchObject({ source }); - } else { - expect(startCall?.[1]).not.toHaveProperty('source'); - } expect(startCall?.[1]).not.toHaveProperty('attachments'); expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( '本轮用户附件', @@ -1803,7 +1822,9 @@ export function registerHomeProjectCreationTests() { ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, - expectedRunProfile: 'standard', + planningV2StartResult: emptyPlanningV2StartResult( + 'home-planning-attachment', + ), }); const fileBytes = Array.from(new TextEncoder().encode('png')); const invoke = vi.fn( @@ -1856,10 +1877,10 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', + 'start_planning_session_v2', expect.objectContaining({ projectPath, - source: PROJECT_SUPERVISOR_PLAN_SOURCE, + mode: 'gdd', }), ); }); @@ -1870,7 +1891,7 @@ export function registerHomeProjectCreationTests() { bytes: fileBytes, }); const startCall = invoke.mock.calls.find( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ([command]) => command === 'start_planning_session_v2', ); expect(startCall?.[1]).not.toHaveProperty('attachments'); expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件'); @@ -1901,7 +1922,52 @@ export function registerHomeProjectCreationTests() { projectPath, expectedRunProfile: 'standard', }); - let planRootRunId = ''; + supervisorHarness.setPlanningV2StartResult({ + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: 'home-planning-v2-session', + projectId: 'local-project-draft', + mode: 'gdd', + status: 'awaiting_user', + turnIndex: 1, + questionCount: 1, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: null, + currentQuestion: { + id: 'visual_direction', + header: '当前要决定:首版美术方向', + question: '首版角色规范图采用哪种美术方向?', + options: [ + { label: '像素', description: '低成本像素风。' }, + { label: '扁平', description: '清晰的扁平插画风。' }, + ], + }, + capabilities: { tools: [], skills: [] }, + processingSeconds: 1, + createdAtUtc: '2026-09-03T00:00:00Z', + updatedAtUtc: '2026-09-03T00:00:01Z', + lastError: null, + }, + result: { + schemaVersion: 'planning-turn-result.v2', + kind: 'question', + payload: { + question: { + id: 'visual_direction', + header: '当前要决定:首版美术方向', + question: '首版角色规范图采用哪种美术方向?', + options: [ + { label: '像素', description: '低成本像素风。' }, + { label: '扁平', description: '清晰的扁平插画风。' }, + ], + }, + }, + }, + currentArtifact: null, + replayed: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'pick_local_project_directory') { @@ -1917,33 +1983,7 @@ export function registerHomeProjectCreationTests() { manifest, }; } - if (command === 'start_game_creator_supervisor_runtime_task') { - planRootRunId = String(args?.runId ?? ''); - } - const result = await supervisorHarness.invoke(command, args); - if (command !== 'read_game_creator_agent_runtime' || !planRootRunId) { - return result; - } - // 后端此刻的真实形态:pending 是 user.input_request,读命令把澄清请求投影在 - // 结果的**顶层**(`AgentRuntimeResult.user_input_request`,与 `state` 平级), - // 前端的 agentRuntimeStateFromResult 也优先读顶层。放进 state 会被顶层的 null - // 盖掉,那是 fixture 写错,不是产品缺陷。 - const runtimeResult = result as { state: Record }; - return { - ...runtimeResult, - state: { - ...runtimeResult.state, - status: 'waiting-for-user-input', - phase: 'waiting-for-user-input', - }, - userInputRequest: agentRuntimeUserInputRequest({ - agentId: 'project-supervisor', - sessionId: supervisorHarness.sessionId, - runId: planRootRunId, - requestId: 'request-plan-round-1', - actionId: 'action-plan-round-1', - }), - }; + return supervisorHarness.invoke(command, args); }, ); window.__TAURI__ = { @@ -1963,8 +2003,8 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', - expect.objectContaining({ source: PROJECT_SUPERVISOR_PLAN_SOURCE }), + 'start_planning_session_v2', + expect.objectContaining({ prompt: '2D射击游戏', mode: 'gdd' }), ); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts index d63bb13b1..181b971a1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts @@ -3,7 +3,6 @@ import { resolve } from 'node:path'; import { PlanGddStageProgress } from '../../src/features/project-workspace/GddApprovalCard'; import { - agentRuntimeUserInputRequest, App, createPlanGddStateView, createProjectSupervisorRuntimeHarness, @@ -20,37 +19,140 @@ import { function mountFormalSupervisor( harness: ReturnType, + planningStartMode = false, ) { window.history.pushState({}, '', '/'); render( React.createElement(App, { initialProjectPath: harness.projectPath, orchestrationMode: 'single-supervisor', - planningStartMode: true, + planningStartMode, projectSupervisorOnly: true, }), ); } +function planningV2ApprovalResult() { + const state = createPlanGddStateView(); + const displayGdd = state.displayGdd; + const session = state.session; + if (!displayGdd || !session) { + throw new Error('fixture 应当带 GDD 和 session'); + } + return { + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: session.sessionId, + projectId: state.projectId, + mode: 'gdd', + status: 'awaiting_approval', + turnIndex: 3, + questionCount: session.clarificationRound, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: displayGdd.version, + currentQuestion: null, + capabilities: { tools: [], skills: [] }, + processingSeconds: session.accumulatedAgentMillis / 1000, + createdAtUtc: displayGdd.createdAtUtc, + updatedAtUtc: displayGdd.createdAtUtc, + lastError: null, + }, + result: null, + currentArtifact: { + artifactId: displayGdd.gddId, + kind: 'gdd', + version: displayGdd.version, + status: 'ready_for_approval', + fingerprint: displayGdd.fingerprint, + payload: { + schemaVersion: 'plan-gdd.v2', + projectId: displayGdd.projectId, + gddId: displayGdd.gddId, + version: displayGdd.version, + createdAtUtc: displayGdd.createdAtUtc, + game: displayGdd.game, + decisions: displayGdd.decisions, + prototypeValidationItems: displayGdd.prototypeValidationItems, + fingerprint: displayGdd.fingerprint, + }, + }, + replayed: false, + conversation: [], + }; +} + +function planningV2QuestionResult() { + return { + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: 'plan-session-v2-question', + projectId: 'local-project-draft', + mode: 'gdd', + status: 'awaiting_user', + turnIndex: 1, + questionCount: 1, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: null, + currentQuestion: { + id: 'core_loop', + header: '当前要决定:核心循环', + question: '玩家在一局中主要反复做什么?', + options: [ + { label: '持续闪避', description: '保持移动并躲避来袭弹幕。' }, + { label: '规划路线', description: '观察弹幕并选择安全路线。' }, + ], + }, + capabilities: { tools: [], skills: [] }, + processingSeconds: 1.2, + createdAtUtc: '2026-09-03T00:00:00Z', + updatedAtUtc: '2026-09-03T00:00:01Z', + lastError: null, + }, + result: { + schemaVersion: 'planning-turn-result.v2', + kind: 'question', + payload: { + question: { + id: 'core_loop', + header: '当前要决定:核心循环', + question: '玩家在一局中主要反复做什么?', + options: [ + { label: '持续闪避', description: '保持移动并躲避来袭弹幕。' }, + { label: '规划路线', description: '观察弹幕并选择安全路线。' }, + ], + }, + }, + }, + currentArtifact: null, + replayed: false, + }; +} + async function mountApprovalCard( harness: ReturnType, + planningStartMode = false, ) { window.__TAURI__ = { core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - mountFormalSupervisor(harness); + mountFormalSupervisor(harness, planningStartMode); return await screen.findByLabelText('GDD 审批卡'); } async function mountPlanningSurface( harness: ReturnType, + planningStartMode = false, ) { window.__TAURI__ = { core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - mountFormalSupervisor(harness); + mountFormalSupervisor(harness, planningStartMode); return await screen.findByLabelText('立项策划阶段进度'); } @@ -502,77 +604,51 @@ export function registerPlanGddApprovalTests() { }); it('still surfaces the clarification card on the planning lane', async () => { - // 澄清卡是策划链路唯一需要用户动手的交互面之一,瘦身不能把它一起收掉。 - const supervisorRunId = 'plan-root-clarifying-run'; - const request = agentRuntimeUserInputRequest({ - agentId: 'project-supervisor', - sessionId: 'supervisor-session-active', - runId: supervisorRunId, - requestId: 'request-plan-round-1', - actionId: 'action-plan-round-1', - }); + // 澄清卡是 V2 策划链路唯一需要用户动手的交互面之一。 const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: supervisorRunId, - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'waiting-for-user-input', - phase: 'waiting-for-user-input', - currentTask: '剧情向恋爱养成游戏', - currentAction: '等待用户回答第 1 轮澄清', - userInputRequest: request, - updatedAt: 7100, - }, + planningV2Result: planningV2QuestionResult(), }); - harness.setPlanGddState( - draftPlanGddState({ - clarificationRound: 0, - awaitingAnswerFor: { - delegationId: 'delegation-0001', - requestId: 'request-plan-round-1', - questionId: 'visual_direction', - round: 0, - }, - }), - ); - await mountPlanningSurface(harness); + await mountPlanningSurface(harness, true); const strip = await screen.findByLabelText('立项策划运行状态'); expect(within(strip).getByLabelText('Needs input')).not.toBeNull(); expect( - within(strip).getByText('首版角色规范图采用哪种美术方向?'), + within(strip).getByText('玩家在一局中主要反复做什么?'), ).not.toBeNull(); expectSupervisorRuntimePanelAbsent(); - expect(screen.queryByText('等待用户回答第 1 轮澄清')).toBeNull(); + expect(screen.queryByText(/agent\.delegate/)).toBeNull(); }); - it('offers a restart on the planning lane once the planning run has failed', async () => { - // 失败是另一处需要用户动手的时刻。恢复入口跟着搬进窄条,而不是随面板一起消失。 + it('surfaces a V2 provider failure on the planning lane', async () => { + // V2 provider_failed 由同一策划会话承接,错误显示在窄条中,用户可用输入框重新提交。 const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: 'plan-root-failed-run', - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'failed', - phase: 'failed', - currentTask: '剧情向恋爱养成游戏', - currentAction: '策划子 Run 退出', - error: '项目总控 Agent Codex 执行失败,请查看运行详情后重试', - updatedAt: 7200, + planningV2Result: { + ...planningV2QuestionResult(), + session: { + ...planningV2QuestionResult().session, + status: 'provider_failed', + currentQuestion: null, + lastError: { + code: 'PROVIDER_FAILED', + summary: 'Planning V2 Provider 调用失败', + }, + }, + result: { + schemaVersion: 'planning-turn-result.v2', + kind: 'error', + payload: { + code: 'PROVIDER_FAILED', + summary: 'Planning V2 Provider 调用失败', + }, + }, }, }); - harness.setPlanGddState(draftPlanGddState()); - await mountPlanningSurface(harness); + await mountPlanningSurface(harness, true); - const recovery = await screen.findByLabelText('立项策划失败恢复'); - expect( - within(recovery).getByRole('button', { name: '重新启动策划' }), - ).not.toBeNull(); - expect(screen.getByRole('alert').textContent).toContain('执行失败'); + const strip = await screen.findByLabelText('立项策划运行状态'); + expect(within(strip).getByRole('alert').textContent).toContain('执行失败'); expectSupervisorRuntimePanelAbsent(); - expect(screen.queryByText('策划子 Run 退出')).toBeNull(); + expect(screen.queryByText(/project-planning/)).toBeNull(); }); it('keeps a stylesheet rule for every class the planning components reference', () => { @@ -661,4 +737,66 @@ export function registerPlanGddApprovalTests() { /\.game-workbench-chat\s+\.plan-gdd-surface--with-card\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)/s, ); }); + + it('routes a formal planning entry through Runtime V2 commands', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovalResult(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + await screen.findByLabelText('GDD 审批卡'); + const planningHydrateCall = harness.invoke.mock.calls.find( + ([command]) => command === 'hydrate_planning_session_v2', + ); + expect(planningHydrateCall).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: '批准 v1' })); + await waitFor(() => { + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'decide_planning_artifact_v2', + ), + ).toBe(true); + }); + }); + + it('starts a new V2 planning session and renders its question card', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + planningV2StartResult: planningV2QuestionResult(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + initialSupervisorMessage: '做一个2D弹幕射击游戏', + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + await screen.findByLabelText('Needs input'); + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'start_planning_session_v2', + ), + ).toBe(true); + expect(screen.getByText('玩家在一局中主要反复做什么?')).not.toBeNull(); + expect(screen.queryByText(/agent\.delegate/)).toBeNull(); + }); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a433e79b7..00e3114ee 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:现有“做方案”依赖 `project-supervisor-plan` 根 Run、`project-planning` 子 Run、静态委派、delivery、Acceptance Graph 和审批前 evidence。新策划 Agent 只需要单 Agent 会话、问询、GDD 和审批;继续在旧 Runtime 上逐条放宽会保留身份/编排耦合。未来策划 Agent 可能支持无限多轮、MCP 和 Skill,需要避免把当前 8 题/GDD/no-tools 固化为 Runtime 根结构。 - 决策:新增独立 `PlanningSessionRuntime`,复用 Provider/流式、会话持久化、项目锁、原子写和基础错误恢复;当前启用 `mode=gdd`、最多展示 8 个有效问题、GDD 审批和用户修改。新建“做方案”会话不创建 Supervisor root、planning child、delegation 或 acceptance evidence。V2 使用独立 `.agent/planning-v2/` 与 V2 schema,继续输出 `game/fast_gdd.md`;不自动转换旧会话。 - 兼容性:Session 保存 `mode`、可空 `questionLimit`、`capabilities.tools/skills`;完整会话记录与 Provider 请求上下文分离;消息模型预留 tool/skill 事件类型但本期不执行 MCP/Skill。无限问询、上下文摘要、多产物和能力执行以后作为策略/能力层扩展,不重新引入 Supervisor 身份模型。 -- 当前进度:P0 合同冻结、P1 会话内核和 P2 GDD/审批核心已落地;P3 正式入口/UI 接入与 P5 旧链路退役尚未开始。 +- 当前进度:P0 合同冻结、P1 会话内核和 P2 GDD/审批核心已落地;P3 正式入口/UI 接入已开始,P5 旧链路退役尚未开始。 - 退役:V2 切换时旧链路直接封存;所有未完成旧会话投影为 `legacy_retired` 失败,禁止继续问询、审批、恢复或 continuation。旧 GDD、approval、conversation 和 `.agent/planning` 文件只读保留;旧入口 caller 关闭,但不删除旧代码、旧测试或旧数据。 - 影响范围:AGC 做方案入口、Rust/Tauri planning session/Provider adapter、GDD/审批 V2、前端 planning lane、阶段任务与 BDD 验收;做游戏/做素材 DirectProject 不变。 - 验证方式:按 `docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md` 的 P0~P5 阶段验收执行;至少覆盖第 8 个问题、上限后 question 抑制、Provider 失败、非法输出、批准/修改/退回、重启恢复、旧会话切换强制失败、迟到 Provider 结果丢弃和当前空能力快照。 @@ -7982,3 +7982,10 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - V2 新增 `GddPlanningPolicy`:Provider 输出只接受合法 question 或完整 GDD JSON;question 在 `questionCount < questionLimit` 时保存并展示,第 8 个问题仍可展示,第 8 个之后再次返回 question 时只允许一次内部强制出稿重试,额外 question 不落盘、不进入用户等待态。 - V2 GDD 使用独立 `plan-gdd.v2`,只保留项目身份、版本、游戏内容、决定、原型验证项和指纹,不带旧 Supervisor/Run/delegation 字段。每个版本写入 `.agent/planning-v2/gdd.vN.json`,同步更新 V2 index 和 `game/fast_gdd.md`;旧版本不可覆盖。 - 新增 `decide_planning_artifact_v2` 与 `plan-approval.v2`。批准、修改、退回均绑定当前 Session、artifact、version 和 fingerprint;修改/退回必须有意见,修改后 Session 回到 `revision_requested`,下一轮由同一 V2 Session 继续。`answerSource` 缺失或未知值回退,不成为单独阻断项。 + +## 2026-09-03 策划会话 Runtime V2 P3 入口与 UI 接入开始 + +- 正式 AGC“做方案”入口在 `planningStartMode` 下直接调用 `start_planning_session_v2`、`continue_planning_session_v2` 和 `decide_planning_artifact_v2`,不再为新策划回合创建 Supervisor root、child Run 或 delegation。 +- 前端以适配层复用现有聊天区、澄清输入卡、GDD 审批卡和阶段进度条;V2 hydrate 返回 `conversation` 消息,用于页面刷新和重启后恢复可见历史。 +- V2 会话使用独立 `planning-session-v2-stream` 事件,旧 Supervisor Runtime 轮询、专业 Agent 轮询和旧 Runtime 事件不会介入 V2 会话。 +- 没有 V2 authority 的项目仍按旧读取路径打开;旧链路封存、未完成旧会话强制失败和旧入口彻底关闭仍留在 P5。 diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md index 4f88c86c0..543987726 100644 --- a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md +++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md @@ -1,10 +1,10 @@ # 策划会话 Runtime V2 接入与旧链路退役方案 - 日期:2026-09-03 -- 状态:P0 合同冻结、P1 内核和 P2 产物闭环核心已完成;P3~P5 待实施;本文是新生产实现的目标方案与阶段验收合同 +- 状态:P0 合同冻结、P1 内核和 P2 产物闭环核心已完成;P3 入口与 UI 接入实施中;P4~P5 待实施;本文是新生产实现的目标方案与阶段验收合同 - 适用范围:AGC 桌面 App 的“做方案”入口、策划会话、GDD 产物与审批 -> 本文只规定新策划 Agent 的生产接入和旧链路退役方式,不修改当前生产代码。现有 `project-supervisor-plan` / `project-planning` 链路在 V2 切换前仍是存量实现;V2 切换时旧链路直接封存,所有未完成旧会话强制失败,旧 Fast GDD 文档之后只作为历史记录依据。 +> 本文规定新策划 Agent 的生产接入和旧链路退役方式。P3 开始修改正式 AGC 入口与工作台,但旧 `project-supervisor-plan` / `project-planning` 源码仍保留,直到 P5 完成退役;V2 切换时旧链路直接封存,所有未完成旧会话强制失败,旧 Fast GDD 文档之后只作为历史记录依据。 ## 1. 决策摘要 @@ -263,8 +263,14 @@ Provider 适配层输出 `planning-turn-result.v2`: "header": "当前要决定:核心回路", "question": "玩家每一局主要反复做什么?", "options": [ - {"label": "工作台设计迭代", "description": "先设计,再验证并回到编辑器修改。"}, - {"label": "直接战斗验证", "description": "先进入战斗,再根据结果调整设计。"} + { + "label": "工作台设计迭代", + "description": "先设计,再验证并回到编辑器修改。" + }, + { + "label": "直接战斗验证", + "description": "先进入战斗,再根据结果调整设计。" + } ] } ``` @@ -295,12 +301,12 @@ Provider 适配层输出 `planning-turn-result.v2`: 四个 command 的输入/输出边界冻结如下: -| command | 必要输入 | 返回 | -|---|---|---| -| `start_planning_session_v2` | `projectPath`、`clientTurnId`、`prompt`、可选 `mode` | V2 Session 及本次回合结果 | -| `continue_planning_session_v2` | `projectPath`、`sessionId`、`clientTurnId`、`input` | 更新后的 Session 及本次回合结果 | -| `decide_planning_artifact_v2` | `projectPath`、`sessionId`、`artifactId`、`version`、`fingerprint`、`decisionId`、`action`、可选 `comment` | 审批结果和最新 Session 状态 | -| `hydrate_planning_session_v2` | `projectPath`、可选 `sessionId` | 只读 V2 Session、当前 question、当前产物和错误摘要 | +| command | 必要输入 | 返回 | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `start_planning_session_v2` | `projectPath`、`clientTurnId`、`prompt`、可选 `mode` | V2 Session 及本次回合结果 | +| `continue_planning_session_v2` | `projectPath`、`sessionId`、`clientTurnId`、`input` | 更新后的 Session 及本次回合结果 | +| `decide_planning_artifact_v2` | `projectPath`、`sessionId`、`artifactId`、`version`、`fingerprint`、`decisionId`、`action`、可选 `comment` | 审批结果和最新 Session 状态 | +| `hydrate_planning_session_v2` | `projectPath`、可选 `sessionId` | 只读 V2 Session、当前 question、当前产物、错误摘要和完整 `conversation` 消息 | `input` 冻结为 `option`、`freeform`、`direct_draft`、`revision` 四种用户意图: @@ -383,12 +389,12 @@ normalize_turn_result() P0 冻结适配器的四个边界对象: -| 对象 | Runtime 可见字段 | 约束 | -|---|---|---| -| `PlanningProviderRequestV2` | `sessionId`、`turnIndex`、`mode`、`policyHint`、`messages`、`capabilities` | 不携带 Supervisor/child/delegation 身份;具体 API kind、URL 和凭据由 Provider substrate 持有 | -| `PlanningProviderStreamEventV2` | `type=text_delta\|completed\|failed`、可选 `text`/`result`/`error` | Provider 原始协议在 adapter 内归一;Runtime 不解析 OpenAI/Anthropic 私有字段 | -| `ContextBuildResultV2` | `messages`、`estimatedTokens`、`overflow` | 完整会话记录不等于请求上下文;`overflow=true` 时显式失败,不静默丢历史 | -| `CapabilitySnapshotV2` | `tools`、`skills` | 稳定排序、去重;当前必须为空,由宿主注入,模型不能修改 | +| 对象 | Runtime 可见字段 | 约束 | +| ------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `PlanningProviderRequestV2` | `sessionId`、`turnIndex`、`mode`、`policyHint`、`messages`、`capabilities` | 不携带 Supervisor/child/delegation 身份;具体 API kind、URL 和凭据由 Provider substrate 持有 | +| `PlanningProviderStreamEventV2` | `type=text_delta\|completed\|failed`、可选 `text`/`result`/`error` | Provider 原始协议在 adapter 内归一;Runtime 不解析 OpenAI/Anthropic 私有字段 | +| `ContextBuildResultV2` | `messages`、`estimatedTokens`、`overflow` | 完整会话记录不等于请求上下文;`overflow=true` 时显式失败,不静默丢历史 | +| `CapabilitySnapshotV2` | `tools`、`skills` | 稳定排序、去重;当前必须为空,由宿主注入,模型不能修改 | Provider adapter 只负责“请求、流式事件、稳定错误、用量/耗时”;是否接受 question/GDD、是否计入问题数和是否生成审批由 `PlanningPolicy` 决定。 @@ -490,13 +496,13 @@ P0 冻结审批记录绑定 `version + fingerprint`,并使用 `plan-approval.v 同一项目只允许一个活跃策划权威。V2 切换包含一次项目级“旧链路封存”操作,路由规则如下: -| 项目状态 | 新请求路由 | -|---|---| -| 已有活跃 V2 会话 | 继续 V2;同一会话只允许一个在途 Provider 回合 | +| 项目状态 | 新请求路由 | +| ----------------------------------------- | ----------------------------------------------------------------------------- | +| 已有活跃 V2 会话 | 继续 V2;同一会话只允许一个在途 Provider 回合 | | 只有旧 `project-supervisor-plan` 活跃会话 | 切换时将旧会话投影为 `legacy_retired` 失败;用户需重新创建 V2,不再继续旧链路 | -| 没有活跃策划会话 | 创建 V2 | -| 旧会话已终态、用户明确重新做方案 | 创建 V2,不改写旧目录 | -| 同时发现旧/V2 活跃会话 | 旧会话优先封存为 `legacy_retired`,只保留 V2 推进 | +| 没有活跃策划会话 | 创建 V2 | +| 旧会话已终态、用户明确重新做方案 | 创建 V2,不改写旧目录 | +| 同时发现旧/V2 活跃会话 | 旧会话优先封存为 `legacy_retired`,只保留 V2 推进 | 不做旧 session → V2 的自动转换。原因是两套身份、计数、审批和 GDD schema 不同,自动转换会把旧 pending 或旧 approval receipt 混入 V2。 @@ -593,11 +599,11 @@ hydrate_planning_session_v2 任务: -| ID | 任务 | 产出 | -|---|---|---| -| P0-1 | 冻结 Session、消息、回合结果、产物和审批 DTO | V2 schema、字段枚举和版本策略 | -| P0-2 | 冻结状态机、`questionLimit` 语义和重试规则 | 状态转移表、错误边界 | -| P0-3 | 冻结新旧并存与同项目单权威规则 | 路由/恢复决策表 | +| ID | 任务 | 产出 | +| ---- | -------------------------------------------- | ----------------------------------- | +| P0-1 | 冻结 Session、消息、回合结果、产物和审批 DTO | V2 schema、字段枚举和版本策略 | +| P0-2 | 冻结状态机、`questionLimit` 语义和重试规则 | 状态转移表、错误边界 | +| P0-3 | 冻结新旧并存与同项目单权威规则 | 路由/恢复决策表 | | P0-4 | 冻结 Provider/ContextBuilder/Capability 插槽 | provider-neutral adapter 和模块边界 | 阶段验收: @@ -626,14 +632,14 @@ hydrate_planning_session_v2 任务: -| ID | 任务 | 产出 | -|---|---|---| -| P1-1 | 新建 V2 Session 生命周期与单项目并发控制 | `planning_session_v2` Rust 模块 | +| ID | 任务 | 产出 | +| ---- | ------------------------------------------ | --------------------------------------- | +| P1-1 | 新建 V2 Session 生命周期与单项目并发控制 | `planning_session_v2` Rust 模块 | | P1-2 | 接入现有 Provider substrate 和流式事件归一 | provider-neutral request/stream adapter | -| P1-3 | 实现消息 JSONL、回合身份和幂等写入 | `conversation.jsonl` 及 turn identity | -| P1-4 | 实现 ContextBuilder 初版 | 有界历史构建;超限显式失败 | -| P1-5 | 实现 Provider 失败/中断/重启恢复 | Session 不丢消息、不伪造成功 | -| P1-6 | 记录 `turnIndex`、处理耗时和安全错误摘要 | Session 快照、诊断字段 | +| P1-3 | 实现消息 JSONL、回合身份和幂等写入 | `conversation.jsonl` 及 turn identity | +| P1-4 | 实现 ContextBuilder 初版 | 有界历史构建;超限显式失败 | +| P1-5 | 实现 Provider 失败/中断/重启恢复 | Session 不丢消息、不伪造成功 | +| P1-6 | 记录 `turnIndex`、处理耗时和安全错误摘要 | Session 快照、诊断字段 | 阶段验收: @@ -656,14 +662,14 @@ hydrate_planning_session_v2 任务: -| ID | 任务 | 产出 | -|---|---|---| -| P2-1 | 实现 question / GDD 结果解析 | 只接受当前策略需要的结果 | -| P2-2 | 实现最多 8 个有效问题的策略计数 | `questionCount` 与 `turnIndex` 分离 | -| P2-3 | 实现达到上限后的单次强制出稿重试 | 不保存/展示额外 question | -| P2-4 | 实现 V2 GDD schema、版本链和 `fast_gdd.md` | `.agent/planning-v2/**` 与 Markdown | -| P2-5 | 实现审批、修改、退回 | `plan-approval.v2` 与新版本生成 | -| P2-6 | 实现轻量输入归一化 | A/B/编号/完整 label/“按第一个选项做”映射 | +| ID | 任务 | 产出 | +| ---- | ------------------------------------------ | ---------------------------------------- | +| P2-1 | 实现 question / GDD 结果解析 | 只接受当前策略需要的结果 | +| P2-2 | 实现最多 8 个有效问题的策略计数 | `questionCount` 与 `turnIndex` 分离 | +| P2-3 | 实现达到上限后的单次强制出稿重试 | 不保存/展示额外 question | +| P2-4 | 实现 V2 GDD schema、版本链和 `fast_gdd.md` | `.agent/planning-v2/**` 与 Markdown | +| P2-5 | 实现审批、修改、退回 | `plan-approval.v2` 与新版本生成 | +| P2-6 | 实现轻量输入归一化 | A/B/编号/完整 label/“按第一个选项做”映射 | 阶段验收: @@ -677,19 +683,19 @@ hydrate_planning_session_v2 依赖:P1。 -### P3:生产入口与 UI 接入 +### P3:生产入口与 UI 接入(实施中) 目标:让用户从正式“做方案”入口使用 V2,同时保持现有页面可用。 任务: -| ID | 任务 | 产出 | -|---|---|---| -| P3-1 | 新增 V2 Tauri command 注册和前端 invoke 封装 | 命令可启动/恢复/审批 | -| P3-2 | 将 `planningStartMode` 路由到 V2 | 新项目不创建旧 Supervisor root | -| P3-3 | 复用审批卡并切换到 V2 hydrate 状态 | GDD 展示、版本和审批按钮正常 | -| P3-4 | 加入当前运行态、流式回复和耗时展示 | 页面可见状态与 Session 一致 | -| P3-5 | 识别旧项目并准备封存投影 | 存量旧会话不被误路由到 V2 | +| ID | 任务 | 产出 | +| ---- | -------------------------------------------- | ------------------------------ | +| P3-1 | 新增 V2 Tauri command 注册和前端 invoke 封装 | 命令可启动/恢复/审批 | +| P3-2 | 将 `planningStartMode` 路由到 V2 | 新项目不创建旧 Supervisor root | +| P3-3 | 复用审批卡并切换到 V2 hydrate 状态 | GDD 展示、版本和审批按钮正常 | +| P3-4 | 加入当前运行态、流式回复和耗时展示 | 页面可见状态与 Session 一致 | +| P3-5 | 识别旧项目并准备封存投影 | 存量旧会话不被误路由到 V2 | 阶段验收: @@ -701,19 +707,27 @@ hydrate_planning_session_v2 依赖:P2。 +当前实现进度(2026-09-03): + +- 正式 `planningStartMode` 首轮提交已改走 `start_planning_session_v2`,后续回答改走 `continue_planning_session_v2`,审批改走 `decide_planning_artifact_v2`;不再为新策划回合创建 Supervisor Run。 +- 前端通过 V2 适配层复用现有聊天区、澄清卡、GDD 审批卡和阶段进度条;V2 Session 的 hydrate 结果额外携带 `conversation`,用于刷新/重启恢复历史消息。 +- 监听 `planning-session-v2-stream`,将 Provider 回合的增量投影到现有实时回复区域;旧 Runtime 轮询、专业 Agent 轮询和旧 Runtime 事件在 V2 策划会话中关闭。 +- 对已存在 V2 Session 的项目,打开项目时先 hydrate V2;没有 V2 authority 的旧项目继续走旧读取路径,避免误把旧项目数据当成 V2。 +- P3 尚未完成旧会话 `legacy_retired` 封存、入口彻底关闭和真实 Provider/UI 全链路回归,这些仍属于 P4/P5。 + ### P4:灰度、真实 Provider 与回归验收 目标:证明 V2 的正常路径和关键失败路径可用,再关闭旧入口新建能力。 任务: -| ID | 任务 | 产出 | -|---|---|---| -| P4-1 | 离线状态/结构定向测试 | Session、策略、schema、审批测试 | -| P4-2 | 真实 Provider 测试 | 流式、问题、GDD、失败恢复 | -| P4-3 | 前端组件/工作台测试 | 路由、审批卡、恢复显示 | -| P4-4 | 旧链路切换封存测试 | 未完成旧 session 强制失败、V2 不误读旧目录 | -| P4-5 | 安全与编码门禁 | `npm run check:encoding`、`git diff --check` 及相关 Rust/TS 检查 | +| ID | 任务 | 产出 | +| ---- | --------------------- | ---------------------------------------------------------------- | +| P4-1 | 离线状态/结构定向测试 | Session、策略、schema、审批测试 | +| P4-2 | 真实 Provider 测试 | 流式、问题、GDD、失败恢复 | +| P4-3 | 前端组件/工作台测试 | 路由、审批卡、恢复显示 | +| P4-4 | 旧链路切换封存测试 | 未完成旧 session 强制失败、V2 不误读旧目录 | +| P4-5 | 安全与编码门禁 | `npm run check:encoding`、`git diff --check` 及相关 Rust/TS 检查 | 阶段验收: @@ -731,12 +745,12 @@ hydrate_planning_session_v2 任务: -| ID | 任务 | 产出 | -|---|---|---| -| P5-1 | 关闭新建和继续旧 `project-supervisor-plan` 的 caller | 入口门禁/路由变更 | -| P5-2 | 执行旧 session 一次性封存 | `legacy-cutover.json` 与失败投影 | -| P5-3 | 保留旧产物只读展示,禁止旧交互继续推进 | 历史查看能力 | -| P5-4 | 更新生产文档和运维说明 | 旧链路退役状态、回滚边界 | +| ID | 任务 | 产出 | +| ---- | ---------------------------------------------------- | -------------------------------- | +| P5-1 | 关闭新建和继续旧 `project-supervisor-plan` 的 caller | 入口门禁/路由变更 | +| P5-2 | 执行旧 session 一次性封存 | `legacy-cutover.json` 与失败投影 | +| P5-3 | 保留旧产物只读展示,禁止旧交互继续推进 | 历史查看能力 | +| P5-4 | 更新生产文档和运维说明 | 旧链路退役状态、回滚边界 | 阶段验收: @@ -875,32 +889,32 @@ hydrate_planning_session_v2 ## 11. 测试映射 -| 场景/规则 | 测试层级 | 计划目标 | -|---|---|---| -| Session 状态、questionCount/turnIndex 分离 | Rust unit | `planning_session_v2` | -| Provider 失败、幂等回合、恢复 | Rust integration | V2 runtime/provider adapter tests | -| GDD schema、版本链、fingerprint | Rust unit/integration | V2 artifact/approval tests | -| 第 8 个问题与上限后重试 | Rust unit | `GddPlanningPolicy` tests | -| 审批批准/修改/退回/过期审批 | Rust integration | V2 approval tests | -| 输入“按 A 做/按第一个选项做” | Rust/TS unit | input normalization tests | -| 做方案入口路由 | frontend integration | `App`/planning lane tests | -| 问题卡、GDD 卡和恢复态展示 | component | `ProjectSupervisorView`/`GddApprovalCard` tests | -| 真实 Provider 流式链路 | real provider smoke | P4 独立脚本或现有 real-e2e harness | -| 旧会话切换强制失败、旧目录不被 V2 读取 | Rust integration | legacy cutover/recovery tests | -| 中文编码和文档 diff | repository gate | `npm run check:encoding`、`git diff --check` | +| 场景/规则 | 测试层级 | 计划目标 | +| ------------------------------------------ | --------------------- | ----------------------------------------------- | +| Session 状态、questionCount/turnIndex 分离 | Rust unit | `planning_session_v2` | +| Provider 失败、幂等回合、恢复 | Rust integration | V2 runtime/provider adapter tests | +| GDD schema、版本链、fingerprint | Rust unit/integration | V2 artifact/approval tests | +| 第 8 个问题与上限后重试 | Rust unit | `GddPlanningPolicy` tests | +| 审批批准/修改/退回/过期审批 | Rust integration | V2 approval tests | +| 输入“按 A 做/按第一个选项做” | Rust/TS unit | input normalization tests | +| 做方案入口路由 | frontend integration | `App`/planning lane tests | +| 问题卡、GDD 卡和恢复态展示 | component | `ProjectSupervisorView`/`GddApprovalCard` tests | +| 真实 Provider 流式链路 | real provider smoke | P4 独立脚本或现有 real-e2e harness | +| 旧会话切换强制失败、旧目录不被 V2 读取 | Rust integration | legacy cutover/recovery tests | +| 中文编码和文档 diff | repository gate | `npm run check:encoding`、`git diff --check` | 未接入 Cucumber/Playwright runner 前,以上 BDD 先作为 Markdown 验收合同;不为本方案新增独立 BDD 测试框架。 ## 12. 风险与处理原则 -| 风险 | 处理 | -|---|---| -| 继续复用旧 `planning_submit.rs` 导致 Supervisor 身份回流 | V2 使用独立 artifact/approval 模块;只复用通用文件/锁能力 | -| 新旧都写 `game/fast_gdd.md` | 同一项目单活跃策划权威;V2/旧路径均使用项目锁和原子写 | -| 无限会话导致上下文无限膨胀 | 当前先分离完整记录和 ContextBuilder;超预算显式失败,后续再加摘要 | -| 未来 MCP/Skill 侵入 GDD 策略 | 能力快照和消息类型在 Runtime 层预留,当前策略不广告、不执行 | -| 强制失败导致旧 pending/receipt 不再可继续 | 这是本次明确的退役语义;旧文件只读保留,不迁移、不删除 | -| 前端组件名继续叫 Supervisor 造成误解 | 第一阶段只切数据源;后续独立重命名,不把命名重构当接入前置 | +| 风险 | 处理 | +| -------------------------------------------------------- | ----------------------------------------------------------------- | +| 继续复用旧 `planning_submit.rs` 导致 Supervisor 身份回流 | V2 使用独立 artifact/approval 模块;只复用通用文件/锁能力 | +| 新旧都写 `game/fast_gdd.md` | 同一项目单活跃策划权威;V2/旧路径均使用项目锁和原子写 | +| 无限会话导致上下文无限膨胀 | 当前先分离完整记录和 ContextBuilder;超预算显式失败,后续再加摘要 | +| 未来 MCP/Skill 侵入 GDD 策略 | 能力快照和消息类型在 Runtime 层预留,当前策略不广告、不执行 | +| 强制失败导致旧 pending/receipt 不再可继续 | 这是本次明确的退役语义;旧文件只读保留,不迁移、不删除 | +| 前端组件名继续叫 Supervisor 造成误解 | 第一阶段只切数据源;后续独立重命名,不把命名重构当接入前置 | ## 13. 完成定义