diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 3229b753c..81dc05d2f 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -960,12 +960,25 @@ export function swarmAutoPilotShouldCloseInput(output, promptsAfterSubmit) { return swarmAutoPilotSitsAtPrompt(output) && promptsAfterSubmit >= 1; } +// GDD 审批位不能等 CLI 退出之后再处理:Run 停在这里时状态是 waiting-for-user-input, +// 而 swarm CLI 恰好把这个状态算作「本轮还在跑」,turn 永远不 settle,CLI 也就永远 +// 不退出。所以审批必须在 CLI 还活着的时候并发做完,让 Run 自己继续跑到收束。 +// 这一句是 PlanGddCompletionBlockerKind::AwaitingApprovalDecision 专有的投影文案, +// 另外三个 blocked 子状态都不会打出它;即便认错了,真正的判据也是随后那次 +// --plan-gdd-status,没有待决定审批时不会有任何写入。 +const planGddApprovalWaitPattern = /等待 Fast GDD 审批决定/u; + +export function swarmOutputAwaitsPlanGddApproval(line) { + return planGddApprovalWaitPattern.test(line); +} + async function runTaskCargo( cliArguments, task, setActiveChild, timeoutMs, autoPilot = false, + onPlanGddApprovalWait = null, ) { const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { stdio: ['pipe', 'pipe', 'inherit'], @@ -977,6 +990,23 @@ async function runTaskCargo( let taskSubmitted = false; let promptsSeen = 0; let sittingAtPrompt = false; + let planGddApproval = null; + let planGddApprovalError = null; + let planGddApprovalStarted = false; + let planGddApprovalPromise = null; + const startPlanGddApproval = () => { + planGddApprovalStarted = true; + console.log('[自动审批] 检测到 Fast GDD 审批位,正在提交 approve'); + planGddApprovalPromise = onPlanGddApprovalWait() + .then((value) => { + planGddApproval = value; + }) + .catch((error) => { + planGddApprovalError = error; + // 审批没成的话 Run 会一直停在等待位,干等到超时只会把真正的原因埋掉。 + void terminateChildTree(child).catch(() => {}); + }); + }; child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk) => { process.stdout.write(chunk); @@ -989,6 +1019,13 @@ async function runTaskCargo( reportLines.push(normalizedLine); settled = true; } + if ( + onPlanGddApprovalWait && + !planGddApprovalStarted && + swarmOutputAwaitsPlanGddApproval(normalizedLine) + ) { + startPlanGddApproval(); + } } if (!autoPilot || child.stdin.writableEnded) return; const atPrompt = swarmAutoPilotSitsAtPrompt(pendingLine); @@ -1027,7 +1064,13 @@ async function runTaskCargo( if (normalizedPendingLine.startsWith(swarmTurnReportPrefix)) { reportLines.push(normalizedPendingLine); } - return { ...result, turnReportOutput: reportLines.join('\n') }; + await planGddApprovalPromise; + if (planGddApprovalError) throw planGddApprovalError; + return { + ...result, + turnReportOutput: reportLines.join('\n'), + planGddApproval, + }; } finally { setActiveChild(null); } @@ -1752,6 +1795,137 @@ async function reportPlanningOutputs(projectPath) { } } +const planGddApprovalTimeoutMs = 60_000; +export const planGddStatusOutputPrefix = 'planGddStateJson='; +export const planGddDecisionOutputPrefix = 'planGddDecisionJson='; + +function parsePrefixedJsonLine(output, prefix, label) { + const line = output + .split('\n') + .map((value) => (value.endsWith('\r') ? value.slice(0, -1) : value)) + .find((value) => value.startsWith(prefix)); + if (!line) throw new Error(`${label}缺少 ${prefix} 输出`); + try { + return JSON.parse(line.slice(prefix.length)); + } catch (error) { + throw new Error(`解析${label}失败:${error.message}`); + } +} + +export function parsePlanGddStatusOutput(output) { + return parsePrefixedJsonLine( + output, + planGddStatusOutputPrefix, + 'Fast GDD 审批状态', + ); +} + +export function parsePlanGddDecisionOutput(output) { + return parsePrefixedJsonLine( + output, + planGddDecisionOutputPrefix, + 'Fast GDD 审批回执', + ); +} + +// 审批卡是这条链路唯一的人类判据,所以自动应答只投 approve,且只在投影确实有一张 +// 待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把判据 +// 换成噪声;要跑那两条分支就手工调 --plan-gdd-decide。 +export function planGddAutoApprovalIsPending(state) { + return Boolean(state?.pendingApproval); +} + +async function settlePlanGddApproval( + projectPath, + runtimeConfigPath, + setActiveChild, +) { + const readStatus = async () => { + const result = await runCapturedCargo( + ['--config-dir', runtimeConfigPath, '--plan-gdd-status', projectPath], + setActiveChild, + { + timeoutMs: planGddApprovalTimeoutMs, + label: 'Fast GDD 审批状态查询', + }, + ); + if (result.code !== 0 || result.signal) { + throw new Error( + `读取 Fast GDD 审批状态失败:${result.stderr.trim() || result.stdout.trim()}`, + ); + } + return parsePlanGddStatusOutput(result.stdout); + }; + + const before = await readStatus(); + if (!planGddAutoApprovalIsPending(before)) { + return { decided: false, state: before }; + } + const decision = await runCapturedCargo( + [ + '--config-dir', + runtimeConfigPath, + '--plan-gdd-decide', + projectPath, + 'approve', + ], + setActiveChild, + { + timeoutMs: planGddApprovalTimeoutMs, + label: 'Fast GDD 审批决定', + }, + ); + if (decision.code !== 0 || decision.signal) { + throw new Error( + `提交 Fast GDD 审批决定失败:${decision.stderr.trim() || decision.stdout.trim()}`, + ); + } + const receipt = parsePlanGddDecisionOutput(decision.stdout); + // 回执落盘和唤醒后台任务是两件事:decide 命令把唤醒失败降级成 recoveryPending, + // 于是审批已经生效、Run 却仍停在 waiting-for-user-input。实测就是这样——只有 + // 补一次 --agent-resume 才会重新起 turn。这是仓库自己给这个状态定义的恢复动作。 + let recovered = false; + if (receipt.recoveryPending) { + const resume = await runCapturedCargo( + ['--config-dir', runtimeConfigPath, '--agent-resume', projectPath], + setActiveChild, + { + timeoutMs: planGddApprovalTimeoutMs, + label: 'Fast GDD 审批后恢复后台任务', + }, + ); + if (resume.code !== 0 || resume.signal) { + throw new Error( + `审批已提交但恢复后台任务失败:${resume.stderr.trim() || resume.stdout.trim()}`, + ); + } + recovered = true; + } + return { decided: true, receipt, recovered, state: await readStatus() }; +} + +async function reportPlanGddApproval(approval) { + const { state } = approval; + console.log('\nFast GDD 审批:'); + if (!approval.decided) { + console.log(` [无待决定审批] 当前投影状态=${state.state}`); + return; + } + console.log( + ` [已批准] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`, + ); + if (approval.recovered) { + console.log( + ' [已恢复] 审批回执的 recoveryPending 由一次 --agent-resume 收口', + ); + } + if (state.session) { + console.log( + ` 澄清轮次=${state.session.clarificationRound} 返工深度=${state.session.repairDepth} phase=${state.session.phase}`, + ); + } +} + export async function hasConfiguredEditorApiKey(configDir) { let configured = false; for (const fileName of [configFileName, localConfigFileName]) { @@ -1904,17 +2078,29 @@ export async function runSwarmTestChat(options) { const setActiveChild = (child) => { activeChild = child; }; + // GDD 审批要和 swarm CLI 并发跑,两者不能共用 activeChild 这一个槽位:审批子进程 + // 结束时的 setActiveChild(null) 会把 CLI 从槽里抹掉,Ctrl-C 就杀不到它了。 + const concurrentChildren = new Set(); + const setConcurrentChild = (child) => { + if (child) concurrentChildren.add(child); + else concurrentChildren.clear(); + }; const stopRequested = () => receivedSignal !== null; const handleSignal = (signal) => { const repeatedSignal = receivedSignal !== null; receivedSignal ??= signal; - if (!activeChild) return; - void terminateChildTree(activeChild, signal, repeatedSignal).catch( - () => {}, - ); + const targets = [activeChild, ...concurrentChildren].filter(Boolean); + if (targets.length === 0) return; + for (const target of targets) { + void terminateChildTree(target, signal, repeatedSignal).catch(() => {}); + } if (repeatedSignal) return; forceTerminationHandle = setTimeout(() => { - void terminateChildTree(activeChild, 'SIGKILL', true).catch(() => {}); + for (const target of [activeChild, ...concurrentChildren].filter( + Boolean, + )) { + void terminateChildTree(target, 'SIGKILL', true).catch(() => {}); + } }, childTerminationGraceMs); forceTerminationHandle.unref(); }; @@ -2005,6 +2191,14 @@ export async function runSwarmTestChat(options) { ? null : Math.max(1, timeoutDeadline - Date.now()), options.plan, + options.plan + ? () => + settlePlanGddApproval( + project.path, + runtimeConfig.path, + setConcurrentChild, + ) + : null, ) : await runInteractiveCargo(chatArguments, setActiveChild); } catch (error) { @@ -2023,10 +2217,26 @@ export async function runSwarmTestChat(options) { if (options.plan) { // 立项策划不出游戏产物,正式验收在 GDD 审批卡上;这里只报告落盘情况, // 是否收束已经由 CLI 的退出码判过了。 + // 自动任务档的审批已经在 CLI 运行期间并发做完了;手工档(人自己敲 Ctrl+D + // 退出)没有那次触发,退出后补一次,没有待决定审批时它是只读的。 + phase = 'plan-approval'; + const approval = + chat.planGddApproval ?? + (await settlePlanGddApproval( + project.path, + runtimeConfig.path, + setConcurrentChild, + )); + if (receivedSignal) break session; phase = 'plan-report'; + await reportPlanGddApproval(approval); await reportPlanningOutputs(project.path); phase = 'complete'; - console.log('\n立项策划链路已收束:Run 正常结束,策划产物见上方清单。'); + console.log( + approval.decided + ? '\n立项策划链路已收束:Fast GDD 已批准,策划产物见上方清单。' + : '\n立项策划链路已收束:Run 正常结束但没有待决定审批,策划产物见上方清单。', + ); break session; } phase = 'artifact-validation'; 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 803454cb9..f8f5571dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -113,6 +113,15 @@ 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 { @@ -148,6 +157,7 @@ impl CliCommand { | Self::AgentGoalResume { .. } | Self::AgentGoalClear { .. } | Self::AgentResume { .. } + | Self::PlanGddDecide { .. } | Self::RunnerShutdownIfIdle ) } @@ -155,7 +165,10 @@ impl CliCommand { pub(crate) fn is_read_only_status(&self) -> bool { matches!( self, - Self::AgentRuntimeStatus { .. } | Self::AgentGoalStatus { .. } | Self::RunnerStatus + Self::AgentRuntimeStatus { .. } + | Self::AgentGoalStatus { .. } + | Self::PlanGddStatus { .. } + | Self::RunnerStatus ) } @@ -203,6 +216,8 @@ 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, @@ -416,6 +431,56 @@ 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(); @@ -645,6 +710,53 @@ 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") { @@ -1423,6 +1535,63 @@ 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={}", @@ -1930,6 +2099,123 @@ 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( + " 把核心循环写具体 +" + )) + .expect("read approval comment"), + "把核心循环写具体" + ); + assert!(read_cli_plan_gdd_approval_comment(&mut Cursor::new( + " +" + )) + .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 2db20fe74..21a903720 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1506,7 +1506,16 @@ pub(crate) fn hydrate_game_creator_plan_gdd_state( request: tauri::ipc::Request<'_>, ) -> Result { let input = parse_hydrate_plan_gdd_state_input(&request)?; - let root = validated_local_project_directory_path(input.project_path.trim())?; + 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()) } diff --git a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts index f91ba8689..53185ad36 100644 --- a/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts +++ b/apps/ai-game-creator-shell/tests/agentSwarmTestEntry.test.ts @@ -36,9 +36,12 @@ import { inspectSwarmProjectArtifacts, localConfigFileName, nextSwarmAutoPilotReply, + parsePlanGddDecisionOutput, + parsePlanGddStatusOutput, parseRunnerShutdownOutput, parseSettledSwarmTurnReport, parseSwarmTestArguments, + planGddAutoApprovalIsPending, prepareSwarmTestProject, prepareSwarmTestRuntimeConfig, removeDirectoryWithTimeout, @@ -48,6 +51,7 @@ import { shouldStartPersistentPreview, swarmAutoPilotShouldCloseInput, swarmAutoPilotSitsAtPrompt, + swarmOutputAwaitsPlanGddApproval, terminateChildTree, testProjectPrefix, testProjectSentinelName, @@ -739,6 +743,75 @@ describe('Swarm test argument parsing', () => { ).toBe(false); }); + it('triggers the approval while the chat CLI is still running', () => { + // Run 停在审批位时状态是 waiting-for-user-input,而 swarm CLI 把这个状态算作 + // 「本轮还在跑」,turn 永远不 settle。等 CLI 退出再审批那一步根本到不了,所以 + // 触发必须认这条运行期状态行。 + expect( + swarmOutputAwaitsPlanGddApproval( + '[状态] project-supervisor running/waiting-for-user-input run=r queue=0/1/0/0 | 等待 Fast GDD 审批决定', + ), + ).toBe(true); + // 另外三个 plan_gdd blocked 子状态各有自己的文案,都不该触发审批。 + expect( + swarmOutputAwaitsPlanGddApproval( + '[状态] project-supervisor running/planning | 推进本根 Run 的 Fast GDD 提交', + ), + ).toBe(false); + expect( + swarmOutputAwaitsPlanGddApproval( + '[状态] project-supervisor running/planning | 等待 Fast GDD 审批投影收尾', + ), + ).toBe(false); + expect( + swarmOutputAwaitsPlanGddApproval( + '[状态] project-supervisor needs-reconciliation | Fast GDD 审批投影需要人工核对', + ), + ).toBe(false); + }); + + it('reads the headless Fast GDD approval projection off the CLI output', () => { + const state = parsePlanGddStatusOutput( + [ + 'plan.gdd.status', + 'planGddStateJson={"state":"ready_for_approval","pendingApproval":{"gddRef":{"gddId":"gdd-1","version":1,"fingerprint":"fp"},"pendingActionId":"action-1","approvalRequestId":"approval-1"},"session":{"clarificationRound":2,"phase":"awaiting_gdd_approval"}}', + '', + ].join('\n'), + ); + expect(state.state).toBe('ready_for_approval'); + expect(state.session.clarificationRound).toBe(2); + expect(planGddAutoApprovalIsPending(state)).toBe(true); + // CLI 在 Windows 上按行输出会带 CR,解析必须先剥掉再判前缀。 + expect( + parsePlanGddStatusOutput('planGddStateJson={"state":"approved"}\r\n') + .state, + ).toBe('approved'); + expect( + parsePlanGddDecisionOutput( + 'plan.gdd.decided\nplanGddDecisionJson={"outcome":"decided","decisionRef":{"version":1}}', + ).outcome, + ).toBe('decided'); + }); + + it('auto-approves only when the projection really carries a pending card', () => { + // 审批状态是「有没有待决定」的唯一权威。没有待决定时自动批准会把一次空跑 + // 报成通过,所以这里必须 false 而不是继续往下走。 + expect( + planGddAutoApprovalIsPending({ + state: 'approved', + pendingApproval: null, + }), + ).toBe(false); + expect(planGddAutoApprovalIsPending({ state: 'draft' })).toBe(false); + expect(planGddAutoApprovalIsPending(null)).toBe(false); + // 缺前缀或 JSON 坏掉都要报错,不能静默当成「没有待决定」。 + expect(() => parsePlanGddStatusOutput('plan.gdd.status')).toThrowError(); + expect(() => + parsePlanGddStatusOutput('planGddStateJson={oops'), + ).toThrowError(); + expect(() => parsePlanGddDecisionOutput('plan.gdd.decided')).toThrowError(); + }); + it('keeps persistent preview only for manual chat mode', () => { expect(shouldStartPersistentPreview(parseSwarmTestArguments([]))).toBe( true,