删除已退役策划 V1 的前端数据通路与命令入口
Project CI / Repository checks (pull_request) Failing after 11s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / Frontend tests (pull_request) Successful in 2m43s
Project CI / Native shell tests (pull_request) Successful in 18m1s

前端 App.tsx 删除 hydrate_game_creator_plan_gdd_state 旧读取与 decide_game_creator_plan_gdd 旧审批分支,策划状态只走 Planning V2 命令
hydratePlanningV2Session 在确认无 V2 会话时清空策划状态,避免残留旧卡片
Tauri 命令层删除 decide_game_creator_plan_gdd 与 hydrate_game_creator_plan_gdd_state 及其注册和输入解析
CLI 删除 --plan-gdd-status 与 --plan-gdd-decide 子命令、stdin 审批意见读取与对应解析测试
测试 harness 删除旧命令 mock 与 planGdd 状态注入,新增 V2 审批失败注入与 decisionId 记录
plan-gdd 测试套件改写为 V2 会话驱动,删除 recoveryPending 与旧 Supervisor 审批等待两个已退役语义的测试
This commit is contained in:
2026-09-08 03:56:16 +00:00
parent 0fde1912f1
commit 38b4abc403
6 changed files with 153 additions and 739 deletions
@@ -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<String>,
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<String, St
Ok(instruction.to_string())
}
fn read_cli_plan_gdd_approval_comment(reader: &mut impl Read) -> Result<String, String> {
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:审批意见的 11000 scalar
// 约束是写入侧权威,CLI 再抄一份就会有两个会漂移的判据。
Ok(comment.to_string())
}
fn take_cli_named_flag_value(
args: &mut Vec<String>,
flag: &str,
usage: &str,
) -> Result<Option<String>, String> {
let positions = args
.iter()
.enumerate()
.filter_map(|(index, arg)| (arg == flag).then_some(index))
.collect::<Vec<_>>();
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<CliAgentGoalPayload, String> {
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<Option<CliCommand>, 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 <本地项目绝对路径> <approve|revise|reject> [--response-id <gdd-response-…>] [--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 提供 11000 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::<Vec<_>>())
.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(
@@ -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<HydratePlanGddStateInput, String> {
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<PathBuf> {
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<String>,
) -> Result<PlanGddDecisionResultV1, String> {
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<PlanGddStateViewV1, String> {
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<PlanGddStateViewV1, String> {
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,
@@ -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,
+34 -101
View File
@@ -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<PlanningSessionCommandResultV2 | null>(
'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<PlanGddStateViewV1>(
'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<PlanningApprovalCommandResultV2>(
'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<PlanningApprovalCommandResultV2>(
'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 身份上,三个决定按钮仍可点,且
@@ -519,12 +519,10 @@ function createProjectSupervisorRuntimeHarness({
let answerRuntime: Record<string, unknown> | 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<Record<string, unknown>> = [];
let planningV2DecisionError: string | null = null;
const planningV2DecisionCalls: Array<Record<string, unknown>> = [];
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<string, unknown> | null) {
currentPlanningV2Result = state;
},
setPlanningV2StartResult(state: Record<string, unknown> | null) {
currentPlanningV2StartResult = state;
},
failNextPlanGddDecision(message: string) {
planGddDecisionError = message;
},
planGddDecisionCalls,
planGddHydrateCount() {
return planGddHydrateCount;
failNextPlanningV2Decision(message: string) {
planningV2DecisionError = message;
},
planningV2DecisionCalls,
appendSupervisorMessage(message: Record<string, unknown>) {
currentSupervisorMessages.push(message);
},
@@ -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<PlanGddStateView['session']>,
displayGdd: NonNullable<PlanGddStateView['displayGdd']>,
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<typeof createPlanGddStateView>;
/** 策划中、尚未提交 GDD 的状态:没有待审批,也没有可展示的 GDD。 */
function draftPlanGddState(
sessionOverrides: Partial<NonNullable<PlanGddStateView['session']>> = {},
) {
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<Record<string, unknown>> = [];
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() {
// 批准之后审批卡按设计整张收掉,此前那一刻起用户就再也够不到 GDDapprovedGddRef
// 前端没人读,渲染好的 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();
});