删除已退役策划 V1 的前端数据通路与命令入口
前端 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:
@@ -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:审批意见的 1~1000 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 提供 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::<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,
|
||||
|
||||
@@ -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() {
|
||||
// 批准之后审批卡按设计整张收掉,此前那一刻起用户就再也够不到 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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user