diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 9c9b1d506..2981b0a63 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -113,15 +113,6 @@ pub(crate) enum CliCommand { AgentResume { project_path: PathBuf, }, - PlanGddStatus { - project_path: PathBuf, - }, - PlanGddDecide { - project_path: PathBuf, - action: String, - response_id: Option, - read_comment_from_stdin: bool, - }, RunnerStatus, RunnerShutdownIfIdle, AgentRun { @@ -157,7 +148,6 @@ impl CliCommand { | Self::AgentGoalResume { .. } | Self::AgentGoalClear { .. } | Self::AgentResume { .. } - | Self::PlanGddDecide { .. } | Self::RunnerShutdownIfIdle ) } @@ -167,7 +157,6 @@ impl CliCommand { self, Self::AgentRuntimeStatus { .. } | Self::AgentGoalStatus { .. } - | Self::PlanGddStatus { .. } | Self::RunnerStatus ) } @@ -216,8 +205,6 @@ impl CliCommand { | Self::AgentRetry { project_path, .. } | Self::AgentSteer { project_path, .. } | Self::AgentResume { project_path } - | Self::PlanGddStatus { project_path } - | Self::PlanGddDecide { project_path, .. } | Self::PreviewServe { project_path } | Self::AgentRun { project_path, .. } => Some((project_path, false)), Self::LlmStatus | Self::RunnerStatus | Self::RunnerShutdownIfIdle => None, @@ -441,56 +428,6 @@ fn read_cli_agent_steer_instruction(reader: &mut impl Read) -> Result Result { - const MAX_STDIN_BYTES: u64 = 8 * 1024; - let mut bytes = Vec::new(); - reader - .take(MAX_STDIN_BYTES + 1) - .read_to_end(&mut bytes) - .map_err(|error| format!("从 stdin 读取 GDD 审批意见失败:{error}"))?; - if bytes.len() as u64 > MAX_STDIN_BYTES { - return Err(format!( - "GDD 审批意见 stdin 超过 {MAX_STDIN_BYTES} 字节上限" - )); - } - let comment = - String::from_utf8(bytes).map_err(|_| "GDD 审批意见 stdin 必须是 UTF-8 文本".to_string())?; - let comment = comment.trim(); - if comment.is_empty() { - return Err("GDD 审批意见 stdin 不能为空".to_string()); - } - // 长度上界交给 normalize_plan_gdd_approval_comment:审批意见的 1~1000 scalar - // 约束是写入侧权威,CLI 再抄一份就会有两个会漂移的判据。 - Ok(comment.to_string()) -} - -fn take_cli_named_flag_value( - args: &mut Vec, - flag: &str, - usage: &str, -) -> Result, String> { - let positions = args - .iter() - .enumerate() - .filter_map(|(index, arg)| (arg == flag).then_some(index)) - .collect::>(); - if positions.len() > 1 { - return Err(usage.to_string()); - } - let Some(index) = positions.first().copied() else { - return Ok(None); - }; - let value = args - .get(index + 1) - .map(String::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| usage.to_string())? - .to_string(); - args.drain(index..=index + 1); - Ok(Some(value)) -} - fn read_cli_agent_goal_payload(reader: &mut impl Read) -> Result { const MAX_STDIN_BYTES: u64 = 64 * 1024; let mut bytes = Vec::new(); @@ -720,53 +657,6 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result, S project_path: PathBuf::from(&args[1]), })); } - if args.first().map(String::as_str) == Some("--plan-gdd-status") { - const USAGE: &str = "用法:--plan-gdd-status <本地项目绝对路径>"; - if args.len() != 2 || args[1].trim().is_empty() { - return Err(USAGE.to_string()); - } - return Ok(Some(CliCommand::PlanGddStatus { - project_path: PathBuf::from(&args[1]), - })); - } - if args.first().map(String::as_str) == Some("--plan-gdd-decide") { - const USAGE: &str = "用法:--plan-gdd-decide <本地项目绝对路径> [--response-id ] [--stdin]"; - let mut rest = args[1..].to_vec(); - let read_comment_from_stdin = - match rest.iter().filter(|arg| arg.as_str() == "--stdin").count() { - 0 => false, - 1 => { - let index = rest - .iter() - .position(|arg| arg == "--stdin") - .ok_or_else(|| USAGE.to_string())?; - rest.remove(index); - true - } - _ => return Err(USAGE.to_string()), - }; - let response_id = take_cli_named_flag_value(&mut rest, "--response-id", USAGE)?; - if rest.len() != 2 || rest.iter().any(|value| value.trim().is_empty()) { - return Err(USAGE.to_string()); - } - let action = rest[1].trim().to_string(); - if !matches!(action.as_str(), "approve" | "revise" | "reject") { - return Err(USAGE.to_string()); - } - // revise/reject 的 comment 是写入侧硬约束,缺了必然在落盘前失败。在解析期就拒绝, - // 错误才指得回命令行本身,而不是变成一条读起来像后端故障的存储错误。 - if action != "approve" && !read_comment_from_stdin { - return Err( - "revise/reject 审批必须通过 --stdin 提供 1~1000 scalar 的修改意见".to_string(), - ); - } - return Ok(Some(CliCommand::PlanGddDecide { - project_path: PathBuf::from(&rest[0]), - action, - response_id, - read_comment_from_stdin, - })); - } if args.first().map(String::as_str) == Some("--agent-enqueue") { let mut rest = args[1..].to_vec(); let initialize = if let Some(index) = rest.iter().position(|arg| arg == "--init") { @@ -1515,63 +1405,6 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { ); Ok(()) } - CliCommand::PlanGddStatus { project_path } => { - let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; - let state = - hydrate_game_creator_plan_gdd_state_for_path(&project_path.display().to_string())?; - println!("plan.gdd.status"); - println!( - "planGddStateJson={}", - serialize_agent_runtime_cli_payload(&state)? - ); - Ok(()) - } - CliCommand::PlanGddDecide { - project_path, - action, - response_id, - read_comment_from_stdin, - } => { - let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; - require_external_agent_runner_for_cli_runtime_write(&project_path)?; - let comment = if read_comment_from_stdin { - Some(read_cli_plan_gdd_approval_comment( - &mut std::io::stdin().lock(), - )?) - } else { - None - }; - let project_path_value = project_path.display().to_string(); - // 审批卡的 identity 只有投影这一个权威来源。CLI 不接受手工传 gddId/fingerprint: - // 那样每个调用方都要自己拼一遍身份,拼错的后果是 PLAN_STALE_APPROVAL, - // 而不是一条能读懂的用法错误。 - let state = hydrate_game_creator_plan_gdd_state_for_path(&project_path_value)?; - let pending = state - .pending_approval - .ok_or_else(|| "当前没有待决定的 Fast GDD 审批".to_string())?; - // 每次调用换新 responseId 是安全方向:重复键的最坏后果是 replayed 降级成 - // already-decided(两者都成功),而复用键改 action 会撞「同 responseId 的 - // 审批意图不一致」硬错误。要复放同一次决定时才显式传 --response-id。 - let response_id = response_id - .unwrap_or_else(|| format!("gdd-response-{}", uuid::Uuid::new_v4().hyphenated())); - let result = decide_game_creator_plan_gdd( - project_path_value, - pending.gdd_ref.gdd_id.clone(), - pending.gdd_ref.version, - pending.gdd_ref.fingerprint.clone(), - pending.pending_action_id.clone(), - pending.approval_request_id.clone(), - response_id, - action, - comment, - )?; - println!("plan.gdd.decided"); - println!( - "planGddDecisionJson={}", - serialize_agent_runtime_cli_payload(&result)? - ); - Ok(()) - } CliCommand::RunnerStatus => { println!( "runnerJson={}", @@ -2079,116 +1912,6 @@ mod tests { ); } - #[test] - fn parses_plan_gdd_headless_approval_entries() { - assert_eq!( - parse_cli_command(&[ - "--plan-gdd-status".to_string(), - "/tmp/game-project".to_string(), - ]) - .expect("parse plan gdd status") - .expect("plan gdd status command"), - CliCommand::PlanGddStatus { - project_path: PathBuf::from("/tmp/game-project"), - } - ); - - assert_eq!( - parse_cli_command(&[ - "--plan-gdd-decide".to_string(), - "/tmp/game-project".to_string(), - " approve ".to_string(), - ]) - .expect("parse plan gdd approve") - .expect("plan gdd approve command"), - CliCommand::PlanGddDecide { - project_path: PathBuf::from("/tmp/game-project"), - action: "approve".to_string(), - response_id: None, - read_comment_from_stdin: false, - } - ); - - assert_eq!( - parse_cli_command(&[ - "--plan-gdd-decide".to_string(), - "/tmp/game-project".to_string(), - "revise".to_string(), - "--response-id".to_string(), - " gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427 ".to_string(), - "--stdin".to_string(), - ]) - .expect("parse plan gdd revise") - .expect("plan gdd revise command"), - CliCommand::PlanGddDecide { - project_path: PathBuf::from("/tmp/game-project"), - action: "revise".to_string(), - response_id: Some("gdd-response-1b4e28ba-2fa1-11d2-883f-0016d3cca427".to_string()), - read_comment_from_stdin: true, - } - ); - - // revise/reject 没有 --stdin 时必须在解析期就失败,否则错误会伪装成写入侧故障。 - for action in ["revise", "reject"] { - assert!(parse_cli_command(&[ - "--plan-gdd-decide".to_string(), - "/tmp/game-project".to_string(), - action.to_string(), - ]) - .is_err()); - } - for args in [ - vec!["--plan-gdd-decide"], - vec!["--plan-gdd-decide", "/tmp/game-project"], - vec!["--plan-gdd-decide", "/tmp/game-project", "confirm"], - vec!["--plan-gdd-decide", "/tmp/game-project", "approve", "extra"], - vec![ - "--plan-gdd-decide", - "/tmp/game-project", - "approve", - "--response-id", - ], - vec!["--plan-gdd-status", "/tmp/game-project", "extra"], - ] { - assert!( - parse_cli_command(&args.into_iter().map(str::to_string).collect::>()) - .is_err() - ); - } - } - - #[test] - fn plan_gdd_decision_requires_started_external_runner_but_status_does_not() { - let decide = CliCommand::PlanGddDecide { - project_path: PathBuf::from("/tmp/game-project"), - action: "approve".to_string(), - response_id: None, - read_comment_from_stdin: false, - }; - assert!(decide.requires_external_agent_runner()); - assert!(decide.requires_started_external_agent_runner()); - assert!(!decide.is_read_only_status()); - - let status = CliCommand::PlanGddStatus { - project_path: PathBuf::from("/tmp/game-project"), - }; - assert!(!status.requires_external_agent_runner()); - assert!(status.is_read_only_status()); - } - - #[test] - fn reads_plan_gdd_approval_comment_from_stdin() { - assert_eq!( - read_cli_plan_gdd_approval_comment(&mut Cursor::new(" 把核心循环写具体 \n")) - .expect("read approval comment"), - "把核心循环写具体" - ); - assert!(read_cli_plan_gdd_approval_comment(&mut Cursor::new(" \n")).is_err()); - assert!( - read_cli_plan_gdd_approval_comment(&mut Cursor::new("a".repeat(9 * 1024))).is_err() - ); - } - #[test] fn reads_strict_structured_goal_payload_from_stdin() { let mut stdin = Cursor::new( diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 4587f4545..a04a21408 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -304,24 +304,6 @@ pub(crate) async fn import_ui_editor_assets( } } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct HydratePlanGddStateInput { - project_path: String, -} - -fn parse_hydrate_plan_gdd_state_input( - request: &tauri::ipc::Request<'_>, -) -> Result { - match request.body() { - tauri::ipc::InvokeBody::Json(value) => serde_json::from_value(value.clone()) - .map_err(|error| format!("hydrate_game_creator_plan_gdd_state 输入无效:{error}")), - tauri::ipc::InvokeBody::Raw(_) => { - Err("hydrate_game_creator_plan_gdd_state 只接受 JSON 输入 {projectPath}".to_string()) - } - } -} - pub(crate) fn closest_existing_project_picker_directory(path: &Path) -> Option { let mut candidate = if path.exists() && path.is_dir() { Some(path) @@ -1809,79 +1791,6 @@ pub(crate) fn answer_game_creator_agent_runtime_user_input( ) } -#[tauri::command] -pub(crate) fn decide_game_creator_plan_gdd( - project_path: String, - gdd_id: String, - version: u32, - fingerprint: String, - pending_action_id: String, - approval_request_id: String, - response_id: String, - action: String, - comment: Option, -) -> Result { - let root = validated_local_project_directory_path(project_path.trim())?; - enforce_project_permission_policy(&root, "conversation.read")?; - enforce_project_permission_policy(&root, "conversation.write")?; - enforce_project_permission_policy(&root, "agent.run_status")?; - enforce_project_permission_policy(&root, "agent.resume")?; - let mut result = match decide_plan_gdd_at( - &root, - &DecidePlanGddInputV1 { - gdd_id, - version, - fingerprint, - pending_action_id, - approval_request_id, - response_id, - action, - comment, - }, - ) { - Ok(result) => result, - Err(error) => { - return Err(error.to_string()); - } - }; - if !result.recovery_pending { - if let Err(error) = wake_pending_game_creator_agent_background_tasks_at(&root) { - let detail = error.to_string(); - crate::error_report::report_diagnostic_error( - "agent", - &detail, - None, - Some("wake_pending_game_creator_agent_background_tasks"), - None, - ); - // The receipt is already the user-decision linearization point; - // surface a recoverable projection state instead of turning a - // durable approval into a false command failure. - result.recovery_pending = true; - } - } - Ok(result) -} - -#[tauri::command] -pub(crate) fn hydrate_game_creator_plan_gdd_state( - request: tauri::ipc::Request<'_>, -) -> Result { - let input = parse_hydrate_plan_gdd_state_input(&request)?; - hydrate_game_creator_plan_gdd_state_for_path(&input.project_path) -} - -/// Transport-independent projection read shared by the Tauri command and the -/// headless CLI entry. Both must cross the same permission gate, otherwise the -/// CLI would become a way to read a project the policy denies. -pub(crate) fn hydrate_game_creator_plan_gdd_state_for_path( - project_path: &str, -) -> Result { - let root = validated_local_project_directory_path(project_path.trim())?; - enforce_project_permission_policy(&root, "conversation.read")?; - hydrate_game_creator_plan_gdd_state_at(&root).map_err(|error| error.to_string()) -} - #[tauri::command] pub(crate) fn read_game_creator_agent_runtime( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 9ff2e207f..9f28d09ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2530,8 +2530,6 @@ fn main() { confirm_game_creator_agent_runtime_task, reject_game_creator_agent_runtime_task, answer_game_creator_agent_runtime_user_input, - decide_game_creator_plan_gdd, - hydrate_game_creator_plan_gdd_state, read_game_creator_agent_runtime, read_game_creator_agent_runtimes, resume_game_creator_agent_runtime_tasks, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9dbff41bb..8a9d335a1 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -250,10 +250,7 @@ import { ProjectSupervisorView } from './features/project-workspace/ProjectSuper import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; -import { - captureAgentRuntimeError, - invokeDiagnostic, -} from './services/errorReporting'; +import { captureAgentRuntimeError } from './services/errorReporting'; import type { HomeCreationType } from './view/home'; import { type ProjectAgentResultSummary, @@ -799,6 +796,9 @@ export function App({ }, ); if (!result) { + planningV2SessionRef.current = null; + setPlanningV2Session(null); + setPlanGddState(null); if (planningStartMode) { planningV2ActiveRef.current = true; setPlanningV2Active(true); @@ -818,63 +818,11 @@ 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) { - // 与旧 hydrate 相同:项目写锁争用是瞬时的。V2 审批修改后会立刻续跑并 - // 重灌,下一拍还能拿到;把占用画进错误位会让刚提交的修改意见看起来失败。 - const transientContention = - String(error).includes('项目正在被其他写操作占用:'); - if ( - !transientContention && - requestSequence === planGddHydrateSequenceRef.current - ) { - setPlanGddError(String(error)); - } - } finally { - if (requestSequence === planGddHydrateSequenceRef.current) { - setPlanGddHydrateBusy(false); - } - } - return; - } const requestSequence = ++planGddHydrateSequenceRef.current; setPlanGddHydrateBusy(true); setPlanGddError(null); try { - const nextState = await invoke( - 'hydrate_game_creator_plan_gdd_state', - { projectPath: targetProjectPath }, - ); - if ( - requestSequence === planGddHydrateSequenceRef.current && - localProjectPathRef.current === targetProjectPath - ) { - setPlanGddState(nextState); - } + await hydratePlanningV2Session(targetProjectPath); } catch (error) { // 项目写锁争用是瞬时的:后端已经等过一个短窗口,仍然没抢到只说明此刻 // 运行时正在写盘。这条 effect 每次监工状态变化都会再跑一次,下一拍就能 @@ -933,52 +881,37 @@ 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); - if (action === 'revise' && comment?.trim()) { - await executePlanningV2Turn( - targetProjectPath, - `审批:revise ${comment.trim()}`, - `planning-v2-revision-${crypto.randomUUID()}`, - ); - } - return; + const planningSessionId = current.session?.sessionId; + if (!planningSessionId) { + throw new Error('Planning V2 Session 不存在'); } - await invokeDiagnostic(invoke, 'decide_game_creator_plan_gdd', { - projectPath: targetProjectPath, - gddId: pending.gddRef.gddId, - version: pending.gddRef.version, - fingerprint: pending.gddRef.fingerprint, - pendingActionId: pending.pendingActionId, - approvalRequestId: pending.approvalRequestId, - responseId, - action, - comment, + 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, }); - await hydratePlanGddState(targetProjectPath); + setPlanGddError(null); + if (action === 'revise' && comment?.trim()) { + await executePlanningV2Turn( + targetProjectPath, + `审批:revise ${comment.trim()}`, + `planning-v2-revision-${crypto.randomUUID()}`, + ); + } } catch (error) { // 方案 §18.3 要求 decision 返回后以 hydrate 对权威文件的重验为准,失败分支同样 // 适用:不重灌就会让卡片停在已失效的 pending 身份上,三个决定按钮仍可点,且 diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index db156dfb5..30d32480f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -519,12 +519,10 @@ function createProjectSupervisorRuntimeHarness({ let answerRuntime: Record | null = null; let answerFailuresRemaining = 0; // 默认不配置策划状态:hydrate 与接入本 harness 之前一样抛出,既有用例行为不变。 - let currentPlanGddState: PlanGddStateViewV1 | null = null; - let planGddDecisionError: string | null = null; - let planGddHydrateCount = 0; let currentPlanningV2Result = planningV2Result; let currentPlanningV2StartResult = planningV2StartResult; - const planGddDecisionCalls: Array> = []; + let planningV2DecisionError: string | null = null; + const planningV2DecisionCalls: Array> = []; let runtimeUpdateHandler: | ((event: { payload: { @@ -842,13 +840,6 @@ function createProjectSupervisorRuntimeHarness({ }); return runtimeResult(); } - if (command === 'hydrate_game_creator_plan_gdd_state') { - planGddHydrateCount += 1; - if (!currentPlanGddState) { - throw new Error('PLAN_STATE_NOT_CONFIGURED'); - } - return currentPlanGddState; - } if (command === 'hydrate_planning_session_v2') { return currentPlanningV2Result; } @@ -867,24 +858,17 @@ function createProjectSupervisorRuntimeHarness({ return nextResult; } if (command === 'decide_planning_artifact_v2') { + planningV2DecisionCalls.push({ ...(args ?? {}) }); + if (planningV2DecisionError) { + const failure = planningV2DecisionError; + planningV2DecisionError = null; + throw new Error(failure); + } if (!currentPlanningV2Result) { throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED'); } return currentPlanningV2Result; } - if (command === 'decide_game_creator_plan_gdd') { - planGddDecisionCalls.push({ ...(args ?? {}) }); - if (planGddDecisionError) { - const failure = planGddDecisionError; - planGddDecisionError = null; - throw new Error(failure); - } - return { - schemaVersion: 'plan-gdd-decision-result.v1', - outcome: 'decided', - recoveryPending: false, - }; - } throw new Error(`unexpected invoke ${command}`); }, ); @@ -945,22 +929,16 @@ function createProjectSupervisorRuntimeHarness({ failNextAnswers(count = 1) { answerFailuresRemaining = count; }, - setPlanGddState(state: PlanGddStateViewV1 | null) { - currentPlanGddState = state; - }, setPlanningV2Result(state: Record | null) { currentPlanningV2Result = state; }, setPlanningV2StartResult(state: Record | null) { currentPlanningV2StartResult = state; }, - failNextPlanGddDecision(message: string) { - planGddDecisionError = message; - }, - planGddDecisionCalls, - planGddHydrateCount() { - return planGddHydrateCount; + failNextPlanningV2Decision(message: string) { + planningV2DecisionError = message; }, + planningV2DecisionCalls, appendSupervisorMessage(message: Record) { currentSupervisorMessages.push(message); }, 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 181b971a1..65be28f15 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 @@ -39,14 +39,28 @@ function planningV2ApprovalResult() { if (!displayGdd || !session) { throw new Error('fixture 应当带 GDD 和 session'); } + return planningV2ResultFor( + session, + displayGdd, + 'awaiting_approval', + 'ready_for_approval', + ); +} + +function planningV2ResultFor( + session: NonNullable, + displayGdd: NonNullable, + sessionStatus: string, + artifactStatus: string, +) { return { session: { schemaVersion: 'planning-session.v2', engine: 'planning-session-v2', sessionId: session.sessionId, - projectId: state.projectId, + projectId: 'local-project-draft', mode: 'gdd', - status: 'awaiting_approval', + status: sessionStatus, turnIndex: 3, questionCount: session.clarificationRound, questionLimit: 8, @@ -64,7 +78,7 @@ function planningV2ApprovalResult() { artifactId: displayGdd.gddId, kind: 'gdd', version: displayGdd.version, - status: 'ready_for_approval', + status: artifactStatus, fingerprint: displayGdd.fingerprint, payload: { schemaVersion: 'plan-gdd.v2', @@ -83,6 +97,32 @@ function planningV2ApprovalResult() { }; } +/** 已批准:审批卡整张收掉,只剩标题栏和它下面的交付行。 */ +function planningV2ApprovedResult() { + const state = createPlanGddStateView(); + const displayGdd = state.displayGdd; + const session = state.session; + if (!displayGdd || !session) { + throw new Error('fixture 应当带 GDD 和 session'); + } + return planningV2ResultFor(session, displayGdd, 'approved', 'approved'); +} + +/** 策划中、尚未提交 GDD 的状态:没有待审批,也没有可展示的 GDD。 */ +function planningV2WorkingResult(questionCount = 2) { + const questionResult = planningV2QuestionResult(); + return { + ...questionResult, + session: { + ...questionResult.session, + status: 'planning', + questionCount, + currentQuestion: null, + }, + result: null, + }; +} + function planningV2QuestionResult() { return { session: { @@ -158,23 +198,6 @@ async function mountPlanningSurface( type PlanGddStateView = ReturnType; -/** 策划中、尚未提交 GDD 的状态:没有待审批,也没有可展示的 GDD。 */ -function draftPlanGddState( - sessionOverrides: Partial> = {}, -) { - const base = createPlanGddStateView(); - return createPlanGddStateView({ - state: 'draft', - versions: [], - displayGdd: null, - pendingApproval: null, - approvedGddRef: null, - session: base.session - ? { ...base.session, phase: 'collecting', ...sessionOverrides } - : null, - }); -} - /** 已批准:审批卡整张收掉,只剩标题栏和它下面的交付行。 */ function approvedPlanGddState() { const base = createPlanGddStateView(); @@ -224,25 +247,26 @@ function typeComment(dialog: HTMLElement, text: string) { export function registerPlanGddApprovalTests() { it('re-hydrates authority after a failed GDD decision and keeps the failure visible', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(createPlanGddStateView()); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovalResult(), + }); await mountApprovalCard(harness); - const hydrateCallsBeforeDecision = harness.planGddHydrateCount(); - // 决定失败后,hydrate 应当读到权威侧已经变成「投影恢复中」的事实。 - harness.setPlanGddState(createPlanGddStateView({ recoveryPending: true })); - harness.failNextPlanGddDecision('PLAN_STALE_APPROVAL'); + const hydrateCallsBeforeDecision = harness.invoke.mock.calls.filter( + ([command]) => command === 'hydrate_planning_session_v2', + ).length; + harness.failNextPlanningV2Decision('PLAN_STALE_APPROVAL'); fireEvent.click(screen.getByRole('button', { name: '批准 v1' })); // 失败分支必须重灌权威状态,否则卡片会停在已失效的 pending 身份上。 await waitFor(() => { - expect(harness.planGddHydrateCount()).toBeGreaterThan( - hydrateCallsBeforeDecision, - ); + expect( + harness.invoke.mock.calls.filter( + ([command]) => command === 'hydrate_planning_session_v2', + ).length, + ).toBeGreaterThan(hydrateCallsBeforeDecision); }); - // 重灌后「重试同步」入口出现——这是 recoveryPending 下唯一被允许的动作。 - await screen.findByRole('button', { name: '重试同步' }); // 而且重灌不能把决定失败的原因擦掉:hydrate 入口会 setPlanGddError(null), // 两句顺序写反这条断言就红。 expect(screen.getByRole('alert').textContent).toContain( @@ -250,9 +274,10 @@ export function registerPlanGddApprovalTests() { ); }); - it('reuses one responseId while the decision is unchanged and mints a new one once the comment changes', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(createPlanGddStateView()); + it('reuses one decisionId while the decision is unchanged and mints a new one once the comment changes', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovalResult(), + }); await mountApprovalCard(harness); const dialog = openReviseDialog(); @@ -262,14 +287,14 @@ export function registerPlanGddApprovalTests() { within(dialog).getByRole('button', { name: '提交决定' }), ).toHaveProperty('disabled', false); }); - harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED'); + harness.failNextPlanningV2Decision('PLAN_DURABILITY_FAILED'); fireEvent.click(screen.getByRole('button', { name: '提交决定' })); await waitFor(() => { - expect(harness.planGddDecisionCalls).toHaveLength(1); + expect(harness.planningV2DecisionCalls).toHaveLength(1); }); - // 原样重试:属于方案 §13.2 的 busy/超时/网络重试,必须复用同一 responseId。 - harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED'); + // 原样重试:属于方案 §13.2 的 busy/超时/网络重试,必须复用同一 decisionId。 + harness.failNextPlanningV2Decision('PLAN_DURABILITY_FAILED'); await waitFor(() => { expect( within(dialog).getByRole('button', { name: '提交决定' }), @@ -277,16 +302,16 @@ export function registerPlanGddApprovalTests() { }); fireEvent.click(screen.getByRole('button', { name: '提交决定' })); await waitFor(() => { - expect(harness.planGddDecisionCalls).toHaveLength(2); + expect(harness.planningV2DecisionCalls).toHaveLength(2); }); - expect(harness.planGddDecisionCalls[1].responseId).toBe( - harness.planGddDecisionCalls[0].responseId, + expect(harness.planningV2DecisionCalls[1].decisionId).toBe( + harness.planningV2DecisionCalls[0].decisionId, ); - // 改写修改意见:审批意图变了,必须换新的 responseId,否则后端会以 - // 「同 responseId 的审批意图不一致」硬拒,用户改写后的原因永远落不了盘。 + // 改写修改意见:审批意图变了,必须换新的 decisionId,否则后端会以 + // 「同 decisionId 的审批意图不一致」硬拒,用户改写后的原因永远落不了盘。 typeComment(dialog, '把核心循环压到两步,并去掉天气系统'); - harness.failNextPlanGddDecision('PLAN_DURABILITY_FAILED'); + harness.failNextPlanningV2Decision('PLAN_DURABILITY_FAILED'); await waitFor(() => { expect( within(dialog).getByRole('button', { name: '提交决定' }), @@ -294,156 +319,56 @@ export function registerPlanGddApprovalTests() { }); fireEvent.click(screen.getByRole('button', { name: '提交决定' })); await waitFor(() => { - expect(harness.planGddDecisionCalls).toHaveLength(3); + expect(harness.planningV2DecisionCalls).toHaveLength(3); }); - expect(harness.planGddDecisionCalls[2].responseId).not.toBe( - harness.planGddDecisionCalls[0].responseId, + expect(harness.planningV2DecisionCalls[2].decisionId).not.toBe( + harness.planningV2DecisionCalls[0].decisionId, ); - expect(harness.planGddDecisionCalls[2].comment).toBe( + expect(harness.planningV2DecisionCalls[2].comment).toBe( '把核心循环压到两步,并去掉天气系统', ); }); it('labels the clarification round the user is actually on rather than the 0-indexed answered count', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - const base = createPlanGddStateView(); - const session = base.session; - if (!session) { - throw new Error('fixture 应当带 session'); - } - // clarificationRound 是「已答轮数」(0-indexed):等待第 3 轮回答时它恒为 2。 - // 直接渲染成「轮次 2/3」会暗示还剩一轮,而这已经是硬上限的最后一轮。 - harness.setPlanGddState( - createPlanGddStateView({ + // questionCount 是「已展示问题数」:等待第 3 轮回答时它恒为 3,当前轮次 + // 由 currentQuestion 钉住。直接渲染成「轮次 2/8」会暗示还剩一轮。 + const questionResult = planningV2QuestionResult(); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: { + ...questionResult, session: { - ...session, - phase: 'awaiting_user_input', - clarificationRound: 2, - awaitingAnswerFor: { - delegationId: 'delegation-0003', - requestId: 'request-0003', - questionId: 'question-0003', - round: 2, - }, + ...questionResult.session, + questionCount: 3, }, - }), - ); - await mountApprovalCard(harness); + result: null, + }, + }); + await mountPlanningSurface(harness); const progress = await screen.findByLabelText('立项策划阶段进度'); - expect(within(progress).getByText('第 3 轮 / 共 3 轮')).not.toBeNull(); - expect(within(progress).queryByText('轮次 2/3')).toBeNull(); + expect(within(progress).getByText('第 3 轮 / 共 8 轮')).not.toBeNull(); + expect(within(progress).queryByText(/轮次 \d+\/\d+/)).toBeNull(); }); it('falls back to an answered-count label when no clarification answer is outstanding', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - // awaitingAnswerFor 为 null(含恢复态)时不去猜当前是第几轮,只报已完成多少轮。 - harness.setPlanGddState(createPlanGddStateView()); - await mountApprovalCard(harness); + // currentQuestion 为 null(含恢复态)时不去猜当前是第几轮,只报已完成多少轮。 + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2WorkingResult(2), + }); + await mountPlanningSurface(harness); const progress = await screen.findByLabelText('立项策划阶段进度'); - expect(within(progress).getByText('已完成 2/3 轮澄清')).not.toBeNull(); - }); - - it('blocks the already-open comment dialog once recovery starts without discarding the typed reason', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(createPlanGddStateView()); - await mountApprovalCard(harness); - - const dialog = openReviseDialog(); - const textarea = typeComment(dialog, '战斗节奏太慢,请压缩到三个回合'); - await waitFor(() => { - expect( - ( - within(dialog).getByRole('button', { - name: '提交决定', - }) as HTMLButtonElement - ).disabled, - ).toBe(false); - }); - - // 弹层是打开之后才被后台 hydrate 翻掉决定资格的:触发按钮的 disabled 管不到它。 - harness.setPlanGddState(createPlanGddStateView({ recoveryPending: true })); - fireEvent.focus(window); - - await waitFor(() => { - expect( - ( - within(dialog).getByRole('button', { - name: '提交决定', - }) as HTMLButtonElement - ).disabled, - ).toBe(true); - }); - // 已经写好的原因不能被丢掉——所以是禁用加说明,不是自动关弹层。 - expect(textarea.value).toBe('战斗节奏太慢,请压缩到三个回合'); - expect(harness.planGddDecisionCalls).toHaveLength(0); - }); - - it('shows only the approval card while the GDD decision is outstanding', async () => { - // 审批等待复用了 waiting-for-user-input 这个 phase,但它没有 userInputRequest;而 - // 策划子 Agent 的 plan.submit_gdd pending 正是 planning/pending.json 里 - // submission.pendingActionId 指向的那一个,交互面就是审批卡本身。两处都套通用面的话, - // 用户会同时看到一句「待回答问题未能读取」和一张点了必然失败的 plan.submit_gdd - // 确认卡,把真正该看的审批卡压在下面。 - const supervisorRunId = 'plan-root-awaiting-approval-run'; - let collaboratingRuntimes: Array> = []; - const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: supervisorRunId, - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'running', - phase: 'waiting-for-user-input', - currentTask: '剧情向恋爱养成游戏', - waitingOn: '用户在审批卡选择批准、修改或退回', - nextStep: '等待 decide_game_creator_plan_gdd', - updatedAt: 8000, - }, - runtimeMapLoader: async () => collaboratingRuntimes, - }); - collaboratingRuntimes = [ - harness.runtimeState({ - agentId: 'project-planning', - taskId: 'project-planning', - sessionId: 'agent-session-project-planning', - runId: 'delegated-delegation-344abab06ded0da1cf572fa3', - source: 'agent-delegate', - parentAgentId: 'project-supervisor', - parentRunId: supervisorRunId, - delegationId: 'delegation-344abab06ded0da1cf572fa3', - runProfile: 'standard', - status: 'idle', - phase: 'completed', - currentTask: '完成立项策划并给出 Fast GDD', - pendingToolAction: { - actionId: 'action-0123456789abcdef01234567', - actionFingerprint: 'a'.repeat(64), - tool: 'plan.submit_gdd', - inputSummary: null, - reason: '提交完整原创 MVP Fast GDD。', - requestedAt: 8000, - }, - updatedAt: 8000, - }), - ]; - harness.setPlanGddState(createPlanGddStateView()); - await mountApprovalCard(harness); - - expect(screen.queryByText('待回答问题未能读取,请稍后重试。')).toBeNull(); - expect(screen.queryByText(/plan\.submit_gdd/)).toBeNull(); - expect(screen.queryByLabelText('立项策划 Agent待确认动作')).toBeNull(); - expect(screen.getByRole('button', { name: '批准 v1' })).not.toBeNull(); + expect(within(progress).getByText('已完成 2/8 轮澄清')).not.toBeNull(); }); it('paints the planning state once in the stage strip and keeps the fingerprint inside the GDD dialog', async () => { // 阶段进度和审批卡曾各自带框叠在一起,「立项策划 / 待审批」在标题栏和卡头各画一遍, // 卡头还露一截指纹。合成一个面之后:状态与版本只在标题栏,卡头只剩游戏标题和一句 // 话,指纹整条放进正文弹层做追溯。 - const harness = createProjectSupervisorRuntimeHarness(); - const view = createPlanGddStateView(); - harness.setPlanGddState(view); + const result = planningV2ApprovalResult(); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: result, + }); const card = await mountApprovalCard(harness); const surface = screen.getByLabelText('立项策划'); @@ -457,7 +382,7 @@ export function registerPlanGddApprovalTests() { expect(within(card).queryByText(/^版本 v1$/)).toBeNull(); expect(within(card).queryByText(/指纹/)).toBeNull(); expect(within(card).getByRole('heading', { level: 2 }).textContent).toBe( - view.displayGdd?.game.title, + result.currentArtifact.payload.game.title, ); fireEvent.click( @@ -465,7 +390,7 @@ export function registerPlanGddApprovalTests() { ); const dialog = screen.getByRole('dialog'); expect(within(dialog).getByLabelText('GDD 版本与指纹').textContent).toBe( - `Fast GDD v1 · ${view.displayGdd?.fingerprint}`, + `Fast GDD v1 · ${result.currentArtifact.fingerprint}`, ); }); @@ -473,8 +398,9 @@ export function registerPlanGddApprovalTests() { // 批准之后审批卡按设计整张收掉,此前那一刻起用户就再也够不到 GDD:approvedGddRef // 前端没人读,渲染好的 Markdown 也没有出口。交付行补的就是这个缺口——不额外占一 // 张卡,只在标题栏下多一行:文件在哪、看正文、用外部程序打开。 - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(approvedPlanGddState()); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovedResult(), + }); await mountPlanningSurface(harness); expect(screen.queryByLabelText('GDD 审批卡')).toBeNull(); @@ -495,8 +421,9 @@ export function registerPlanGddApprovalTests() { }); it('asks the shell to open the rendered GDD for the current project', async () => { - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState(approvedPlanGddState()); + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovedResult(), + }); await mountPlanningSurface(harness); const delivery = await screen.findByLabelText('GDD 交付'); @@ -534,72 +461,18 @@ export function registerPlanGddApprovalTests() { }); }); - it('keeps the delivery row hidden while the approval projection is still recovering', async () => { - // 恢复态下权威投影还没收敛,磁盘上那份 Markdown 未必是用户批的那版。此时给出口 - // 等于让用户读一份可能已经失效的交付物。 - const harness = createProjectSupervisorRuntimeHarness(); - harness.setPlanGddState({ - ...approvedPlanGddState(), - recoveryPending: true, - }); - await mountPlanningSurface(harness); - - expect(screen.queryByLabelText('GDD 交付')).toBeNull(); - }); - it('keeps the supervisor runtime panel off the planning lane while the planner is working', async () => { // 完整面板是给做游戏链路的:十几个专业 Agent、多步计划、逐 Agent 重试。策划链路 - // 只有一个 project-planning 子 Run、一两步计划,面板画出来的全是 D11 拓扑的内部 - // 记账(currentAction 原文、计划 1/2、「专业 Agent 协作:1」、子 Agent 卡)。 - // 状态由顶部的阶段进度承担;底部在策划正常进行时什么都不该画。 - const supervisorRunId = 'plan-root-collecting-run'; + // 只有一个策划会话,面板画出来的全是内部记账。状态由顶部的阶段进度承担; + // 底部在策划正常进行时什么都不该画。 const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: supervisorRunId, - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'running', - phase: 'waiting-for-delegate-receipts', - currentTask: '剧情向恋爱养成游戏', - currentAction: '等待 project-planning 提交 GDD', - waitingOn: '策划子 Run 提交 Fast GDD', - nextStep: '读取 GDD 并进入审批', - plan: ['委派策划子 Run', '读取 GDD 并审批'], - planSteps: [ - { step: '委派策划子 Run', status: 'active' }, - { step: '读取 GDD 并审批', status: 'pending' }, - ], - activePlanStepIndex: 0, - updatedAt: 7000, - }, - runtimeMapLoader: async () => [ - harness.runtimeState({ - agentId: 'project-planning', - taskId: 'project-planning', - sessionId: 'agent-session-project-planning', - runId: 'delegated-delegation-collecting', - source: 'agent-delegate', - parentAgentId: 'project-supervisor', - parentRunId: supervisorRunId, - delegationId: 'delegation-collecting', - runProfile: 'standard', - status: 'running', - phase: 'running', - currentTask: '完成立项策划并给出 Fast GDD', - currentAction: '整理第 1 轮澄清答案', - updatedAt: 7000, - }), - ], + planningV2Result: planningV2WorkingResult(), }); - harness.setPlanGddState(draftPlanGddState()); const progress = await mountPlanningSurface(harness); expect(within(progress).getByText('策划中')).not.toBeNull(); expectSupervisorRuntimePanelAbsent(); expect(screen.queryByLabelText('立项策划运行状态')).toBeNull(); - expect(screen.queryByText('等待 project-planning 提交 GDD')).toBeNull(); - expect(screen.queryByText('整理第 1 轮澄清答案')).toBeNull(); expect(screen.queryByText(/计划 \d+\/\d+/)).toBeNull(); });