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 7957d9956..b1f317674 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 @@ -957,9 +957,6 @@ async function runInteractiveCargo(cliArguments, setActiveChild) { return result; } -// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认, -// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要 -// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。 const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u; const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u; diff --git a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs index 20cf4a1ea..68fc0909a 100644 --- a/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs +++ b/apps/ai-game-creator-shell/scripts/start-dev-stack.mjs @@ -1192,7 +1192,7 @@ async function main() { function isDirectModuleExecution() { return Boolean( process.argv[1] && - resolve(process.argv[1]) === fileURLToPath(import.meta.url), + resolve(process.argv[1]) === fileURLToPath(import.meta.url), ); } diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md index 8a3109cd2..fac3c5107 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/resources/SKILL.md @@ -4,7 +4,7 @@ 你是"游戏策划 Agent",资深游戏策划,看过上千份策划案。你用第一人称教练式口吻与用户协作("我建议……我不会……");你的建议永远是建议——你不会把建议冒充为用户的决定。你的任务是与用户一起把一句话游戏想法整理成与项目范围匹配、可开工的策划产物:五层文档(概念→顶层→架构→系统×N→技术文档)是可用的组织方式,不是每个项目都必须完整执行的固定流水线。【主轴】按项目规模和用户要求选择需要的层级;层级可以合并、裁剪或补充,上层未定稿时不得让下层替它拍板,定稿以用户检阅确认为准。用户参与度沿层递减:前期关注用户取舍,后期关注实现合同。【模板与样例】模板与样例提供参考结构和写法,产物的字段、章节、数量、篇幅和展开程度按当前游戏需求与用户要求决定。适用项写入,同类项可合并,若某项对本项目没意义则省略;复杂项目可以拆分补充,简单项目可以压缩为最小可用规格。【grounding】动笔前先读相关文档(本层+上层接口件);用户当前打开的文档路径随消息注入,作为你的注意力锚;跨天续聊时先读文档树与台账恢复上下文。【判断先行】先判断问题框架;与定调记录冲突时先纠偏(推荐+理由+风险+推翻条件)。【开场与概念设计】开工先通过概念设计式的自然对话了解用户想做什么:类型、参照作品、核心感受、压力偏好——调性原则的数量和形式按项目需要决定。此后全项目一切判断先回用户已确认的核心承诺和范围。【提问纪律】开放问题先分诊:文档有答案的不问、字段级预留空列、手感类标待原型、数值类推内容期;仅阻塞级二义才发决策卡(一题三选项,第三项"需要原型验证");每轮收尾发提案卡"下一步最有价值的是X,是否继续"。数量基线按项目复杂度决定,不以固定条数或固定章节作为完成标准。【知识库】查证先读知识库 INDEX,三跳定位,禁止盲扫;查到沉淀进调性锚,每主题只查一次;检索不到写"库里没有",禁止编造与外搜。【文档协议】design 只放结论;分析只放论证;台账放活队列。写前读、写后复读同文档;改命名扫跨文档引用;新系统成对建档;修订只动用户意见涉及的内容;架构文档是系统清单的唯一真源——新建或修改任何系统必须同步更新架构文档;技术文档收编按当前版本的施工需要决定,不为不存在的系统、数据、界面、素材或配置建立文档。【低幻觉】六态标注;默认建议不冒充用户决定;AI 猜的永不标 confirmed;代决必带理由与推翻条件。【质量三件】动笔前读金样;初稿后按项目范围做必要的一致性检查;不以填满模板或扩展篇幅作为质量标准。【产物纪律】每层只写当前范围需要的内容;架构职责表在存在多个职责边界时明确不负责与移交;技术文档覆盖实际施工所需的系统、数据、界面和素材;有 blocker 禁止扩充内容;堆字数=没想清楚,停笔回读核心承诺。【边界情况】用户想改已定稿的层→接受:重写该层受影响节→概念层变更则重新投影走审批→下游层检查是否受牵连并在提案卡说明;技术文档期发现上层文档有错→在当前层记开放问题回执(登记台账),继续技术文档不受阻,错误在下一轮检阅时由用户裁决;用户推翻某条历史决定→台账旧行标 overturned 挂新行,受影响文档节重写。【收尾】有决策点或提议→ask_user(决策卡/提案卡);机械完成→finish(summary)。 -- 部署:单 Agent——现 plan 根 Supervisor 与立项策划两个 Agent 合并为一个策划 Agent,全程单一连续上下文(主控六步职责并入系统提示词承载);project-planning.md 整文件替换为本骨架+附录 A 分册拼接(编译期打包路径不变),决策卡渲染与审批等运行时机制沿用 Runtime 代管。 +- 部署:单一连续上下文的 Design Agent;主控职责由系统提示词承载,阶段确认、澄清和工作区浏览由当前 Design Agent Runtime 提供。 # 10 交付与施工 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md index cb2123047..534b488d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/supervisor/playbook.md @@ -2,8 +2,4 @@ 需要等待专业 Agent 时不得调用 respond_to_user;Runtime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256;Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。 -委派 `project-planning` 时,acceptanceCriteria 只写产物形状、覆盖范围与红线(例如必须交付 `game/fast_gdd.md`、必须原创、必须只定义一个 MVP 闭环),**不得替用户预先裁定产品取舍**。用户没有指定的玩法规则、数值、关卡量级、美术方向和目标人群,一律留给策划子 Agent 按其 3 轮问询预算决定是提问还是按默认建议填写;不要写“未指定的标注为立项假设”“自行假设后继续”这类指令,那会把问询预算作废。平台事实(自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览)由 Runtime 固定注入,属于已定事实,不得要求标为待定、建议或开放项。 - -对 `project-planning` 的澄清 continuation,必须按 A/B/“需要原型验证”三项合同原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。若用户后续自由填写推翻已确认决定,保留用户答案原文逐字不改写、不拆分、不搬轮次,并在被推翻决定后注明“已被第 N 轮回答推翻,以后者为准”,在新决定 topic 中写明推翻关系。 - 只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 9e907c2c0..c0bf3a860 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1069,15 +1069,10 @@ pub(crate) async fn continue_design_agent_at( .ok_or("策划 Agent 当前正在工作")?; let mut session = match read_design_session(root)? { Some(session) => session, - None => { - if read_planning_session_v2(root)?.is_some() { - return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into()); - } - new_design_session( - &project_id, - &load_game_creator_app_config()?.selected_model_id, - ) - } + None => new_design_session( + &project_id, + &load_game_creator_app_config()?.selected_model_id, + ), }; if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs index a685a2d3f..ae21f2ea0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_execution.rs @@ -559,7 +559,7 @@ fn append_agent_runtime_file_read_evidence_ref( // 给的是**整个三元组**,不是一个碎片。`agent.acceptance_update` 的 evidence 引用 // 要求 {agentId, runId, actionId} 三个字段,回执查找也按三元组整体做 key。早期 // 只给 actionId,另外两个靠模型回忆——实测它第一次就把其中一个记错,白吃一次 - // 拒绝。反正 `validate_fast_gdd_evidence_identity` 只接受当前根 run 的回执, + // 拒绝。验收证据只接受当前根 run 的回执, // 合法取值唯一,本来就不该让它猜。 observation.summary = format!( "{} · sourceAgentId={agent_id} · sourceRunId={run_id} · sourceActionId={action_id}", @@ -929,9 +929,9 @@ mod file_read_source_action_id_tests { AgentRuntimeToolObservation { tool: "file.read".to_string(), status: "ok".to_string(), - summary: "已读取 game/fast_gdd.md 第 1-134 行(共 134 行)".to_string(), + summary: "已读取 design.md 第 1-134 行(共 134 行)".to_string(), detail: Some(format!( - "game/fast_gdd.md · sha256={} · lines 1-134 of 134 + "design.md · sha256={} · lines 1-134 of 134 第一行内容", "a".repeat(64) )), @@ -964,7 +964,7 @@ mod file_read_source_action_id_tests { agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation) .expect("safe detail still parses"); let value = serde_json::from_str::(&safe_detail).expect("json"); - assert_eq!(value["path"], "game/fast_gdd.md"); + assert_eq!(value["path"], "design.md"); assert_eq!(value["lines"], "1-134 of 134"); assert_eq!(value["contentSha256"], "a".repeat(64)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 45700983c..13f70e747 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -78,12 +78,6 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) matches!(kind.as_str(), "empty-response" | "deserialize") } -/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在 -/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。 -fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool { - matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT") -} - const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4; /// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 0bae6dc31..f35612757 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -7,9 +7,6 @@ mod design_session; mod finalization; mod json_sidecar; mod models; -mod planning_gdd_model; -mod planning_policy_v2; -mod planning_session_v2; mod provider_control; mod provider_retry; mod real_e2e_checkpoint; @@ -24,9 +21,6 @@ pub(crate) use design_session::*; pub(in crate::agent) use finalization::*; pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use models::*; -pub(crate) use planning_gdd_model::*; -pub(crate) use planning_policy_v2::*; -pub(crate) use planning_session_v2::*; pub(in crate::agent) use provider_control::*; pub(in crate::agent) use provider_retry::*; pub(in crate::agent) use real_e2e_checkpoint::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_gdd_model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_gdd_model.rs deleted file mode 100644 index 4ca7168cd..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_gdd_model.rs +++ /dev/null @@ -1,1082 +0,0 @@ -//! 策划 GDD 的内容模型与落盘工具:GDD 业务结构、字段校验、类型化指纹和 -//! `game/fast_gdd.md` 原子写。立项策划 V2 会话与 Fast GDD 交付物共用这一层。 - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fmt; -use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::{Uuid, Variant}; - -use crate::project::{normalize_relative_path, resolve_local_project_path}; - -pub(crate) const PLAN_INDEX_MAX_BYTES: usize = 256 * 1024; -pub(crate) const PLAN_FAST_GDD_PATH: &str = "game/fast_gdd.md"; -pub(crate) const PLAN_FAST_GDD_MAX_BYTES: usize = 128 * 1024; -/// 单条决定 answerSummary 的上限(`initial-request` 除外)。 -pub(crate) const PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS: usize = 800; - -/// `initial-request` 那条决定的 answerSummary 上限,也就是立项策划入口原始需求的上限。 -/// -/// 上游用 `sanitize_agent_runtime_text(task, AGENT_RUNTIME_TASK_MAX_CHARS)` 归一根 -/// task:4000 个 Unicode scalar 封顶,超长时再补一个省略号,真实上界因此是 4001。 -/// 这里早期写死 400,于是 401~4001 字的开场需求会让根 run、Goal Contract 与首跳 -/// 委派全部正常建立,直到策划子 Agent 的 task-start 才在 session 投影上硬失败 -/// (`phase=planning-session-projection-failed`);此时 session 从未创建,用同一根 -/// task 重试必然复现,用户只能重开一条链。直接绑定到上游常量,两边不会再漂开。 -pub(crate) const PLAN_INITIAL_REQUEST_MAX_CHARS: usize = - crate::agent::runtime_driver::AGENT_RUNTIME_TASK_MAX_CHARS + 1; - -/// `initial-request` 承载的是用户原话,长度上限与其余决定不同。 -fn decision_answer_summary_max_chars(decision_id: &str) -> usize { - if decision_id == "initial-request" { - PLAN_INITIAL_REQUEST_MAX_CHARS - } else { - PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS - } -} -static PLANNING_TEMP_NONCE: AtomicU64 = AtomicU64::new(1); - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PlanningStorageError { - code: &'static str, - detail: String, -} - -impl PlanningStorageError { - pub(crate) fn new(code: &'static str, detail: impl Into) -> Self { - Self { - code, - detail: detail.into(), - } - } - - pub(crate) fn code(&self) -> &'static str { - self.code - } -} - -impl fmt::Display for PlanningStorageError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}: {}", self.code, self.detail) - } -} - -impl std::error::Error for PlanningStorageError {} - -pub(crate) type FingerprintError = PlanningStorageError; - -fn invalid(detail: impl Into) -> PlanningStorageError { - PlanningStorageError::new("PLAN_INVALID_SCHEMA", detail) -} - -fn io_error(label: &str, error: impl fmt::Display) -> PlanningStorageError { - PlanningStorageError::new("PLAN_STORAGE_IO", format!("{label}: {error}")) -} - -fn canonical_bytes(value: &T) -> Result, PlanningStorageError> { - serde_json::to_vec(value) - .map_err(|error| PlanningStorageError::new("PLAN_SERIALIZE_FAILED", error.to_string())) -} - -#[derive(Serialize)] -struct FingerprintEnvelope<'a, T: Serialize + ?Sized> { - domain: &'static str, - value: &'a T, -} - -/// Typed planning fingerprint. The envelope and the serialized value are -/// deliberately struct-shaped; map/string concatenation fingerprints are not -/// interchangeable with this contract. -pub(crate) fn typed_serde_fingerprint( - domain: &'static str, - value: &T, -) -> Result { - if domain.trim().is_empty() { - return Err(PlanningStorageError::new( - "PLAN_INVALID_FINGERPRINT_DOMAIN", - "fingerprint domain 不能为空", - )); - } - let bytes = typed_serde_canonical_bytes(domain, value)?; - Ok(format!("sha256-serde-json-v2:{:x}", Sha256::digest(bytes))) -} - -pub(crate) fn typed_serde_canonical_bytes( - domain: &'static str, - value: &T, -) -> Result, FingerprintError> { - if domain.trim().is_empty() { - return Err(PlanningStorageError::new( - "PLAN_INVALID_FINGERPRINT_DOMAIN", - "fingerprint domain 不能为空", - )); - } - let envelope = FingerprintEnvelope { domain, value }; - canonical_bytes(&envelope) -} -fn is_hex(value: &str, length: usize) -> bool { - value.len() == length && value.bytes().all(|byte| byte.is_ascii_hexdigit()) -} - -fn is_lower_hex(value: &str, length: usize) -> bool { - is_hex(value, length) && value.bytes().all(|byte| !byte.is_ascii_uppercase()) -} - -pub(crate) fn is_typed_fingerprint(value: &str) -> bool { - value - .strip_prefix("sha256-serde-json-v2:") - .is_some_and(|digest| is_lower_hex(digest, 64)) -} - -pub(crate) fn validate_text( - value: &str, - label: &str, - min: usize, - max: usize, -) -> Result<(), PlanningStorageError> { - if value.chars().count() < min || value.chars().count() > max { - return Err(invalid(format!( - "{label} 必须为 {min}..={max} 个 Unicode scalar" - ))); - } - if value != value.trim() { - return Err(invalid(format!("{label} 必须已完成首尾空白规范化"))); - } - if value.contains('\r') - || value.chars().any(|character| { - character == '\0' - || character == '\u{7f}' - || (character.is_control() && character != '\n' && character != '\t') - }) - { - return Err(invalid(format!("{label} 包含不允许的控制字符"))); - } - Ok(()) -} -pub(crate) fn validate_opaque_id( - value: &str, - label: &str, - allow_empty: bool, -) -> Result<(), PlanningStorageError> { - if value.is_empty() && allow_empty { - return Ok(()); - } - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return Err(invalid(format!("{label} 不能为空"))); - }; - if value.chars().count() > 128 - || !first.is_ascii_alphanumeric() - || !chars.all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') - }) - { - return Err(invalid(format!("{label} 不是合法 opaque ID"))); - } - Ok(()) -} - -pub(crate) fn validate_uuid_prefixed( - value: &str, - prefix: &str, - label: &str, -) -> Result<(), PlanningStorageError> { - let Some(uuid) = value.strip_prefix(prefix) else { - return Err(invalid(format!("{label} 必须以 {prefix} 开头"))); - }; - if uuid.len() != 36 - || !uuid.bytes().enumerate().all(|(index, byte)| { - matches!(index, 8 | 13 | 18 | 23) - .then_some(byte == b'-') - .unwrap_or_else(|| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) - }) - || Uuid::parse_str(uuid).ok().is_none_or(|parsed| { - parsed.hyphenated().to_string() != uuid || parsed.get_variant() != Variant::RFC4122 - }) - { - return Err(invalid(format!("{label} 不是小写 RFC 4122 UUID"))); - } - Ok(()) -} - -pub(crate) fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageError> { - let Some(digest) = value.strip_prefix("action-") else { - return Err(invalid(format!("{label} 必须以 action- 开头"))); - }; - if !is_lower_hex(digest, 24) { - return Err(invalid(format!("{label} 不是合法 actionId"))); - } - Ok(()) -} -pub(crate) fn validate_timestamp(value: &str, label: &str) -> Result<(), PlanningStorageError> { - validate_text(value, label, 24, 24)?; - if value.len() != 24 || !value.is_ascii() { - return Err(invalid(format!("{label} 必须是 ASCII UTC 毫秒时间"))); - } - let bytes = value.as_bytes(); - let punctuation = [ - (4, b'-'), - (7, b'-'), - (10, b'T'), - (13, b':'), - (16, b':'), - (19, b'.'), - (23, b'Z'), - ]; - if punctuation - .iter() - .any(|(index, expected)| bytes[*index] != *expected) - || bytes.iter().enumerate().any(|(index, byte)| { - !punctuation - .iter() - .any(|(punctuation_index, _)| *punctuation_index == index) - && !byte.is_ascii_digit() - }) - { - return Err(invalid(format!("{label} 必须是 UTC 毫秒时间"))); - } - let year = value[0..4] - .parse::() - .map_err(|_| invalid(format!("{label} 年份非法")))?; - let month = value[5..7] - .parse::() - .map_err(|_| invalid(format!("{label} 月份非法")))?; - let day = value[8..10] - .parse::() - .map_err(|_| invalid(format!("{label} 日期非法")))?; - let hour = value[11..13] - .parse::() - .map_err(|_| invalid(format!("{label} 小时非法")))?; - let minute = value[14..16] - .parse::() - .map_err(|_| invalid(format!("{label} 分钟非法")))?; - let second = value[17..19] - .parse::() - .map_err(|_| invalid(format!("{label} 秒非法")))?; - let millis = value[20..23] - .parse::() - .map_err(|_| invalid(format!("{label} 毫秒非法")))?; - if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 || millis > 999 { - return Err(invalid(format!("{label} 的 UTC 日期时间分量越界"))); - } - let leap_year = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let days_in_month = match month { - 2 if leap_year => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - }; - if day == 0 || day > days_in_month { - return Err(invalid(format!("{label} 的日期分量越界"))); - } - Ok(()) -} -fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> { - if matches!(value, "confirmed" | "default_pending" | "prototype_pending") { - Ok(()) - } else { - Err(invalid(format!("未知 decision state:{value}"))) - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddGame { - pub(crate) title: String, - pub(crate) genre: PlanGenre, - pub(crate) art_style: PlanArtStyle, - pub(crate) one_liner: String, - pub(crate) pillars: Vec, - pub(crate) core_loop: Vec, - pub(crate) target_users: PlanTargetUsers, - pub(crate) platform_facts: PlanPlatformFacts, - pub(crate) mvp_systems: Vec, - pub(crate) out_of_scope: Vec, - pub(crate) creator_tips: PlanCreatorTips, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGenre { - pub(crate) primary: String, - pub(crate) fusion: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanArtStyle { - pub(crate) visual_type: String, - pub(crate) keywords: Vec, - pub(crate) mood_and_color: String, - pub(crate) mvp_art_boundary: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanPillar { - pub(crate) name: String, - pub(crate) player_feel: String, - pub(crate) mechanism: String, - pub(crate) decision_state: String, - pub(crate) basis: Option<()>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanTargetUsers { - pub(crate) core_users: String, - pub(crate) preferences: String, - pub(crate) session_length: String, - pub(crate) reference_games: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanPlatformFacts { - pub(crate) runtime: String, - pub(crate) viewports: Vec, - pub(crate) inputs: Vec, - pub(crate) preview: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanMvpSystem { - pub(crate) system: String, - pub(crate) minimal_function: String, - pub(crate) why_required: String, - pub(crate) verify_method: String, - pub(crate) decision_state: String, - pub(crate) basis: Option<()>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanCreatorTips { - pub(crate) do_first: String, - pub(crate) defer_for_now: String, - pub(crate) how_to_verify: String, - pub(crate) expand_when: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanDecision { - pub(crate) id: String, - pub(crate) topic: String, - pub(crate) state: String, - pub(crate) answer_source: String, - pub(crate) round: u32, - pub(crate) answer_summary: String, - pub(crate) basis: Option<()>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanPrototypeValidationItem { - pub(crate) id: String, - pub(crate) question: String, - pub(crate) micro_prototype: String, - pub(crate) observation: String, - pub(crate) pass_criterion: String, -} -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitGame { - pub(crate) title: String, - pub(crate) genre: PlanGenre, - pub(crate) art_style: PlanArtStyle, - pub(crate) one_liner: String, - pub(crate) pillars: Vec, - pub(crate) core_loop: Vec, - pub(crate) target_users: PlanTargetUsers, - pub(crate) mvp_systems: Vec, - pub(crate) out_of_scope: Vec, - pub(crate) creator_tips: PlanCreatorTips, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitPillar { - pub(crate) name: String, - pub(crate) player_feel: String, - pub(crate) mechanism: String, - pub(crate) decision_state: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitMvpSystem { - pub(crate) system: String, - pub(crate) minimal_function: String, - pub(crate) why_required: String, - pub(crate) verify_method: String, - pub(crate) decision_state: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitDecision { - pub(crate) id: String, - pub(crate) topic: String, - pub(crate) state: String, - pub(crate) answer_source: String, - pub(crate) round: u32, - pub(crate) answer_summary: String, -} -fn validate_plan_platform_facts(value: &PlanPlatformFacts) -> Result<(), PlanningStorageError> { - if value.runtime != "self-contained-web" - || value.viewports != ["desktop", "mobile"] - || value.inputs != ["keyboard", "touch"] - || value.preview != "local-http" - { - return Err(invalid("platformFacts 必须是 Runtime 固定的平台事实")); - } - Ok(()) -} - -pub(crate) fn fixed_plan_platform_facts() -> PlanPlatformFacts { - PlanPlatformFacts { - runtime: "self-contained-web".to_string(), - viewports: vec!["desktop".to_string(), "mobile".to_string()], - inputs: vec!["keyboard".to_string(), "touch".to_string()], - preview: "local-http".to_string(), - } -} - -pub(crate) fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { - validate_text(&game.title, "game.title", 1, 80)?; - validate_text(&game.genre.primary, "game.genre.primary", 1, 80)?; - if let Some(fusion) = &game.genre.fusion { - validate_text(fusion, "game.genre.fusion", 1, 80)?; - } - validate_text( - &game.art_style.visual_type, - "game.artStyle.visualType", - 1, - 80, - )?; - if !(1..=6).contains(&game.art_style.keywords.len()) { - return Err(invalid("game.artStyle.keywords 必须有 1~6 项")); - } - for (index, keyword) in game.art_style.keywords.iter().enumerate() { - validate_text(keyword, &format!("game.artStyle.keywords[{index}]"), 1, 64)?; - } - validate_text( - &game.art_style.mood_and_color, - "game.artStyle.moodAndColor", - 1, - 1000, - )?; - validate_text( - &game.art_style.mvp_art_boundary, - "game.artStyle.mvpArtBoundary", - 1, - 1200, - )?; - // 设计约定:Runtime 接受范围为 10~160,模型 schema 提示范围收紧为 25~90; - // 两者有意不对称,用于避免过短概念同时保留对已有/人工 GDD 的兼容,不是缺陷或 bug。 - validate_text(&game.one_liner, "game.oneLiner", 10, 160)?; - - if !(1..=6).contains(&game.pillars.len()) { - return Err(invalid("game.pillars 必须有 1~6 条")); - } - for (index, pillar) in game.pillars.iter().enumerate() { - validate_text(&pillar.name, &format!("game.pillars[{index}].name"), 1, 80)?; - validate_text( - &pillar.player_feel, - &format!("game.pillars[{index}].playerFeel"), - 1, - 400, - )?; - validate_text( - &pillar.mechanism, - &format!("game.pillars[{index}].mechanism"), - 1, - 400, - )?; - validate_decision_state(&pillar.decision_state)?; - if pillar.basis.is_some() { - return Err(invalid("v1 的 pillar.basis 必须为 null")); - } - } - - if !(1..=8).contains(&game.core_loop.len()) { - return Err(invalid("game.coreLoop 必须有 1~8 步")); - } - for (index, step) in game.core_loop.iter().enumerate() { - validate_text(step, &format!("game.coreLoop[{index}]"), 1, 400)?; - } - - validate_text( - &game.target_users.core_users, - "game.targetUsers.coreUsers", - 1, - 400, - )?; - validate_text( - &game.target_users.preferences, - "game.targetUsers.preferences", - 1, - 400, - )?; - validate_text( - &game.target_users.session_length, - "game.targetUsers.sessionLength", - 1, - 400, - )?; - if game.target_users.reference_games.len() > 8 { - return Err(invalid("game.targetUsers.referenceGames 最多 8 项")); - } - for (index, reference) in game.target_users.reference_games.iter().enumerate() { - validate_text( - reference, - &format!("game.targetUsers.referenceGames[{index}]"), - 1, - 80, - )?; - } - validate_plan_platform_facts(&game.platform_facts)?; - - if !(1..=8).contains(&game.mvp_systems.len()) { - return Err(invalid("game.mvpSystems 必须有 1~8 项")); - } - for (index, system) in game.mvp_systems.iter().enumerate() { - validate_text( - &system.system, - &format!("game.mvpSystems[{index}].system"), - 1, - 80, - )?; - validate_text( - &system.minimal_function, - &format!("game.mvpSystems[{index}].minimalFunction"), - 1, - 400, - )?; - validate_text( - &system.why_required, - &format!("game.mvpSystems[{index}].whyRequired"), - 1, - 400, - )?; - validate_text( - &system.verify_method, - &format!("game.mvpSystems[{index}].verifyMethod"), - 1, - 400, - )?; - validate_decision_state(&system.decision_state)?; - if system.basis.is_some() { - return Err(invalid("v1 的 mvpSystem.basis 必须为 null")); - } - } - if game.out_of_scope.len() > 12 { - return Err(invalid("game.outOfScope 必须有 0~12 项")); - } - for (index, item) in game.out_of_scope.iter().enumerate() { - validate_text(item, &format!("game.outOfScope[{index}]"), 1, 80)?; - } - validate_text( - &game.creator_tips.do_first, - "game.creatorTips.doFirst", - 1, - 1000, - )?; - validate_text( - &game.creator_tips.defer_for_now, - "game.creatorTips.deferForNow", - 1, - 1000, - )?; - validate_text( - &game.creator_tips.how_to_verify, - "game.creatorTips.howToVerify", - 1, - 1000, - )?; - validate_text( - &game.creator_tips.expand_when, - "game.creatorTips.expandWhen", - 1, - 1000, - )?; - Ok(()) -} -/// Resolve a planning path and classify a linked path component as untrusted -/// rather than merely invalid. The generic resolver folds "component is a -/// symlink" into the same opaque string as every other path error, so mapping -/// its failure wholesale to `PLAN_INVALID_PATH` misreports an untrusted target; -/// `ensure_planning_parent` and `verify_regular_planning_file` already classify -/// links as `PLAN_UNTRUSTED_PATH`, and this keeps the whole module consistent. -fn resolve_planning_path( - root: &Path, - relative_path: &str, -) -> Result { - let normalized = normalize_relative_path(relative_path) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; - let mut path = root.to_path_buf(); - for part in normalized.split('/') { - path.push(part); - // 只判定链接/重解析点;缺失组件与真实 IO 错误交给通用解析器,保持既有分类。 - if fs::symlink_metadata(&path) - .is_ok_and(|metadata| planning_metadata_is_link_or_reparse(&metadata)) - { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("规划路径组件不能是链接或重解析点:{}", path.display()), - )); - } - } - resolve_local_project_path(root, relative_path) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error)) -} - -fn planning_metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { - if metadata.file_type().is_symlink() { - return true; - } - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; - } - #[cfg(not(windows))] - { - false - } -} -#[cfg(unix)] -fn open_planning_parent_directory(parent: &Path) -> Result { - use std::os::unix::fs::OpenOptionsExt; - OpenOptions::new() - .read(true) - .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) - .open(parent) - .map_err(|error| io_error("打开 planning 父目录失败", error)) -} - -#[cfg(windows)] -fn open_planning_parent_directory(parent: &Path) -> Result { - use std::os::windows::fs::OpenOptionsExt; - let directory = OpenOptions::new() - .read(true) - // FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS; the - // latter is required for opening a directory handle on Windows. - .custom_flags(0x0020_0000 | 0x0200_0000) - .open(parent) - .map_err(|error| io_error("打开 planning 父目录失败", error))?; - let metadata = directory - .metadata() - .map_err(|error| io_error("读取 planning 父目录句柄失败", error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("planning 父目录句柄不是可信普通目录:{}", parent.display()), - )); - } - Ok(directory) -} - -#[cfg(not(any(unix, windows)))] -fn open_planning_parent_directory(parent: &Path) -> Result { - File::open(parent).map_err(|error| io_error("打开 planning 父目录失败", error)) -} - -fn verify_regular_planning_file( - path: &Path, - label: &str, -) -> Result { - let metadata = fs::symlink_metadata(path) - .map_err(|error| io_error(&format!("读取 {label} 元数据失败"), error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_file() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} 必须是可信普通文件:{}", path.display()), - )); - } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if metadata.nlink() != 1 { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} 不能是硬链接文件:{}", path.display()), - )); - } - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - let file = OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .open(path) - .map_err(|error| io_error(&format!("打开 {label} 句柄失败"), error))?; - crate::runner::validate_windows_regular_file_handle(&file, label) - .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; - } - Ok(metadata) -} - -fn read_regular_planning_file(path: &Path, label: &str) -> Result, PlanningStorageError> { - let metadata = verify_regular_planning_file(path, label)?; - if metadata.len() > PLAN_INDEX_MAX_BYTES as u64 { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), - )); - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - // The final open must inspect the directory entry itself. Without - // OPEN_REPARSE_POINT a junction/symlink can be followed before the - // handle validator sees the target's (apparently regular) attributes. - options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); - } - let mut file = options - .open(path) - .map_err(|error| io_error(&format!("打开 {label} 失败"), error))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let opened_metadata = file - .metadata() - .map_err(|error| io_error(&format!("复核 {label} 句柄元数据失败"), error))?; - if opened_metadata.dev() != metadata.dev() - || opened_metadata.ino() != metadata.ino() - || opened_metadata.nlink() != 1 - { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("打开 {label} 时文件身份发生漂移"), - )); - } - } - #[cfg(windows)] - crate::runner::validate_windows_regular_file_handle(&file, label) - .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; - let mut bytes = Vec::with_capacity(metadata.len().min(PLAN_INDEX_MAX_BYTES as u64) as usize); - (&mut file) - .take((PLAN_INDEX_MAX_BYTES as u64).saturating_add(1)) - .read_to_end(&mut bytes) - .map_err(|error| io_error(&format!("读取 {label} 失败"), error))?; - if bytes.len() > PLAN_INDEX_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), - )); - } - let final_metadata = file - .metadata() - .map_err(|error| io_error(&format!("复核 {label} 元数据失败"), error))?; - if final_metadata.len() != bytes.len() as u64 || final_metadata.len() != metadata.len() { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("读取 {label} 时文件发生漂移"), - )); - } - Ok(bytes) -} - -fn sync_planning_parent(parent: &Path) -> Result<(), PlanningStorageError> { - #[cfg(unix)] - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|error| io_error("同步 planning 父目录失败", error))?; - #[cfg(windows)] - { - let directory = open_planning_parent_directory(parent)?; - if let Err(error) = directory.sync_all() { - // MoveFileExW(MOVEFILE_WRITE_THROUGH) already flushes the file - // publication on Windows. Some Windows filesystems reject - // FlushFileBuffers on a directory handle with ACCESS_DENIED or - // INVALID_FUNCTION; keep the stronger native flush as the - // fallback rather than making every durable write unusable there. - if !matches!(error.raw_os_error(), Some(1 | 5 | 6)) { - return Err(io_error("同步 planning 父目录失败", error)); - } - } - } - #[cfg(not(any(unix, windows)))] - let _ = parent; - Ok(()) -} - -fn temp_planning_path(parent: &Path, target: &Path) -> PathBuf { - let nonce = PLANNING_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); - let random = Uuid::new_v4(); - let file_name = target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("planning.json"); - parent.join(format!( - ".{file_name}.tmp-{}-{nonce}-{random}", - std::process::id() - )) -} - -fn write_sync_new_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), PlanningStorageError> { - if bytes.len() > PLAN_INDEX_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 临时内容超过 {} 字节上限", PLAN_INDEX_MAX_BYTES), - )); - } - let result = (|| { - let mut options = OpenOptions::new(); - options.read(true).write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); - } - let mut file = options - .open(path) - .map_err(|error| io_error(&format!("创建 {label} 临时文件失败"), error))?; - #[cfg(windows)] - crate::runner::validate_windows_regular_file_handle(&file, label) - .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; - file.write_all(bytes) - .and_then(|_| file.sync_all()) - .map_err(|error| io_error(&format!("写入 {label} 临时文件失败"), error))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let metadata = file - .metadata() - .map_err(|error| io_error(&format!("复核 {label} 临时文件句柄失败"), error))?; - if !metadata.is_file() || metadata.nlink() != 1 { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} 临时文件句柄不是唯一普通文件"), - )); - } - } - file.seek(SeekFrom::Start(0)) - .map_err(|error| io_error(&format!("定位 {label} 临时文件回读位置失败"), error))?; - let mut check = Vec::new(); - file.read_to_end(&mut check) - .map_err(|error| io_error(&format!("回读 {label} 临时文件失败"), error))?; - if check != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("{label} 临时文件回读不一致"), - )); - } - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(path); - } - result -} - -pub(crate) fn write_plan_fast_gdd_markdown_atomic_locked( - root: &Path, - markdown: &str, -) -> Result<(), PlanningStorageError> { - let bytes = markdown.as_bytes(); - if bytes.is_empty() { - return Err(invalid("Fast GDD Markdown 不能为空")); - } - if bytes.len() > PLAN_FAST_GDD_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!( - "Fast GDD Markdown 超过 {} 字节上限", - PLAN_FAST_GDD_MAX_BYTES - ), - )); - } - if bytes.contains(&0) || !std::str::from_utf8(bytes).is_ok() { - return Err(invalid("Fast GDD Markdown 必须是无 NUL 的 UTF-8 文本")); - } - - let target = resolve_planning_path(root, PLAN_FAST_GDD_PATH)?; - let parent = target - .parent() - .ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 缺少父目录"))?; - - // The projection lives outside `.agent/planning`, so it cannot use the - // planning-only parent helper. Build the relative `game/` directory one - // component at a time and reject links/reparse points at every step. - let root_metadata = - fs::symlink_metadata(root).map_err(|error| io_error("读取项目根目录失败", error))?; - if planning_metadata_is_link_or_reparse(&root_metadata) || !root_metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "项目根目录必须是可信普通目录", - )); - } - let mut cursor = root.to_path_buf(); - let relative_parent = parent - .strip_prefix(root) - .map_err(|_| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 父目录越出项目根"))?; - for component in relative_parent.components() { - use std::path::Component; - let Component::Normal(component) = component else { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - "Fast GDD 父目录组件非法", - )); - }; - cursor.push(component); - match fs::symlink_metadata(&cursor) { - Ok(metadata) => { - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "Fast GDD 父目录必须是可信普通目录", - )); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - fs::create_dir(&cursor) - .map_err(|error| io_error("创建 Fast GDD 父目录失败", error))?; - let metadata = fs::symlink_metadata(&cursor) - .map_err(|error| io_error("复核 Fast GDD 父目录失败", error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "新建 Fast GDD 父目录不是可信普通目录", - )); - } - } - Err(error) => return Err(io_error("读取 Fast GDD 父目录失败", error)), - } - } - - match fs::symlink_metadata(&target) { - Ok(_) => { - verify_regular_planning_file(&target, "现有 Fast GDD Markdown")?; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error("读取现有 Fast GDD Markdown 失败", error)), - } - - let temporary = temp_planning_path(parent, &target); - let result = (|| { - write_sync_new_file(&temporary, bytes, "Fast GDD Markdown")?; - verify_replace_target_is_safe(&target, "Fast GDD Markdown")?; - replace_planning_file_atomically(&temporary, &target, "Fast GDD Markdown")?; - let published = read_regular_planning_file(&target, "已发布 Fast GDD Markdown")?; - if published != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "Fast GDD Markdown 发布后内容不一致", - )); - } - sync_planning_parent(parent) - })(); - if temporary.exists() { - let cleanup = fs::remove_file(&temporary) - .map_err(|error| io_error("清理 Fast GDD 临时文件失败", error)); - if result.is_ok() { - cleanup?; - } - } - result -} - -fn replace_planning_file_atomically( - temporary: &Path, - target: &Path, - label: &str, -) -> Result<(), PlanningStorageError> { - #[cfg(not(windows))] - { - fs::rename(temporary, target) - .map_err(|error| io_error(&format!("原子替换 {label} 失败"), error))?; - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{ - MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, - }; - let from = temporary - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let to = target - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let result = unsafe { - MoveFileExW( - from.as_ptr(), - to.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if result == 0 { - return Err(io_error( - &format!("原子替换 {label} 失败"), - std::io::Error::last_os_error(), - )); - } - } - Ok(()) -} - -fn verify_replace_target_is_safe(target: &Path, label: &str) -> Result<(), PlanningStorageError> { - match fs::symlink_metadata(target) { - Ok(_) => { - verify_regular_planning_file(target, &format!("现有 {label}"))?; - Ok(()) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(io_error(&format!("读取现有 {label} 目标失败"), error)), - } -} - -/// Return the fixed UTC millisecond timestamp used by Runtime-owned planning -/// projections. Keeping this helper here makes tests able to inject a fixed -/// timestamp while production callers can use the same formatting contract. -pub(crate) fn current_plan_timestamp_utc() -> String { - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let seconds = millis / 1_000; - let millis_part = millis % 1_000; - let days = seconds / 86_400; - let day_seconds = seconds % 86_400; - let hour = day_seconds / 3_600; - let minute = (day_seconds % 3_600) / 60; - let second = day_seconds % 60; - - // Civil-from-days, Gregorian calendar (Howard Hinnant algorithm). - let z = days as i64 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let year = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = mp + if mp < 10 { 3 } else { -9 }; - let year = year + i64::from(month <= 2); - format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis_part:03}Z") -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs deleted file mode 100644 index 4b588acfb..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs +++ /dev/null @@ -1,2021 +0,0 @@ -use super::*; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::fs; -use std::io::Write; -use std::path::{Path, PathBuf}; -use uuid::Uuid; - -pub(crate) const PLAN_GDD_V2_SCHEMA_VERSION: &str = "plan-gdd.v2"; -pub(crate) const PLAN_GDD_INDEX_V2_SCHEMA_VERSION: &str = "plan-gdd-index.v2"; -pub(crate) const PLAN_APPROVAL_V2_SCHEMA_VERSION: &str = "plan-approval.v2"; -const PLAN_GDD_V2_FINGERPRINT_DOMAIN: &str = "genarrative.plan.gdd.v2"; -const PLAN_APPROVAL_V2_FINGERPRINT_DOMAIN: &str = "genarrative.plan.approval.v2"; -const PLAN_GDD_V2_MAX_BYTES: usize = 64 * 1024; -const PLAN_GDD_V2_MAX_VERSIONS: u32 = 128; -pub(crate) const PLAN_ASK_QUESTION_TOOL_NAME: &str = "plan_ask_question"; -pub(crate) const PLAN_SUBMIT_GDD_TOOL_NAME: &str = "plan_submit_gdd"; - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningQuestionV2 { - pub id: String, - pub header: String, - pub question: String, - pub options: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningQuestionOptionV2 { - pub label: String, - pub description: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningGddDecisionInputV2 { - pub topic: String, - pub state: String, - #[serde(default)] - pub answer_source: Option, - pub answer_summary: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningPrototypeValidationItemInputV2 { - pub question: String, - pub micro_prototype: String, - pub observation: String, - pub pass_criterion: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningGddInputV2 { - #[serde(default)] - pub schema_version: Option, - pub game: PlanSubmitGame, - pub decisions: Vec, - pub prototype_validation_items: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningGddV2 { - pub schema_version: String, - pub project_id: String, - pub gdd_id: String, - pub version: u32, - pub created_at_utc: String, - pub game: PlanGddGame, - pub decisions: Vec, - pub prototype_validation_items: Vec, - pub fingerprint: String, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct PlanningGddFingerprintValueV2<'a> { - schema_version: &'a str, - project_id: &'a str, - gdd_id: &'a str, - version: u32, - created_at_utc: &'a str, - game: &'a PlanGddGame, - decisions: &'a Vec, - prototype_validation_items: &'a Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PlanningGddIndexV2 { - schema_version: String, - project_id: String, - latest_version: Option, - entries: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct PlanningGddIndexEntryV2 { - gdd_id: String, - version: u32, - fingerprint: String, - status: String, - created_at_utc: String, - decision_id: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanningApprovalV2 { - pub schema_version: String, - pub project_id: String, - pub session_id: String, - pub artifact_id: String, - pub version: u32, - pub fingerprint: String, - pub decision_id: String, - pub action: String, - pub comment: Option, - pub decided_at_utc: String, - pub receipt_fingerprint: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct DecidePlanningArtifactV2Input { - pub session_id: String, - pub artifact_id: String, - pub version: u32, - pub fingerprint: String, - pub decision_id: String, - pub action: String, - pub comment: Option, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningApprovalCommandResultV2 { - pub approval: PlanningApprovalV2, - pub session: PlanningSessionV2, - pub current_artifact: Option, - pub replayed: bool, -} - -#[derive(Clone, Debug)] -pub(crate) enum PlanningPolicyOutputV2 { - Question(PlanningQuestionV2), - Gdd(PlanningGddInputV2), -} - -#[derive(Clone, Debug)] -pub(crate) struct PlanningPolicyPersistedV2 { - pub session: PlanningSessionV2, - pub result: PlanningTurnResultV2, - pub current_artifact: Option, -} - -fn v2_path(root: &Path, relative: &str) -> PathBuf { - root.join(".agent/planning-v2").join(relative) -} - -fn decision_state_schema() -> Value { - serde_json::json!({ - "type": "string", - "enum": ["confirmed", "assumption_pending", "prototype_pending"], - "description": "confirmed=已确认;assumption_pending=Agent 推断待确认;prototype_pending=待原型验证" - }) -} - -pub(crate) fn planning_v2_function_tools() -> Vec { - let decision_state = decision_state_schema(); - vec![ - platform_llm::LlmFunctionTool::new( - PLAN_ASK_QUESTION_TOOL_NAME, - "向用户提出一个关键问题。options 可包含一项「需要原型验证」,表示先做小原型验证。", - serde_json::json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "question": { - "type": "object", - "additionalProperties": false, - "description": "决策卡内容,只问一个最关键的问题", - "properties": { - "id": {"type": "string", "description": "snake_case 问题 id"}, - "header": {"type": "string", "description": "当前要决定的主题"}, - "question": {"type": "string", "description": "问用户的问题正文"}, - "options": { - "type": "array", - "description": "可选方案", - "minItems": 2, - "maxItems": 4, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "label": {"type": "string", "description": "选项标签"}, - "description": {"type": "string", "description": "该选项的具体方案"} - }, - "required": ["label", "description"] - } - } - }, - "required": ["id", "header", "question", "options"] - } - }, - "required": ["question"] - }), - ), - platform_llm::LlmFunctionTool::new( - PLAN_SUBMIT_GDD_TOOL_NAME, - "提交完整 GDD。", - serde_json::json!({ - "type": "object", - "additionalProperties": false, - "properties": { - "gdd": { - "type": "object", - "additionalProperties": false, - "description": "完整 GDD,按字段填全,不要增删或改名", - "properties": { - "game": { - "type": "object", - "additionalProperties": false, - "description": "玩法方案", - "properties": { - "title": {"type": "string", "description": "中文标题"}, - "genre": { - "type": "object", - "additionalProperties": false, - "description": "游戏类型", - "properties": { - "primary": {"type": "string", "description": "主类型"}, - "fusion": { - "type": ["string", "null"], - "description": "融合类型,没有则为 null" - } - }, - "required": ["primary"] - }, - "artStyle": { - "type": "object", - "additionalProperties": false, - "description": "美术风格", - "properties": { - "visualType": {"type": "string", "description": "视觉类型"}, - "keywords": { - "type": "array", - "description": "风格关键词", - "minItems": 1, - "maxItems": 5, - "items": {"type": "string"} - }, - "moodAndColor": {"type": "string", "description": "氛围与色彩"}, - "mvpArtBoundary": {"type": "string", "description": "MVP 美术边界"} - }, - "required": ["visualType", "keywords", "moodAndColor", "mvpArtBoundary"] - }, - "oneLiner": {"type": "string", "minLength": 25, "maxLength": 90, "description": "一句话概念(建议 25~90 字;Runtime 实际接受 10~160 字,两者有意不对称,不是缺陷或 bug)"}, - "pillars": { - "type": "array", - "description": "游戏支柱", - "minItems": 1, - "maxItems": 5, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "name": {"type": "string", "description": "支柱名称"}, - "playerFeel": {"type": "string", "description": "玩家感受"}, - "mechanism": {"type": "string", "description": "实现机制"}, - "decisionState": decision_state.clone() - }, - "required": ["name", "playerFeel", "mechanism", "decisionState"] - } - }, - "coreLoop": { - "type": "array", - "description": "单局核心循环步骤", - "minItems": 1, - "maxItems": 7, - "items": {"type": "string"} - }, - "targetUsers": { - "type": "object", - "additionalProperties": false, - "description": "目标用户", - "properties": { - "coreUsers": {"type": "string", "description": "核心用户"}, - "preferences": {"type": "string", "description": "用户偏好"}, - "sessionLength": {"type": "string", "description": "单局时长"}, - "referenceGames": { - "type": "array", - "description": "参考游戏,没有则为 []", - "items": {"type": "string"} - } - }, - "required": ["coreUsers", "preferences", "sessionLength", "referenceGames"] - }, - "mvpSystems": { - "type": "array", - "description": "MVP 必须有的系统", - "minItems": 1, - "maxItems": 7, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "system": {"type": "string", "description": "系统名称"}, - "minimalFunction": {"type": "string", "description": "最小功能"}, - "whyRequired": {"type": "string", "description": "为什么必须有"}, - "verifyMethod": {"type": "string", "description": "验证方式"}, - "decisionState": decision_state.clone() - }, - "required": [ - "system", - "minimalFunction", - "whyRequired", - "verifyMethod", - "decisionState" - ] - } - }, - "outOfScope": { - "type": "array", - "description": "暂不做的内容", - "minItems": 0, - "maxItems": 10, - "items": {"type": "string"} - }, - "creatorTips": { - "type": "object", - "additionalProperties": false, - "description": "给创作者的提示", - "properties": { - "doFirst": {"type": "string", "description": "先做什么"}, - "deferForNow": {"type": "string", "description": "暂缓什么"}, - "howToVerify": {"type": "string", "description": "如何验证"}, - "expandWhen": {"type": "string", "description": "何时扩展"} - }, - "required": ["doFirst", "deferForNow", "howToVerify", "expandWhen"] - } - }, - "required": [ - "title", - "genre", - "artStyle", - "oneLiner", - "pillars", - "coreLoop", - "targetUsers", - "mvpSystems", - "outOfScope", - "creatorTips" - ] - }, - "decisions": { - "type": "array", - "description": "已确认或待确认的决定", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "topic": {"type": "string", "description": "主题"}, - "state": decision_state.clone(), - "answerSource": { - "type": "string", - "description": "来源标记,如 user_freeform、user_choice、agent_inferred" - }, - "answerSummary": {"type": "string", "description": "结论摘要"} - }, - "description": "决定内容", - "required": ["topic", "state", "answerSummary"] - } - }, - "prototypeValidationItems": { - "type": "array", - "description": "无 prototype_pending 时为 [];有则按 decisions 中 prototype_pending 决定的顺序填写", - "items": { - "type": "object", - "additionalProperties": false, - "properties": { - "question": {"type": "string", "description": "要验证的问题"}, - "microPrototype": {"type": "string", "description": "最小原型做法"}, - "observation": {"type": "string", "description": "观察什么"}, - "passCriterion": {"type": "string", "description": "通过标准"} - }, - "required": ["question", "microPrototype", "observation", "passCriterion"] - } - } - }, - "required": ["game", "decisions", "prototypeValidationItems"] - } - }, - "required": ["gdd"] - }), - ), - ] -} - -fn parse_tool_arguments(raw: &Value) -> Result { - match raw { - Value::String(text) => serde_json::from_str(text) - .map_err(|error| format!("PLANNING_INVALID_OUTPUT: 工具参数不是合法 JSON:{error}")), - Value::Object(_) => Ok(raw.clone()), - _ => Err("PLANNING_INVALID_OUTPUT: 工具参数必须是 JSON object".to_string()), - } -} - -pub(crate) fn parse_planning_policy_output_v2( - result: &PlanningTurnResultV2, -) -> Result { - let calls = result - .payload - .get("toolCalls") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - if calls.len() != 1 { - return Err( - "PLANNING_INVALID_OUTPUT: 必须且只能调用 plan_ask_question 或 plan_submit_gdd 其中一个,不要在正文输出 JSON" - .to_string(), - ); - } - let call = &calls[0]; - let name = call.get("name").and_then(Value::as_str).unwrap_or(""); - let args = parse_tool_arguments(call.get("arguments").unwrap_or(&Value::Null))?; - match name { - PLAN_ASK_QUESTION_TOOL_NAME => { - let question_value = args.get("question").cloned().ok_or_else(|| { - "PLANNING_INVALID_QUESTION: plan_ask_question 缺少 question 参数".to_string() - })?; - let question = - serde_json::from_value::(question_value).map_err(|error| { - format!("PLANNING_INVALID_QUESTION: question 结构无效:{error}") - })?; - validate_question_v2(&question)?; - Ok(PlanningPolicyOutputV2::Question(question)) - } - PLAN_SUBMIT_GDD_TOOL_NAME => { - let gdd_value = args - .get("gdd") - .cloned() - .ok_or_else(|| "PLANNING_INVALID_GDD: plan_submit_gdd 缺少 gdd 参数".to_string())?; - let gdd = serde_json::from_value::(gdd_value) - .map_err(|error| format!("PLANNING_INVALID_GDD: GDD 结构无效:{error}"))?; - Ok(PlanningPolicyOutputV2::Gdd(gdd)) - } - _ => Err(format!("PLANNING_INVALID_OUTPUT: 未知策划工具:{name}")), - } -} - -fn validate_question_v2(question: &PlanningQuestionV2) -> Result<(), String> { - let id = &question.id; - if id.len() > 32 - || !id.chars().enumerate().all(|(index, value)| { - (index == 0 && value.is_ascii_lowercase()) - || (index > 0 - && (value.is_ascii_lowercase() || value.is_ascii_digit() || value == '_')) - }) - { - return Err("PLANNING_INVALID_QUESTION: question.id 必须是 snake_case".to_string()); - } - validate_text(&question.id, "question.id", 1, 32).map_err(|error| error.to_string())?; - validate_text(&question.header, "question.header", 1, 120) - .map_err(|error| error.to_string())?; - validate_text(&question.question, "question.question", 1, 400) - .map_err(|error| error.to_string())?; - if !(2..=4).contains(&question.options.len()) { - return Err("PLANNING_INVALID_QUESTION: options 必须有 2~4 项".to_string()); - } - let mut labels = std::collections::BTreeSet::new(); - for option in &question.options { - validate_text(&option.label, "question.options.label", 1, 80) - .map_err(|error| error.to_string())?; - validate_text(&option.description, "question.options.description", 1, 400) - .map_err(|error| error.to_string())?; - if !labels.insert(option.label.as_str()) { - return Err("PLANNING_INVALID_QUESTION: options.label 不能重复".to_string()); - } - } - Ok(()) -} - -pub(crate) fn validate_question_value_v2(value: &Value) -> Result<(), String> { - let question = serde_json::from_value::(value.clone()) - .map_err(|error| format!("PLANNING_INVALID_QUESTION: question 结构无效:{error}"))?; - validate_question_v2(&question) -} - -fn normalize_v2_decision_state(value: &str) -> Result { - match value.trim() { - "confirmed" => Ok("confirmed".to_string()), - "assumption_pending" => Ok("assumption_pending".to_string()), - "prototype_pending" => Ok("prototype_pending".to_string()), - value => Err(format!( - "PLANNING_INVALID_GDD: decision state 无效:{value}" - )), - } -} - -fn normalize_answer_source(state: &str, source: Option) -> Result { - let state = normalize_v2_decision_state(state)?; - let fallback = match state.as_str() { - "confirmed" => "user_freeform".to_string(), - "assumption_pending" => "agent_inferred".to_string(), - "prototype_pending" => "user_option".to_string(), - _ => unreachable!("normalize_v2_decision_state returned an unknown state"), - }; - let source = source - .map(|value| value.trim().to_string()) - .filter(|value| { - matches!( - value.as_str(), - "user_option" | "user_freeform" | "user_revision" | "agent_inferred" - ) - }) - .unwrap_or(fallback); - Ok(match state.as_str() { - // assumption_pending 的含义就是“不是用户明确决定,而是 Agent 推断”, - // 因此无论模型是否漏填/错填来源,都统一落成 agent_inferred。 - "assumption_pending" => "agent_inferred".to_string(), - // agent_inferred 不能与 confirmed 或 prototype_pending 自相矛盾;这两类 - // 状态分别回退到最接近的用户来源,但不把来源不一致当作阻断错误。 - "confirmed" if source == "agent_inferred" => "user_freeform".to_string(), - "prototype_pending" if source == "agent_inferred" => "user_option".to_string(), - _ => source, - }) -} - -fn planning_gdd_game_from_input(game: &PlanSubmitGame) -> Result { - Ok(PlanGddGame { - title: game.title.clone(), - genre: game.genre.clone(), - art_style: game.art_style.clone(), - one_liner: game.one_liner.clone(), - pillars: game - .pillars - .iter() - .map(|value| { - Ok(PlanPillar { - name: value.name.clone(), - player_feel: value.player_feel.clone(), - mechanism: value.mechanism.clone(), - decision_state: normalize_v2_decision_state(&value.decision_state)?, - basis: None, - }) - }) - .collect::, String>>()?, - core_loop: game.core_loop.clone(), - target_users: game.target_users.clone(), - platform_facts: fixed_plan_platform_facts(), - mvp_systems: game - .mvp_systems - .iter() - .map(|value| { - Ok(PlanMvpSystem { - system: value.system.clone(), - minimal_function: value.minimal_function.clone(), - why_required: value.why_required.clone(), - verify_method: value.verify_method.clone(), - decision_state: normalize_v2_decision_state(&value.decision_state)?, - basis: None, - }) - }) - .collect::, String>>()?, - out_of_scope: game.out_of_scope.clone(), - creator_tips: game.creator_tips.clone(), - }) -} - -fn validate_v2_game(game: &PlanGddGame) -> Result<(), String> { - // `validate_plan_game` 属于旧存储模块;V2 先独立校验自己的状态枚举,再用 - // 中性的 confirmed 占位调用旧结构校验器。V2 输入、产物和 Markdown 永远使用 - // assumption_pending,不把 V1 的默认值语义带入 V2。 - let mut storage_compatible = game.clone(); - for pillar in &mut storage_compatible.pillars { - normalize_v2_decision_state(&pillar.decision_state)?; - pillar.decision_state = "confirmed".to_string(); - } - for system in &mut storage_compatible.mvp_systems { - normalize_v2_decision_state(&system.decision_state)?; - system.decision_state = "confirmed".to_string(); - } - validate_plan_game(&storage_compatible) - .map_err(|error| format!("PLANNING_INVALID_GDD: {error}")) -} - -fn validate_v2_decision_content( - index: usize, - topic: &str, - state: &str, - answer_source: Option, - answer_summary: &str, -) -> Result<(), String> { - validate_text(topic, &format!("decisions[{index}].topic"), 1, 400) - .map_err(|error| error.to_string())?; - normalize_v2_decision_state(state)?; - normalize_answer_source(state, answer_source)?; - validate_text( - answer_summary, - &format!("decisions[{index}].answerSummary"), - 1, - if index == 0 { - PLAN_INITIAL_REQUEST_MAX_CHARS - } else { - PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS - }, - ) - .map_err(|error| error.to_string())?; - Ok(()) -} - -fn validate_v2_prototype_content( - question: &str, - micro_prototype: &str, - observation: &str, - pass_criterion: &str, -) -> Result<(), String> { - for (label, value) in [ - ("question", question), - ("microPrototype", micro_prototype), - ("observation", observation), - ("passCriterion", pass_criterion), - ] { - validate_text(value, &format!("prototypeValidationItems.{label}"), 1, 800) - .map_err(|error| error.to_string())?; - } - Ok(()) -} - -fn validate_v2_decision_inputs( - decisions: &[PlanningGddDecisionInputV2], - prototype_items: &[PlanningPrototypeValidationItemInputV2], -) -> Result<(), String> { - if !(1..=64).contains(&decisions.len()) { - return Err("PLANNING_INVALID_GDD: decisions 必须有 1~64 项".to_string()); - } - for (index, decision) in decisions.iter().enumerate() { - validate_v2_decision_content( - index, - &decision.topic, - &decision.state, - decision.answer_source.clone(), - &decision.answer_summary, - )?; - } - let prototype_count = decisions - .iter() - .filter(|decision| decision.state.trim() == "prototype_pending") - .count(); - if prototype_items.len() > 16 || prototype_count != prototype_items.len() { - return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 16 项".to_string()); - } - for item in prototype_items { - validate_v2_prototype_content( - &item.question, - &item.micro_prototype, - &item.observation, - &item.pass_criterion, - )?; - } - Ok(()) -} - -fn validate_v2_durable_decisions( - decisions: &[PlanDecision], - prototype_items: &[PlanPrototypeValidationItem], -) -> Result<(), String> { - // Provider 输入不携带 ID;这里只校验 Runtime 已生成或已持久化产物的 - // 内部引用完整性,避免把模型负责的内容校验与产物身份校验混在一起。 - if !(1..=64).contains(&decisions.len()) { - return Err("PLANNING_INVALID_GDD: decisions 必须有 1~64 项".to_string()); - } - - let mut prototype_ids = std::collections::BTreeSet::new(); - for (index, decision) in decisions.iter().enumerate() { - validate_v2_decision_content( - index, - &decision.topic, - &decision.state, - Some(decision.answer_source.clone()), - &decision.answer_summary, - )?; - if decision.state == "prototype_pending" { - prototype_ids.insert(decision.id.as_str()); - } - } - let mut item_ids = std::collections::BTreeSet::new(); - if prototype_ids.len() != prototype_items.len() || prototype_items.len() > 16 { - return Err("PLANNING_INVALID_GDD: prototypeValidationItems 必须逐项对应 prototype_pending 决定且最多 16 项".to_string()); - } - for item in prototype_items { - if !item_ids.insert(item.id.as_str()) || !prototype_ids.contains(item.id.as_str()) { - return Err("PLANNING_INVALID_GDD: prototypeValidationItems.id 必须与 prototype_pending 决定双射".to_string()); - } - validate_v2_prototype_content( - &item.question, - &item.micro_prototype, - &item.observation, - &item.pass_criterion, - )?; - } - Ok(()) -} - -fn runtime_decision_id(index: usize) -> String { - if index == 0 { - "initial-request".to_string() - } else { - format!("decision-{index}") - } -} - -fn build_gdd_v2( - project_id: &str, - version: u32, - input: PlanningGddInputV2, -) -> Result { - validate_planning_policy_output_v2(&PlanningPolicyOutputV2::Gdd(input.clone()))?; - let game = planning_gdd_game_from_input(&input.game)?; - validate_v2_game(&game)?; - let decisions = input - .decisions - .into_iter() - .enumerate() - .map(|(index, value)| { - Ok(PlanDecision { - id: runtime_decision_id(index), - topic: value.topic, - state: normalize_v2_decision_state(&value.state)?, - answer_source: normalize_answer_source(&value.state, value.answer_source)?, - // round 是 Runtime 元数据:首项代表初始需求,其余决策按 - // 输出顺序归入当前最多 3 轮的澄清区间,不接受模型自行指定。 - round: (index as u32).min(3), - answer_summary: value.answer_summary, - basis: None, - }) - }) - .collect::, String>>()?; - let prototype_ids = decisions - .iter() - .filter(|decision| decision.state == "prototype_pending") - .map(|decision| decision.id.clone()) - .collect::>(); - let prototype_validation_items = input - .prototype_validation_items - .into_iter() - .zip(prototype_ids) - .map(|(value, id)| PlanPrototypeValidationItem { - id, - question: value.question, - micro_prototype: value.micro_prototype, - observation: value.observation, - pass_criterion: value.pass_criterion, - }) - .collect::>(); - let mut gdd = PlanningGddV2 { - schema_version: PLAN_GDD_V2_SCHEMA_VERSION.to_string(), - project_id: project_id.to_string(), - gdd_id: format!("gdd-{}", Uuid::new_v4()), - version, - created_at_utc: current_plan_timestamp_utc(), - game, - decisions, - prototype_validation_items, - fingerprint: String::new(), - }; - gdd.fingerprint = fingerprint_gdd_v2(&gdd)?; - Ok(gdd) -} - -pub(crate) fn validate_planning_policy_output_v2( - output: &PlanningPolicyOutputV2, -) -> Result<(), String> { - match output { - PlanningPolicyOutputV2::Question(question) => validate_question_v2(question), - PlanningPolicyOutputV2::Gdd(input) => { - validate_v2_decision_inputs(&input.decisions, &input.prototype_validation_items)?; - let game = planning_gdd_game_from_input(&input.game)?; - validate_v2_game(&game) - } - } -} - -fn fingerprint_gdd_v2(value: &PlanningGddV2) -> Result { - typed_serde_fingerprint( - PLAN_GDD_V2_FINGERPRINT_DOMAIN, - &PlanningGddFingerprintValueV2 { - schema_version: &value.schema_version, - project_id: &value.project_id, - gdd_id: &value.gdd_id, - version: value.version, - created_at_utc: &value.created_at_utc, - game: &value.game, - decisions: &value.decisions, - prototype_validation_items: &value.prototype_validation_items, - }, - ) - .map_err(|error| error.to_string()) -} - -fn validate_gdd_v2(value: &PlanningGddV2, project_id: &str) -> Result<(), String> { - if value.schema_version != PLAN_GDD_V2_SCHEMA_VERSION - || value.project_id != project_id - || !(1..=PLAN_GDD_V2_MAX_VERSIONS).contains(&value.version) - || !is_typed_fingerprint(&value.fingerprint) - { - return Err("PLANNING_INVALID_GDD: V2 GDD 身份或 schema 无效".to_string()); - } - validate_uuid_prefixed(&value.gdd_id, "gdd-", "gddId").map_err(|error| error.to_string())?; - validate_timestamp(&value.created_at_utc, "createdAtUtc").map_err(|error| error.to_string())?; - validate_v2_game(&value.game)?; - validate_v2_durable_decisions(&value.decisions, &value.prototype_validation_items)?; - let expected = fingerprint_gdd_v2(value)?; - if expected != value.fingerprint { - return Err("PLANNING_INVALID_GDD: GDD fingerprint 不匹配".to_string()); - } - Ok(()) -} - -fn canonical_gdd_v2_bytes(value: &PlanningGddV2) -> Result, String> { - let bytes = serde_json::to_vec(value).map_err(|error| error.to_string())?; - if bytes.len() > PLAN_GDD_V2_MAX_BYTES { - return Err("PLANNING_SIZE_LIMIT: V2 GDD 超过 64 KiB".to_string()); - } - Ok(bytes) -} - -fn create_v2_immutable_file(root: &Path, relative: &str, bytes: &[u8]) -> Result<(), String> { - let path = v2_path(root, relative); - let parent = path - .parent() - .ok_or_else(|| "V2 文件缺少父目录".to_string())?; - ensure_game_creator_private_directory_tree(parent, "Planning V2 目录")?; - prepare_game_creator_private_path_for_read(parent, true, "Planning V2 目录")?; - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&path) - { - Ok(mut file) => { - // `create_new` has already made the final path visible. Every - // subsequent step must therefore be transactional from the - // caller's perspective: a failed ACL repair, partial write, or - // failed sync must not leave an empty/truncated immutable file - // that would permanently block a retry. - let result = (|| { - harden_new_game_creator_private_path(&path, false, "Planning V2 文件")?; - file.write_all(bytes) - .and_then(|_| file.sync_data()) - .map_err(|error| { - format!("写入 Planning V2 文件失败:{}: {error}", path.display()) - })?; - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(&path); - } - result - } - Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { - prepare_game_creator_private_path_for_read(&path, false, "Planning V2 文件")?; - let existing = fs::read(&path).map_err(|read_error| { - format!( - "读取已存在 Planning V2 文件失败:{}: {read_error}", - path.display() - ) - })?; - if existing == bytes { - Ok(()) - } else { - Err(format!( - "Planning V2 不可变文件已存在且内容不同:{}", - path.display() - )) - } - } - Err(error) => Err(format!( - "创建 Planning V2 文件失败:{}: {error}", - path.display() - )), - } -} - -fn read_gdd_v2(root: &Path, version: u32) -> Result { - try_read_gdd_v2(root, version)?.ok_or_else(|| { - format!( - "读取 V2 GDD 失败:{}", - v2_path(root, &format!("gdd.v{version}.json")).display() - ) - }) -} - -fn try_read_gdd_v2(root: &Path, version: u32) -> Result, String> { - let path = v2_path(root, &format!("gdd.v{version}.json")); - if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 GDD")? { - return Ok(None); - } - let metadata = - fs::metadata(&path).map_err(|error| format!("读取 V2 GDD 元数据失败:{error}"))?; - if metadata.len() > PLAN_GDD_V2_MAX_BYTES as u64 { - return Err("PLANNING_SIZE_LIMIT: V2 GDD 超过 64 KiB".to_string()); - } - let bytes = fs::read(&path) - .map_err(|error| format!("读取 V2 GDD 失败:{}: {error}", path.display()))?; - let gdd = serde_json::from_slice::(&bytes) - .map_err(|error| format!("解析 V2 GDD 失败:{error}"))?; - let project_id = read_manifest_for_project(root)?.project_id; - validate_gdd_v2(&gdd, &project_id)?; - if gdd.version != version { - return Err("PLANNING_INVALID_GDD: GDD 文件名与 version 不一致".to_string()); - } - Ok(Some(gdd)) -} - -fn next_gdd_version_v2(session: &PlanningSessionV2) -> Result { - let next_version = session - .current_artifact_version - .map(|value| value.saturating_add(1)) - .unwrap_or(1); - u32::try_from(next_version).map_err(|_| "PLANNING_VERSION_LIMIT: GDD 版本超出范围".to_string()) -} - -fn gdd_projection_status_v2( - root: &Path, - gdd: &PlanningGddV2, -) -> Result<(&'static str, Option), String> { - Ok(match read_approval_v2(root, gdd.version)? { - Some(approval) => ( - match approval.action.as_str() { - "approve" => "approved", - "reject" => "rejected", - _ => "revision_requested", - }, - Some(approval.decision_id), - ), - None => ("ready_for_approval", None), - }) -} - -fn session_status_for_gdd_projection_v2(status: &str) -> String { - match status { - "ready_for_approval" => "awaiting_approval".to_string(), - other => other.to_string(), - } -} - -fn conversation_has_gdd_artifact_v2(messages: &[PlanningMessageV2], version: u32) -> bool { - messages.iter().any(|message| { - message.kind == "artifact" - && message.payload.get("version").and_then(Value::as_u64) == Some(u64::from(version)) - }) -} - -fn recover_gdd_client_turn_id_v2( - root: &Path, - turn_index: u64, - version: u32, -) -> Result { - let messages = read_planning_messages_v2(root)?; - if let Some(message) = messages.iter().rev().find(|message| { - message.kind == "artifact" - && message.payload.get("version").and_then(Value::as_u64) == Some(u64::from(version)) - }) { - return Ok(message.client_turn_id.clone()); - } - if let Some(message) = messages - .iter() - .rev() - .find(|message| message.role == "user" && message.turn_index == turn_index) - { - return Ok(message.client_turn_id.clone()); - } - if let Some(message) = messages.iter().rev().find(|message| message.role == "user") { - return Ok(message.client_turn_id.clone()); - } - Ok(format!("planning-v2-recover-v{version}")) -} - -fn project_committed_gdd_v2( - root: &Path, - session: &mut PlanningSessionV2, - client_turn_id: &str, - turn_index: u64, - elapsed_seconds: f64, - gdd: &PlanningGddV2, -) -> Result { - let (status, decision_id) = gdd_projection_status_v2(root, gdd)?; - update_index_v2(root, gdd, status, decision_id)?; - let markdown = render_gdd_v2_markdown(gdd, status)?; - write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) - .map_err(|error| error.to_string())?; - let artifact = artifact_value_v2(gdd, status); - let messages = read_planning_messages_v2(root)?; - if !conversation_has_gdd_artifact_v2(&messages, gdd.version) { - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: client_turn_id.to_string(), - turn_index, - at_utc: current_plan_timestamp_utc(), - role: "assistant".to_string(), - kind: "artifact".to_string(), - payload: artifact.clone(), - }, - )?; - } - session.current_artifact_version = Some(u64::from(gdd.version)); - session.current_question = None; - session.status = session_status_for_gdd_projection_v2(status); - if elapsed_seconds > 0.0 { - session.processing_seconds += elapsed_seconds.max(0.0); - } - session.updated_at_utc = current_plan_timestamp_utc(); - session.last_error = None; - write_planning_session_v2(root, session)?; - Ok(PlanningPolicyPersistedV2 { - session: session.clone(), - result: PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "artifact".to_string(), - payload: artifact.clone(), - }, - current_artifact: Some(artifact), - }) -} - -/// 调用方必须已持有项目写锁。`gdd.v{N}.json` 一旦创建即为提交点: -/// 只认领 session 指针的下一个连续版本,补投影,不重新生成身份。 -pub(crate) fn reconcile_committed_planning_gdd_v2( - root: &Path, -) -> Result, String> { - let Some(mut session) = read_planning_session_v2(root)? else { - return Ok(None); - }; - if matches!(session.status.as_str(), "approved" | "rejected") { - return Ok(None); - } - let next_version = next_gdd_version_v2(&session)?; - let Some(gdd) = try_read_gdd_v2(root, next_version)? else { - return Ok(None); - }; - let turn_index = session.turn_index; - let client_turn_id = recover_gdd_client_turn_id_v2(root, turn_index, gdd.version)?; - project_committed_gdd_v2(root, &mut session, &client_turn_id, turn_index, 0.0, &gdd)?; - Ok(Some(gdd)) -} - -fn read_index_v2(root: &Path) -> Result { - let path = v2_path(root, "index.json"); - let project_id = read_manifest_for_project(root)?.project_id; - prepare_game_creator_private_path_for_read(&path, false, "Planning V2 GDD index")?; - match fs::read(&path) { - Ok(bytes) => { - let index = serde_json::from_slice::(&bytes) - .map_err(|error| format!("解析 V2 GDD index 失败:{error}"))?; - if index.schema_version != PLAN_GDD_INDEX_V2_SCHEMA_VERSION - || index.project_id != project_id - { - return Err("PLANNING_INVALID_INDEX: V2 GDD index 身份无效".to_string()); - } - Ok(index) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(PlanningGddIndexV2 { - schema_version: PLAN_GDD_INDEX_V2_SCHEMA_VERSION.to_string(), - project_id, - latest_version: None, - entries: Vec::new(), - }), - Err(error) => Err(format!( - "读取 V2 GDD index 失败:{}: {error}", - path.display() - )), - } -} - -fn write_index_v2(root: &Path, index: &PlanningGddIndexV2) -> Result<(), String> { - let bytes = - serde_json::to_vec(index).map_err(|error| format!("序列化 V2 GDD index 失败:{error}"))?; - if bytes.len() > 256 * 1024 { - return Err("PLANNING_SIZE_LIMIT: V2 GDD index 超过 256 KiB".to_string()); - } - write_game_creator_private_file( - &v2_path(root, "index.json"), - &bytes, - "Planning V2 GDD index", - ) -} - -fn update_index_v2( - root: &Path, - gdd: &PlanningGddV2, - status: &str, - decision_id: Option, -) -> Result<(), String> { - let mut index = read_index_v2(root)?; - if let Some(entry) = index - .entries - .iter_mut() - .find(|entry| entry.version == gdd.version) - { - entry.status = status.to_string(); - entry.decision_id = decision_id; - } else { - index.entries.push(PlanningGddIndexEntryV2 { - gdd_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - status: status.to_string(), - created_at_utc: gdd.created_at_utc.clone(), - decision_id, - }); - } - index.entries.sort_by_key(|entry| entry.version); - index.latest_version = index.entries.last().map(|entry| entry.version); - write_index_v2(root, &index) -} - -fn index_status_v2(root: &Path, version: u32) -> Result { - Ok(read_index_v2(root)? - .entries - .into_iter() - .find(|entry| entry.version == version) - .map(|entry| entry.status) - .unwrap_or_else(|| "ready_for_approval".to_string())) -} - -fn markdown_escape_v2(value: &str) -> String { - value - .replace('\\', "\\\\") - .replace('`', "\\`") - .replace('*', "\\*") - .replace('_', "\\_") - .replace('|', "\\|") -} - -fn render_gdd_v2_markdown(gdd: &PlanningGddV2, status: &str) -> Result { - validate_gdd_v2(gdd, &gdd.project_id)?; - let mut output = format!( - "# {}\n\n> Fast GDD v{} · 状态:{}\n> gddId:`{}`\n> fingerprint:`{}`\n\n", - markdown_escape_v2(&gdd.game.title), - gdd.version, - markdown_escape_v2(status), - gdd.gdd_id, - gdd.fingerprint - ); - output.push_str("## 决定状态\n\n"); - for decision in &gdd.decisions { - output.push_str(&format!( - "- **{}**({},{},第 {} 轮):{}\n", - markdown_escape_v2(&decision.topic), - decision.state, - decision.answer_source, - decision.round, - markdown_escape_v2(&decision.answer_summary) - )); - } - output.push_str(&format!( - "\n## 一句话描述\n\n{}\n\n", - markdown_escape_v2(&gdd.game.one_liner) - )); - output.push_str("## 核心循环\n\n"); - for (index, step) in gdd.game.core_loop.iter().enumerate() { - output.push_str(&format!("{}. {}\n", index + 1, markdown_escape_v2(step))); - } - output.push_str("\n## MVP 系统\n\n"); - for system in &gdd.game.mvp_systems { - output.push_str(&format!( - "### {}\n\n- 最小功能:{}\n- 必要原因:{}\n- 验证方式:{}\n\n", - markdown_escape_v2(&system.system), - markdown_escape_v2(&system.minimal_function), - markdown_escape_v2(&system.why_required), - markdown_escape_v2(&system.verify_method) - )); - } - output.push_str("## 制作边界\n\n"); - for item in &gdd.game.out_of_scope { - output.push_str(&format!("- {}\n", markdown_escape_v2(item))); - } - if !gdd.prototype_validation_items.is_empty() { - output.push_str("\n## 原型验证项\n\n"); - for item in &gdd.prototype_validation_items { - output.push_str(&format!( - "### {}\n\n- 问题:{}\n- 微型原型:{}\n- 观察:{}\n- 通过标准:{}\n\n", - markdown_escape_v2(&item.id), - markdown_escape_v2(&item.question), - markdown_escape_v2(&item.micro_prototype), - markdown_escape_v2(&item.observation), - markdown_escape_v2(&item.pass_criterion) - )); - } - } - if output.len() > PLAN_FAST_GDD_MAX_BYTES { - return Err("PLANNING_SIZE_LIMIT: Fast GDD Markdown 超过大小上限".to_string()); - } - Ok(output) -} - -fn artifact_value_v2(gdd: &PlanningGddV2, status: &str) -> Value { - serde_json::json!({ - "artifactId": gdd.gdd_id, - "kind": "gdd", - "version": gdd.version, - "status": status, - "fingerprint": gdd.fingerprint, - "payload": gdd, - }) -} - -pub(crate) fn current_planning_artifact_v2(root: &Path) -> Result, String> { - let Some(session) = read_planning_session_v2(root)? else { - return Ok(None); - }; - let Some(version) = session.current_artifact_version else { - return Ok(None); - }; - let version = u32::try_from(version) - .map_err(|_| "PLANNING_INVALID_GDD: artifact version 超出范围".to_string())?; - let gdd = read_gdd_v2(root, version)?; - Ok(Some(artifact_value_v2( - &gdd, - &index_status_v2(root, version)?, - ))) -} - -pub(crate) fn persist_planning_policy_output_v2( - root: &Path, - client_turn_id: &str, - session_id: &str, - turn_index: u64, - elapsed_seconds: f64, - output: PlanningPolicyOutputV2, -) -> Result { - // Provider 已经成功返回;落盘是一次性提交点。hydrate / 审批续跑会同时伸手 - // 拿项目锁,无等待取锁会把瞬时争用变成“总控执行失败”。 - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.v2.policy.persist", - )?; - let mut session = - read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; - if session.session_id != session_id || session.turn_index != turn_index { - return Err("Planning V2 Session 回合身份发生变化".to_string()); - } - let project_id = read_manifest_for_project(root)?.project_id; - if session.project_id != project_id { - return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); - } - match output { - PlanningPolicyOutputV2::Question(question) => { - let payload = serde_json::to_value(&question).map_err(|error| error.to_string())?; - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: client_turn_id.to_string(), - turn_index, - at_utc: current_plan_timestamp_utc(), - role: "assistant".to_string(), - kind: "question".to_string(), - payload: payload.clone(), - }, - )?; - session.question_count = session.question_count.saturating_add(1); - session.current_question = Some(payload.clone()); - session.status = "awaiting_user".to_string(); - session.processing_seconds += elapsed_seconds.max(0.0); - session.updated_at_utc = current_plan_timestamp_utc(); - session.last_error = None; - write_planning_session_v2(root, &session)?; - Ok(PlanningPolicyPersistedV2 { - session, - result: PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "question".to_string(), - payload, - }, - current_artifact: None, - }) - } - PlanningPolicyOutputV2::Gdd(input) => { - let next_version = next_gdd_version_v2(&session)?; - let gdd = if let Some(existing) = try_read_gdd_v2(root, next_version)? { - existing - } else { - let gdd = build_gdd_v2(&project_id, next_version, input)?; - validate_gdd_v2(&gdd, &project_id)?; - let bytes = canonical_gdd_v2_bytes(&gdd)?; - create_v2_immutable_file(root, &format!("gdd.v{}.json", gdd.version), &bytes)?; - gdd - }; - project_committed_gdd_v2( - root, - &mut session, - client_turn_id, - turn_index, - elapsed_seconds, - &gdd, - ) - } - } -} - -fn approval_fingerprint(value: &PlanningApprovalV2) -> Result { - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - struct Fingerprint<'a> { - schema_version: &'a str, - project_id: &'a str, - session_id: &'a str, - artifact_id: &'a str, - version: u32, - fingerprint: &'a str, - decision_id: &'a str, - action: &'a str, - comment: &'a Option, - decided_at_utc: &'a str, - } - typed_serde_fingerprint( - PLAN_APPROVAL_V2_FINGERPRINT_DOMAIN, - &Fingerprint { - schema_version: &value.schema_version, - project_id: &value.project_id, - session_id: &value.session_id, - artifact_id: &value.artifact_id, - version: value.version, - fingerprint: &value.fingerprint, - decision_id: &value.decision_id, - action: &value.action, - comment: &value.comment, - decided_at_utc: &value.decided_at_utc, - }, - ) - .map_err(|error| error.to_string()) -} - -fn validate_approval_v2(value: &PlanningApprovalV2, project_id: &str) -> Result<(), String> { - if value.schema_version != PLAN_APPROVAL_V2_SCHEMA_VERSION - || value.project_id != project_id - || !(1..=PLAN_GDD_V2_MAX_VERSIONS).contains(&value.version) - || !matches!(value.action.as_str(), "approve" | "revise" | "reject") - || !is_typed_fingerprint(&value.fingerprint) - || !is_typed_fingerprint(&value.receipt_fingerprint) - { - return Err("PLANNING_INVALID_APPROVAL: V2 审批记录身份或 schema 无效".to_string()); - } - validate_uuid_prefixed(&value.artifact_id, "gdd-", "artifactId") - .map_err(|error| error.to_string())?; - validate_text(&value.decision_id, "decisionId", 1, 128).map_err(|error| error.to_string())?; - validate_timestamp(&value.decided_at_utc, "decidedAtUtc").map_err(|error| error.to_string())?; - if matches!(value.action.as_str(), "revise" | "reject") && value.comment.is_none() { - return Err("PLANNING_INVALID_APPROVAL: 修改或退回审批记录必须填写 comment".to_string()); - } - if value - .comment - .as_deref() - .is_some_and(|comment| comment.chars().count() > 2_000) - { - return Err("PLANNING_INVALID_APPROVAL: comment 不能超过 2000 个字符".to_string()); - } - let expected = approval_fingerprint(value)?; - if expected != value.receipt_fingerprint { - return Err("PLANNING_INVALID_APPROVAL: receiptFingerprint 不匹配".to_string()); - } - Ok(()) -} - -fn read_approval_v2(root: &Path, version: u32) -> Result, String> { - let path = v2_path(root, &format!("approvals/v{version}.json")); - prepare_game_creator_private_path_for_read(&path, false, "Planning V2 审批记录")?; - match fs::read(&path) { - Ok(bytes) => { - let approval = serde_json::from_slice::(&bytes) - .map_err(|error| format!("解析 V2 审批记录失败:{error}"))?; - let project_id = read_manifest_for_project(root)?.project_id; - validate_approval_v2(&approval, &project_id)?; - Ok(Some(approval)) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(error) => Err(format!("读取 V2 审批记录失败:{}: {error}", path.display())), - } -} - -fn write_approval_v2(root: &Path, approval: &PlanningApprovalV2) -> Result<(), String> { - let bytes = - serde_json::to_vec(approval).map_err(|error| format!("序列化 V2 审批记录失败:{error}"))?; - create_v2_immutable_file( - root, - &format!("approvals/v{}.json", approval.version), - &bytes, - ) -} - -pub(crate) fn decide_planning_artifact_v2_at( - root: &Path, - input: DecidePlanningArtifactV2Input, -) -> Result { - // 审批按钮是一次性意图。修订后续跑和 GUI hydrate 会同时抢同一把项目锁; - // V1 `decide_plan_gdd_at` 已按完整窗口等待,V2 必须同样等过瞬时争用。 - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.v2.approval", - )?; - let mut session = - read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; - let project_id = read_manifest_for_project(root)?.project_id; - if session.project_id != project_id { - return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); - } - if session.session_id != input.session_id { - return Err("Planning V2 Session ID 不匹配".to_string()); - } - if !matches!(input.action.as_str(), "approve" | "revise" | "reject") { - return Err("PLANNING_INVALID_APPROVAL: action 必须是 approve/revise/reject".to_string()); - } - let comment = input - .comment - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - if input.decision_id.trim().is_empty() || input.decision_id.chars().count() > 128 { - return Err("PLANNING_INVALID_APPROVAL: decisionId 不能为空且长度不能超过 128".to_string()); - } - if comment - .as_deref() - .is_some_and(|value| value.chars().count() > 2_000) - { - return Err("PLANNING_INVALID_APPROVAL: comment 不能超过 2000 个字符".to_string()); - } - if matches!(input.action.as_str(), "revise" | "reject") && comment.is_none() { - return Err("PLANNING_INVALID_APPROVAL: 修改或退回必须填写 comment".to_string()); - } - let gdd = read_gdd_v2(root, input.version)?; - if gdd.gdd_id != input.artifact_id - || gdd.fingerprint != input.fingerprint - || session.current_artifact_version != Some(u64::from(input.version)) - { - return Err("PLANNING_STALE_APPROVAL: 审批引用不是当前最新 GDD".to_string()); - } - if let Some(existing) = read_approval_v2(root, input.version)? { - if existing.session_id == session.session_id - && existing.action == input.action - && existing.comment == comment - { - let status = match existing.action.as_str() { - "approve" => "approved", - "reject" => "rejected", - _ => "revision_requested", - }; - update_index_v2(root, &gdd, status, Some(existing.decision_id.clone()))?; - let markdown = render_gdd_v2_markdown(&gdd, status)?; - write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) - .map_err(|error| error.to_string())?; - if session.status != status { - session.status = status.to_string(); - if status == "revision_requested" { - session.revision_count = session.revision_count.saturating_add(1); - } - session.updated_at_utc = current_plan_timestamp_utc(); - write_planning_session_v2(root, &session)?; - } - return Ok(PlanningApprovalCommandResultV2 { - approval: existing, - session, - current_artifact: Some(artifact_value_v2( - &gdd, - &index_status_v2(root, input.version)?, - )), - replayed: true, - }); - } - return Err("PLANNING_APPROVAL_CONFLICT: 当前 GDD 已存在不同审批决定".to_string()); - } - let mut approval = PlanningApprovalV2 { - schema_version: PLAN_APPROVAL_V2_SCHEMA_VERSION.to_string(), - project_id: project_id.clone(), - session_id: session.session_id.clone(), - artifact_id: gdd.gdd_id.clone(), - version: gdd.version, - fingerprint: gdd.fingerprint.clone(), - decision_id: input.decision_id.clone(), - action: input.action.clone(), - comment: comment.clone(), - decided_at_utc: current_plan_timestamp_utc(), - receipt_fingerprint: String::new(), - }; - approval.receipt_fingerprint = approval_fingerprint(&approval)?; - write_approval_v2(root, &approval)?; - if input.action != "revise" { - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: input.decision_id.clone(), - turn_index: session.turn_index, - at_utc: approval.decided_at_utc.clone(), - role: "user".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": format!("审批:{} {}", input.action, comment.as_deref().unwrap_or_default()), "approvalAction": input.action, "artifactId": gdd.gdd_id, "version": gdd.version}), - }, - )?; - } - update_index_v2( - root, - &gdd, - match input.action.as_str() { - "approve" => "approved", - "reject" => "rejected", - _ => "revision_requested", - }, - Some(input.decision_id.clone()), - )?; - let status = match input.action.as_str() { - "approve" => "approved", - "reject" => "rejected", - _ => "revision_requested", - }; - let markdown = render_gdd_v2_markdown(&gdd, status)?; - write_plan_fast_gdd_markdown_atomic_locked(root, &markdown) - .map_err(|error| error.to_string())?; - session.status = status.to_string(); - session.current_question = None; - if input.action == "revise" { - session.revision_count = session.revision_count.saturating_add(1); - } - session.updated_at_utc = current_plan_timestamp_utc(); - write_planning_session_v2(root, &session)?; - Ok(PlanningApprovalCommandResultV2 { - approval, - session, - current_artifact: Some(artifact_value_v2(&gdd, status)), - replayed: false, - }) -} - -#[tauri::command] -pub(crate) fn decide_planning_artifact_v2( - project_path: String, - session_id: String, - artifact_id: String, - version: u32, - fingerprint: String, - decision_id: String, - action: String, - comment: Option, -) -> Result { - let root = PathBuf::from(project_path.trim()); - enforce_project_permission_policy(&root, "conversation.read")?; - enforce_project_permission_policy(&root, "conversation.write")?; - decide_planning_artifact_v2_at( - &root, - DecidePlanningArtifactV2Input { - session_id, - artifact_id, - version, - fingerprint, - decision_id, - action, - comment, - }, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn missing_answer_source_is_normalized_without_blocking() { - assert_eq!( - normalize_answer_source("confirmed", None).unwrap(), - "user_freeform" - ); - assert_eq!( - normalize_answer_source("assumption_pending", None).unwrap(), - "agent_inferred" - ); - assert_eq!( - normalize_answer_source("assumption_pending", Some("user_option".to_string())).unwrap(), - "agent_inferred" - ); - assert_eq!( - normalize_answer_source("prototype_pending", None).unwrap(), - "user_option" - ); - assert_eq!( - normalize_v2_decision_state("default_pending").is_err(), - true - ); - } - - #[test] - fn question_validation_allows_two_to_four_options() { - let question = PlanningQuestionV2 { - id: "core_loop".to_string(), - header: "当前要决定:核心循环".to_string(), - question: "玩家主要做什么?".to_string(), - options: vec![ - PlanningQuestionOptionV2 { - label: "方案 A".to_string(), - description: "先设计再验证".to_string(), - }, - PlanningQuestionOptionV2 { - label: "方案 B".to_string(), - description: "先战斗再调整".to_string(), - }, - ], - }; - assert!(validate_question_v2(&question).is_ok()); - } - - fn sample_gdd_value() -> Value { - serde_json::json!({ - "game": { - "title": "萤火守夜者", - "genre": {"primary": "轻策略", "fusion": null}, - "artStyle": { - "visualType": "手绘平面", - "keywords": ["暖色", "剪影", "纸感"], - "moodAndColor": "夜色中的暖黄灯火", - "mvpArtBoundary": "只做可复用占位素材" - }, - "oneLiner": "玩家在短局守夜旅程中分配有限灯火、判断风险并选择路线,守住营地后寻找下一处安全落脚点再继续前进", - "pillars": [ - {"name": "取舍", "playerFeel": "每次选择都有代价", "mechanism": "有限灯火在路线与营地之间分配", "decisionState": "confirmed"}, - {"name": "重玩", "playerFeel": "想再试一次更优路线", "mechanism": "不同路线组合产生不同风险", "decisionState": "confirmed"} - ], - "coreLoop": ["观察地图", "分配灯火", "选择路线", "处理事件"], - "targetUsers": {"coreUsers": "喜欢短局策略的玩家", "preferences": "偏好清晰反馈", "sessionLength": "10至20分钟", "referenceGames": []}, - "mvpSystems": [ - {"system": "地图", "minimalFunction": "展示当前营地与路线", "whyRequired": "承载空间选择", "verifyMethod": "玩家能走完一局", "decisionState": "confirmed"}, - {"system": "灯火", "minimalFunction": "在安全和探索间分配", "whyRequired": "承载核心取舍", "verifyMethod": "两种策略结果可区分", "decisionState": "confirmed"}, - {"system": "事件", "minimalFunction": "路线途中触发选择", "whyRequired": "提供短局变化", "verifyMethod": "重玩时结果不同", "decisionState": "confirmed"} - ], - "outOfScope": ["多人联机"], - "creatorTips": {"doFirst": "先做一张可走完的地图", "deferForNow": "暂缓复杂成长线", "howToVerify": "观察玩家是否能说出选择后果", "expandWhen": "连续三局都能理解后再扩展"} - }, - "decisions": [{"topic": "初始需求", "state": "confirmed", "answerSummary": "做一个短局守夜策略游戏"}], - "prototypeValidationItems": [] - }) - } - - fn tool_turn_result(name: &str, arguments: Value) -> PlanningTurnResultV2 { - PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "assistant_text".to_string(), - payload: serde_json::json!({ - "text": "", - "toolCalls": [{ - "id": "call-1", - "name": name, - "arguments": arguments - }] - }), - } - } - - #[test] - fn parses_v2_gdd_from_submit_tool_without_schema_version() { - let result = tool_turn_result( - PLAN_SUBMIT_GDD_TOOL_NAME, - serde_json::json!({"gdd": sample_gdd_value()}), - ); - let parsed = parse_planning_policy_output_v2(&result).expect("parse v2 gdd"); - let PlanningPolicyOutputV2::Gdd(gdd) = parsed else { - panic!("expected gdd"); - }; - assert!(gdd.schema_version.is_none()); - validate_planning_policy_output_v2(&PlanningPolicyOutputV2::Gdd(gdd)).expect("valid gdd"); - let text_json = PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "assistant_text".to_string(), - payload: serde_json::json!({ - "text": "{\"kind\":\"gdd\",\"gdd\":{\"schemaVersion\":\"plan-gdd.v2\"}}" - }), - }; - assert!(parse_planning_policy_output_v2(&text_json).is_err()); - } - - #[test] - fn parses_v2_question_from_ask_tool() { - let result = tool_turn_result( - PLAN_ASK_QUESTION_TOOL_NAME, - serde_json::json!({ - "question": { - "id": "core_loop", - "header": "当前要决定:核心循环", - "question": "玩家主要做什么?", - "options": [ - {"label": "方案 A", "description": "先设计再验证"}, - {"label": "方案 B", "description": "先战斗再调整"} - ] - } - }), - ); - let parsed = parse_planning_policy_output_v2(&result).expect("parse question"); - assert!(matches!(parsed, PlanningPolicyOutputV2::Question(_))); - } - - #[test] - fn planning_v2_tool_schema_describes_nested_gdd_without_schema_version() { - let tools = planning_v2_function_tools(); - assert_eq!(tools.len(), 2); - assert_eq!(tools[0].name, PLAN_ASK_QUESTION_TOOL_NAME); - assert_eq!(tools[1].name, PLAN_SUBMIT_GDD_TOOL_NAME); - let gdd_properties = tools[1].parameters["properties"]["gdd"]["properties"] - .as_object() - .expect("gdd properties"); - assert!(gdd_properties.contains_key("game")); - assert!(gdd_properties.contains_key("decisions")); - assert!(!gdd_properties.contains_key("schemaVersion")); - let decision_properties = gdd_properties["decisions"]["items"]["properties"] - .as_object() - .expect("decision properties"); - assert!(!decision_properties.contains_key("id")); - assert!(!decision_properties.contains_key("round")); - assert!(!gdd_properties["decisions"]["items"]["required"] - .as_array() - .expect("decision required") - .iter() - .any(|value| value == "id")); - let prototype_properties = gdd_properties["prototypeValidationItems"]["items"] - ["properties"] - .as_object() - .expect("prototype properties"); - assert!(!prototype_properties.contains_key("id")); - assert!(gdd_properties["game"]["properties"] - .as_object() - .expect("game properties") - .contains_key("title")); - } - - #[test] - fn runtime_assigns_decision_and_prototype_ids() { - let mut value = sample_gdd_value(); - value["decisions"] = serde_json::json!([ - {"topic": "初始需求", "state": "confirmed", "answerSummary": "做一个短局守夜策略游戏"}, - {"topic": "核心回路", "state": "prototype_pending", "answerSummary": "验证核心回路"} - ]); - value["prototypeValidationItems"] = serde_json::json!([ - {"question": "玩家是否理解核心回路?", "microPrototype": "做一个最小交互原型", "observation": "观察玩家行为", "passCriterion": "多数玩家完成目标"} - ]); - let input: PlanningGddInputV2 = serde_json::from_value(value).expect("input"); - let gdd = build_gdd_v2("project-v2", 1, input).expect("build gdd"); - assert_eq!( - gdd.decisions - .iter() - .map(|decision| decision.id.as_str()) - .collect::>(), - vec!["initial-request", "decision-1"] - ); - assert_eq!(gdd.prototype_validation_items[0].id, "decision-1"); - } - - fn sample_gdd_input() -> PlanningGddInputV2 { - serde_json::from_value(sample_gdd_value()).expect("sample gdd input") - } - - fn v2_persist_fixture() -> (tempfile::TempDir, PathBuf, PlanningSessionV2) { - let directory = tempfile::tempdir().expect("create v2 persist fixture"); - let root = directory.path().to_path_buf(); - crate::init_local_game_project_at(&root, "project-v2-persist", "V2 持久化测试") - .expect("init project"); - let project_id = crate::read_manifest_for_project(&root) - .expect("read manifest") - .project_id; - let now = current_plan_timestamp_utc(); - let session = PlanningSessionV2 { - schema_version: PLANNING_SESSION_V2_SCHEMA_VERSION.to_string(), - engine: PLANNING_SESSION_V2_ENGINE.to_string(), - session_id: "ps-test-persist".to_string(), - project_id, - mode: "gdd".to_string(), - status: "planning".to_string(), - turn_index: 1, - question_count: 0, - question_limit: Some(8), - revision_count: 0, - current_artifact_version: None, - current_question: None, - capabilities: PlanningCapabilitySnapshotV2::default(), - processing_seconds: 0.0, - created_at_utc: now.clone(), - updated_at_utc: now, - last_error: None, - }; - write_planning_session_v2(&root, &session).expect("write session"); - append_planning_message_v2( - &root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: "msg-user-1".to_string(), - client_turn_id: "turn-1".to_string(), - turn_index: 1, - at_utc: current_plan_timestamp_utc(), - role: "user".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": "做一个短局守夜策略游戏"}), - }, - ) - .expect("write user message"); - (directory, root, session) - } - - fn rewind_session_keep_gdd_file(root: &Path, session: &PlanningSessionV2) { - let mut session = session.clone(); - session.current_artifact_version = None; - session.status = "provider_failed".to_string(); - session.last_error = Some(PlanningErrorV2 { - code: "PLANNING_PERSIST_FAILED".to_string(), - summary: "投影失败".to_string(), - }); - write_planning_session_v2(root, &session).expect("rewind session"); - let _ = fs::remove_file(v2_path(root, "index.json")); - let _ = fs::remove_file(root.join("game/fast_gdd.md")); - let users = read_planning_messages_v2(root) - .expect("read conversation") - .into_iter() - .filter(|message| message.role == "user") - .collect::>(); - let mut content = String::new(); - for message in users { - content.push_str(&serde_json::to_string(&message).expect("serialize user message")); - content.push('\n'); - } - crate::write_game_creator_private_file( - &root.join(PLANNING_SESSION_V2_CONVERSATION_PATH), - content.as_bytes(), - "Planning V2 对话记录", - ) - .expect("rewrite conversation"); - } - - #[test] - fn persist_retries_adopt_existing_gdd_instead_of_conflicting_identity() { - let (_dir, root, session) = v2_persist_fixture(); - let first = persist_planning_policy_output_v2( - &root, - "turn-1", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(sample_gdd_input()), - ) - .expect("first persist"); - let first_id = first - .current_artifact - .as_ref() - .and_then(|value| value.get("artifactId")) - .and_then(Value::as_str) - .expect("first gdd id") - .to_string(); - assert_eq!(first.session.current_artifact_version, Some(1)); - rewind_session_keep_gdd_file(&root, &session); - let mut changed = sample_gdd_input(); - changed.game.title = "完全不同的标题".to_string(); - let retry = persist_planning_policy_output_v2( - &root, - "turn-1", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(changed), - ) - .expect("retry persist"); - assert_eq!(retry.session.current_artifact_version, Some(1)); - assert_eq!(retry.session.status, "awaiting_approval"); - assert_eq!( - retry - .current_artifact - .as_ref() - .and_then(|value| value.get("artifactId")) - .and_then(Value::as_str), - Some(first_id.as_str()) - ); - assert!(v2_path(&root, "gdd.v1.json").is_file()); - assert!(!v2_path(&root, "gdd.v2.json").exists()); - let gdd = read_gdd_v2(&root, 1).expect("read adopted gdd"); - assert_eq!(gdd.gdd_id, first_id); - assert_eq!(gdd.game.title, "萤火守夜者"); - let artifacts = read_planning_messages_v2(&root) - .expect("read conversation") - .into_iter() - .filter(|message| message.kind == "artifact") - .count(); - assert_eq!(artifacts, 1); - } - - #[test] - fn hydrate_adopts_orphan_gdd_after_projection_failure() { - let (_dir, root, session) = v2_persist_fixture(); - persist_planning_policy_output_v2( - &root, - "turn-1", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(sample_gdd_input()), - ) - .expect("first persist"); - rewind_session_keep_gdd_file(&root, &session); - let hydrated = hydrate_planning_session_v2_sync( - root.to_string_lossy().to_string(), - Some(session.session_id.clone()), - ) - .expect("hydrate") - .expect("session"); - assert_eq!(hydrated.session.current_artifact_version, Some(1)); - assert_eq!(hydrated.session.status, "awaiting_approval"); - assert!(hydrated.current_artifact.is_some()); - assert!(hydrated - .conversation - .as_ref() - .expect("conversation") - .iter() - .any(|message| message.kind == "artifact")); - } - - #[test] - fn successful_gdd_persist_still_allocates_next_version() { - let (_dir, root, session) = v2_persist_fixture(); - persist_planning_policy_output_v2( - &root, - "turn-1", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(sample_gdd_input()), - ) - .expect("persist v1"); - let mut next = sample_gdd_input(); - next.game.title = "第二版守夜者".to_string(); - let second = persist_planning_policy_output_v2( - &root, - "turn-2", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(next), - ) - .expect("persist v2"); - assert_eq!(second.session.current_artifact_version, Some(2)); - assert!(v2_path(&root, "gdd.v1.json").is_file()); - assert!(v2_path(&root, "gdd.v2.json").is_file()); - assert_eq!( - read_gdd_v2(&root, 2).expect("read v2").game.title, - "第二版守夜者" - ); - } - - fn hold_project_lock_briefly(root: &Path, hold_millis: u64) -> std::thread::JoinHandle<()> { - let lock_path = root.join(".agent/project.lock"); - let held = serde_json::json!({ - "commandId": "test.hold", - "pid": std::process::id(), - "createdAt": unix_timestamp(), - "nonce": 0, - }); - fs::write( - &lock_path, - serde_json::to_vec(&held).expect("serialize held lock"), - ) - .expect("hold project lock"); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(hold_millis)); - fs::remove_file(&lock_path).expect("release project lock"); - }) - } - - #[test] - fn v2_decision_rides_out_a_briefly_held_project_lock() { - let (_dir, root, session) = v2_persist_fixture(); - let persisted = persist_planning_policy_output_v2( - &root, - "turn-1", - &session.session_id, - session.turn_index, - 1.0, - PlanningPolicyOutputV2::Gdd(sample_gdd_input()), - ) - .expect("persist gdd"); - let artifact = persisted - .current_artifact - .as_ref() - .expect("current artifact"); - let holder = hold_project_lock_briefly(&root, 120); - let decision = decide_planning_artifact_v2_at( - &root, - DecidePlanningArtifactV2Input { - session_id: session.session_id.clone(), - artifact_id: artifact - .get("artifactId") - .and_then(Value::as_str) - .expect("artifactId") - .to_string(), - version: 1, - fingerprint: artifact - .get("fingerprint") - .and_then(Value::as_str) - .expect("fingerprint") - .to_string(), - decision_id: "gdd-response-v2-lock-wait".to_string(), - action: "revise".to_string(), - comment: Some("加强节奏".to_string()), - }, - ) - .expect("审批修改必须等过瞬时锁争用,而不是把失败甩回按钮"); - holder.join().expect("lock holder thread"); - assert_eq!(decision.session.status, "revision_requested"); - assert!(!decision.replayed); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs deleted file mode 100644 index 0d5aa7239..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs +++ /dev/null @@ -1,1756 +0,0 @@ -use super::*; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::BTreeSet; -use std::fs; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::Instant; -use uuid::Uuid; - -pub(crate) const PLANNING_SESSION_V2_SCHEMA_VERSION: &str = "planning-session.v2"; -pub(crate) const PLANNING_MESSAGE_V2_SCHEMA_VERSION: &str = "planning-message.v2"; -pub(crate) const PLANNING_TURN_RESULT_V2_SCHEMA_VERSION: &str = "planning-turn-result.v2"; -pub(crate) const PLANNING_SESSION_V2_ENGINE: &str = "planning-session-v2"; -pub(crate) const PLANNING_SESSION_V2_SESSION_PATH: &str = ".agent/planning-v2/session.json"; -pub(crate) const PLANNING_SESSION_V2_CONVERSATION_PATH: &str = - ".agent/planning-v2/conversation.jsonl"; -const PLANNING_SESSION_V2_DEBUG_ROOT: &str = ".agent/planning-v2/debug"; -const PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION: &str = "planning-debug.v1"; -const PLANNING_SESSION_V2_MAX_CONTEXT_CHARS: usize = 1_000_000; -const PLANNING_SESSION_V2_MAX_TEXT_CHARS: usize = 64 * 1024; -const PLANNING_SESSION_V2_MAX_CONVERSATION_BYTES: u64 = 4 * 1024 * 1024; - -static ACTIVE_PLANNING_V2_PROJECTS: OnceLock>> = OnceLock::new(); - -fn active_planning_v2_projects() -> &'static Mutex> { - ACTIVE_PLANNING_V2_PROJECTS.get_or_init(|| Mutex::new(BTreeSet::new())) -} - -struct PlanningV2ActiveGuard { - project_id: String, -} - -impl Drop for PlanningV2ActiveGuard { - fn drop(&mut self) { - if let Ok(mut active) = active_planning_v2_projects().lock() { - active.remove(&self.project_id); - } - } -} - -fn try_acquire_planning_v2_active(project_id: &str) -> Result { - let mut active = active_planning_v2_projects() - .lock() - .map_err(|_| "Planning V2 活跃回合锁损坏".to_string())?; - if !active.insert(project_id.to_string()) { - return Err("Planning V2 当前已有回合执行中".to_string()); - } - Ok(PlanningV2ActiveGuard { - project_id: project_id.to_string(), - }) -} - -fn planning_v2_is_active(project_id: &str) -> bool { - active_planning_v2_projects() - .lock() - .map(|active| active.contains(project_id)) - .unwrap_or(false) -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningCapabilitySnapshotV2 { - pub tools: Vec, - pub skills: Vec, -} - -impl Default for PlanningCapabilitySnapshotV2 { - fn default() -> Self { - Self { - tools: Vec::new(), - skills: Vec::new(), - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningErrorV2 { - pub code: String, - pub summary: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningSessionV2 { - pub schema_version: String, - pub engine: String, - pub session_id: String, - pub project_id: String, - pub mode: String, - pub status: String, - pub turn_index: u64, - pub question_count: u64, - pub question_limit: Option, - pub revision_count: u64, - pub current_artifact_version: Option, - pub current_question: Option, - pub capabilities: PlanningCapabilitySnapshotV2, - pub processing_seconds: f64, - pub created_at_utc: String, - pub updated_at_utc: String, - pub last_error: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningMessageV2 { - pub schema_version: String, - pub message_id: String, - pub client_turn_id: String, - pub turn_index: u64, - pub at_utc: String, - pub role: String, - pub kind: String, - pub payload: Value, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningTurnResultV2 { - pub schema_version: String, - pub kind: String, - pub payload: Value, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningSessionCommandResultV2 { - pub session: PlanningSessionV2, - pub result: Option, - pub current_artifact: Option, - pub replayed: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub conversation: Option>, -} - -#[derive(Clone, Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct PlanningSessionStreamEventV2 { - pub session_id: String, - pub client_turn_id: String, - pub status: String, - pub delta_text: String, - pub accumulated_text: String, - pub finish_reason: Option, - pub result: Option, - pub error: Option, -} - -#[derive(Clone, Debug)] -struct PlanningTurnStartV2 { - session: PlanningSessionV2, - context_messages: Vec, - replay: Option, -} - -fn planning_session_path(root: &Path) -> PathBuf { - root.join(PLANNING_SESSION_V2_SESSION_PATH) -} - -fn planning_conversation_path(root: &Path) -> PathBuf { - root.join(PLANNING_SESSION_V2_CONVERSATION_PATH) -} - -fn planning_debug_call_dir(root: &Path, debug_call_id: &str) -> PathBuf { - root.join(PLANNING_SESSION_V2_DEBUG_ROOT) - .join(debug_call_id) -} - -fn write_planning_debug_json( - root: &Path, - debug_call_id: &str, - relative: &str, - value: &Value, -) -> Option { - let path = planning_debug_call_dir(root, debug_call_id).join(relative); - let mut bytes = serde_json::to_vec_pretty(value).ok()?; - bytes.push(b'\n'); - write_game_creator_private_file(&path, &bytes, "Planning V2 Provider 诊断") - .ok() - .map(|_| format!("{PLANNING_SESSION_V2_DEBUG_ROOT}/{debug_call_id}/{relative}")) -} - -fn append_planning_debug_event(root: &Path, debug_call_id: &str, event: Value) { - let path = planning_debug_call_dir(root, debug_call_id).join("events.jsonl"); - let Ok(line) = serde_json::to_string(&event) else { - return; - }; - let mut content = match fs::read_to_string(&path) { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(_) => return, - }; - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&line); - content.push('\n'); - let _ = - write_game_creator_private_file(&path, content.as_bytes(), "Planning V2 Provider 诊断事件"); -} - -fn persist_planning_debug_request_v2( - root: &Path, - debug_call_id: &str, - session: &PlanningSessionV2, - client_turn_id: &str, - provider_attempt: u8, - stream: bool, - request: &platform_llm::LlmRunRequest, -) { - let snapshot = serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "kind": "provider_request", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "model": request.model.as_deref(), - "apiKind": request.api_kind, - "stream": stream, - "maxOutputTokens": request.max_output_tokens, - "requestTimeoutMs": request.request_timeout_ms, - "reasoningEffort": format!("{:?}", request.response_reasoning_effort), - "textVerbosity": format!("{:?}", request.response_text_verbosity), - "toolChoice": request.tool_choice, - "messages": &request.messages, - "tools": &request.function_tools, - }); - let file = format!("requests/attempt-{provider_attempt}.json"); - let path = write_planning_debug_json(root, debug_call_id, &file, &snapshot); - append_planning_debug_event( - root, - debug_call_id, - serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "eventType": "provider_attempt_started", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "requestFile": path, - "atUtc": current_plan_timestamp_utc(), - }), - ); -} - -fn persist_planning_debug_response_v2( - root: &Path, - debug_call_id: &str, - session: &PlanningSessionV2, - client_turn_id: &str, - provider_attempt: u8, - response: Option<&platform_llm::LlmRunResponse>, - error: Option<&str>, -) { - let response_value = response.map(|response| { - serde_json::json!({ - "provider": response.provider, - "model": &response.model, - "responseId": &response.response_id, - "finishReason": &response.finish_reason, - "usage": &response.usage, - "text": &response.text, - "toolCalls": &response.tool_calls, - }) - }); - let snapshot = serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "kind": "provider_response", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "response": response_value, - "error": error, - "atUtc": current_plan_timestamp_utc(), - }); - let file = format!("responses/attempt-{provider_attempt}.json"); - let path = write_planning_debug_json(root, debug_call_id, &file, &snapshot); - append_planning_debug_event( - root, - debug_call_id, - serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "eventType": "provider_attempt_finished", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "responseFile": path, - "error": error, - "atUtc": current_plan_timestamp_utc(), - }), - ); -} - -fn persist_planning_debug_classification_v2( - root: &Path, - debug_call_id: &str, - session: &PlanningSessionV2, - client_turn_id: &str, - provider_attempt: u8, - parsed_type: Option<&str>, - accepted: bool, - retry_scheduled: bool, - error: Option<&str>, -) { - append_planning_debug_event( - root, - debug_call_id, - serde_json::json!({ - "schemaVersion": PLANNING_SESSION_V2_DEBUG_SCHEMA_VERSION, - "eventType": "provider_output_classified", - "debugCallId": debug_call_id, - "sessionId": &session.session_id, - "projectId": &session.project_id, - "clientTurnId": client_turn_id, - "turnIndex": session.turn_index, - "providerAttempt": provider_attempt, - "parsedType": parsed_type, - "accepted": accepted, - "retryScheduled": retry_scheduled, - "error": error, - "atUtc": current_plan_timestamp_utc(), - }), - ); -} - -fn validate_client_turn_id(value: &str) -> Result { - let value = value.trim(); - if value.is_empty() || value.chars().count() > 128 || value.contains(['\r', '\n', '\0']) { - return Err("clientTurnId 不能为空且长度不能超过 128 个字符".to_string()); - } - Ok(value.to_string()) -} - -fn validate_prompt(value: &str) -> Result { - let value = value.trim(); - if value.is_empty() { - return Err("策划输入不能为空".to_string()); - } - if value.chars().count() > PLANNING_SESSION_V2_MAX_TEXT_CHARS { - return Err("策划输入过长".to_string()); - } - Ok(value.to_string()) -} - -fn normalize_planning_input(value: Value) -> Result { - match value { - Value::String(text) => validate_prompt(&text), - Value::Object(object) => { - if let Some(text) = object.get("text").and_then(Value::as_str) { - return validate_prompt(text); - } - if let Some(input) = object.get("input").and_then(Value::as_str) { - return validate_prompt(input); - } - if let Some(label) = object.get("optionLabel").and_then(Value::as_str) { - return validate_prompt(&format!("按选项:{label}")); - } - if let Some(index) = object.get("optionIndex").and_then(Value::as_u64) { - return validate_prompt(&format!("按第 {} 个选项做", index.saturating_add(1))); - } - Err("Planning V2 input 缺少 text、input 或 optionLabel".to_string()) - } - _ => Err("Planning V2 input 必须是文本或对象".to_string()), - } -} - -fn safe_error(code: &str, detail: impl AsRef) -> PlanningErrorV2 { - PlanningErrorV2 { - code: code.to_string(), - summary: sanitize_diagnostic_message(detail.as_ref(), None), - } -} - -pub(crate) fn read_planning_session_v2(root: &Path) -> Result, String> { - let path = planning_session_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 Session")? { - return Ok(None); - } - let metadata = fs::metadata(&path) - .map_err(|error| format!("读取 Planning V2 Session 元数据失败:{error}"))?; - if metadata.len() > 64 * 1024 { - return Err("PLANNING_SESSION_INVALID: Planning V2 Session 超过大小上限".to_string()); - } - let content = fs::read_to_string(&path) - .map_err(|error| format!("读取 Planning V2 Session 失败:{}: {error}", path.display()))?; - let session = serde_json::from_str::(&content) - .map_err(|error| format!("解析 Planning V2 Session 失败:{}: {error}", path.display()))?; - validate_planning_session_v2(&session)?; - Ok(Some(session)) -} - -fn validate_planning_session_v2(session: &PlanningSessionV2) -> Result<(), String> { - if session.schema_version != PLANNING_SESSION_V2_SCHEMA_VERSION - || session.engine != PLANNING_SESSION_V2_ENGINE - || session.session_id.trim().is_empty() - || session.project_id.trim().is_empty() - || !matches!( - session.status.as_str(), - "idle" - | "planning" - | "awaiting_user" - | "awaiting_approval" - | "revision_requested" - | "approved" - | "rejected" - | "provider_failed" - ) - || session - .capabilities - .tools - .iter() - .any(|value| value.trim().is_empty()) - || session - .capabilities - .skills - .iter() - .any(|value| value.trim().is_empty()) - { - return Err("PLANNING_SESSION_INVALID: Planning V2 Session 字段无效".to_string()); - } - Ok(()) -} - -pub(crate) fn write_planning_session_v2( - root: &Path, - session: &PlanningSessionV2, -) -> Result<(), String> { - let content = serde_json::to_vec_pretty(session) - .map_err(|error| format!("序列化 Planning V2 Session 失败:{error}"))?; - write_game_creator_private_file( - &planning_session_path(root), - format!("{}\n", String::from_utf8_lossy(&content)).as_bytes(), - "Planning V2 Session", - ) -} - -pub(crate) fn append_planning_message_v2( - root: &Path, - message: &PlanningMessageV2, -) -> Result<(), String> { - let line = serde_json::to_string(message) - .map_err(|error| format!("序列化 Planning V2 消息失败:{error}"))?; - let path = planning_conversation_path(root); - if let Some(parent) = path.parent() { - ensure_game_creator_private_directory_tree(parent, "Planning V2 对话目录")?; - prepare_game_creator_private_path_for_read(parent, true, "Planning V2 对话目录")?; - } - let mut content = match fs::read_to_string(&path) { - Ok(content) => content, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => { - return Err(format!( - "读取 Planning V2 对话记录失败:{}: {error}", - path.display() - )) - } - }; - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&line); - content.push('\n'); - if content.len() > PLANNING_SESSION_V2_MAX_CONTEXT_CHARS.saturating_mul(4) { - return Err("Planning V2 对话记录超过有界大小".to_string()); - } - write_game_creator_private_file(&path, content.as_bytes(), "Planning V2 对话记录") -} - -pub(crate) fn read_planning_messages_v2(root: &Path) -> Result, String> { - let path = planning_conversation_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "Planning V2 对话记录")? { - return Ok(Vec::new()); - } - let metadata = fs::metadata(&path) - .map_err(|error| format!("读取 Planning V2 对话记录元数据失败:{error}"))?; - if metadata.len() > PLANNING_SESSION_V2_MAX_CONVERSATION_BYTES { - return Err("PLANNING_CONTEXT_OVERFLOW: Planning V2 对话记录超过有界大小".to_string()); - } - let file = fs::File::open(&path) - .map_err(|error| format!("读取 Planning V2 对话记录失败:{}: {error}", path.display()))?; - let mut messages = Vec::new(); - for line in BufReader::new(file).lines() { - let line = line.map_err(|error| format!("读取 Planning V2 消息失败:{error}"))?; - if line.trim().is_empty() { - continue; - } - let message = serde_json::from_str::(&line) - .map_err(|error| format!("解析 Planning V2 消息失败:{error}"))?; - messages.push(message); - } - Ok(messages) -} - -fn message_text(message: &PlanningMessageV2) -> Option { - message - .payload - .get("text") - .and_then(Value::as_str) - .map(ToString::to_string) -} - -fn build_context_v2( - root: &Path, - llm: &GameCreatorLlmConfig, - skip_client_turn_id: Option<&str>, -) -> Result, String> { - let messages = read_planning_messages_v2(root)?; - let mut estimated_chars = 0_usize; - let mut output = Vec::with_capacity(messages.len()); - for message in messages { - if skip_client_turn_id - .is_some_and(|turn_id| message.client_turn_id == turn_id && message.role == "user") - { - continue; - } - let text = message_text(&message).or_else(|| { - matches!(message.kind.as_str(), "question" | "artifact") - .then(|| serde_json::to_string(&message.payload).ok()) - .flatten() - }); - let Some(text) = text else { continue }; - estimated_chars = estimated_chars.saturating_add(text.chars().count()); - if estimated_chars > PLANNING_SESSION_V2_MAX_CONTEXT_CHARS { - return Err( - "PLANNING_CONTEXT_OVERFLOW: Planning V2 会话上下文超过有界上限".to_string(), - ); - } - let role = match message.role.as_str() { - "user" => platform_llm::LlmMessage::user(text), - "assistant" => platform_llm::LlmMessage::assistant(text), - _ => continue, - }; - output.push(role); - } - let configured_limit = llm.context_window_tokens.saturating_mul(4) as usize; - if configured_limit > 0 && estimated_chars > configured_limit { - return Err( - "PLANNING_CONTEXT_OVERFLOW: Planning V2 请求上下文超过 Provider 窗口".to_string(), - ); - } - Ok(output) -} - -fn new_session_v2(project_id: String, mode: String) -> PlanningSessionV2 { - let now = current_plan_timestamp_utc(); - PlanningSessionV2 { - schema_version: PLANNING_SESSION_V2_SCHEMA_VERSION.to_string(), - engine: PLANNING_SESSION_V2_ENGINE.to_string(), - session_id: format!("ps-{}", Uuid::new_v4().simple()), - project_id, - mode, - status: "idle".to_string(), - turn_index: 0, - question_count: 0, - question_limit: Some(8), - revision_count: 0, - current_artifact_version: None, - current_question: None, - capabilities: PlanningCapabilitySnapshotV2::default(), - processing_seconds: 0.0, - created_at_utc: now.clone(), - updated_at_utc: now, - last_error: None, - } -} - -fn read_project_id_v2(root: &Path) -> Result { - read_manifest_for_project(root) - .map(|manifest| manifest.project_id) - .map_err(|error| format!("读取项目身份失败:{error}")) -} - -fn existing_turn_result_v2( - messages: &[PlanningMessageV2], - client_turn_id: &str, -) -> Option { - messages - .iter() - .rev() - .find(|message| { - message.client_turn_id == client_turn_id - && message.role == "assistant" - && message.kind != "error" - }) - .and_then(|message| { - Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "assistant_text".to_string(), - payload: message.payload.clone(), - }) - }) -} - -fn successful_assistant_for_turn( - messages: &[PlanningMessageV2], - turn_index: u64, -) -> Option<&PlanningMessageV2> { - messages.iter().rev().find(|message| { - message.turn_index == turn_index - && message.role == "assistant" - && message.kind != "error" - && (message.kind == "question" && validate_question_value_v2(&message.payload).is_ok() - || message_text(message).is_some_and(|text| !text.trim().is_empty())) - }) -} - -fn has_successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> bool { - successful_assistant_for_turn(messages, turn_index).is_some() -} - -fn committed_gdd_replay_v2( - root: &Path, - session: &PlanningSessionV2, - client_turn_id: &str, - messages: &[PlanningMessageV2], -) -> Result, String> { - if !messages - .iter() - .any(|message| message.client_turn_id == client_turn_id && message.role == "user") - { - return Ok(None); - } - if !matches!( - session.status.as_str(), - "awaiting_approval" | "approved" | "rejected" | "revision_requested" - ) || session.current_artifact_version.is_none() - { - return Ok(None); - } - let Some(artifact) = current_planning_artifact_v2(root)? else { - return Ok(None); - }; - Ok(Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "artifact".to_string(), - payload: artifact, - })) -} - -fn prepare_turn_v2( - root: &Path, - client_turn_id: &str, - prompt: &str, - mode: Option<&str>, - is_start: bool, - expected_session_id: Option<&str>, - question_id: Option<&str>, -) -> Result { - validate_project_root(root)?; - let client_turn_id = validate_client_turn_id(client_turn_id)?; - let prompt = validate_prompt(prompt)?; - // 用户提交回答或审批修改意见后的续跑是一次性意图。无等待取锁会把 - // hydrate / 刚结束的审批写盘误判成外部占用,前端再映射成总控失败。 - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.v2.turn.start", - )?; - let mut session = match read_planning_session_v2(root)? { - Some(session) => session, - None => { - let mode = mode.unwrap_or("gdd").trim(); - if mode != "gdd" { - return Err("Planning V2 当前只支持 mode=gdd".to_string()); - } - new_session_v2(read_project_id_v2(root)?, mode.to_string()) - } - }; - let project_id = read_project_id_v2(root)?; - if expected_session_id.is_some_and(|expected| session.session_id != expected.trim()) { - return Err("Planning V2 Session ID 不匹配".to_string()); - } - if session.project_id != project_id { - return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); - } - if let Some(requested_mode) = mode.map(str::trim).filter(|value| !value.is_empty()) { - if requested_mode != session.mode { - return Err("Planning V2 不允许在同一 Session 切换 mode".to_string()); - } - } - if read_planning_session_v2(root)?.is_some() { - reconcile_committed_planning_gdd_v2(root)?; - if let Some(updated) = read_planning_session_v2(root)? { - session = updated; - } - } - let messages = read_planning_messages_v2(root)?; - if let Some(replay) = existing_turn_result_v2(&messages, &client_turn_id) { - return Ok(PlanningTurnStartV2 { - session, - context_messages: Vec::new(), - replay: Some(replay), - }); - } - if let Some(replay) = committed_gdd_replay_v2(root, &session, &client_turn_id, &messages)? { - return Ok(PlanningTurnStartV2 { - session, - context_messages: Vec::new(), - replay: Some(replay), - }); - } - if session.status == "planning" { - return Err("Planning V2 当前已有回合执行中".to_string()); - } - if !is_start - && !matches!( - session.status.as_str(), - "awaiting_user" | "revision_requested" | "provider_failed" - ) - { - return Err(format!( - "Planning V2 Session 当前状态不可提交用户输入:{}", - session.status - )); - } - if matches!(session.status.as_str(), "approved" | "rejected") { - return Err(format!( - "Planning V2 Session 当前状态不可继续:{}", - session.status - )); - } - if is_start && session.turn_index > 0 && messages.iter().any(|message| message.role == "user") { - return Err("Planning V2 已有会话内容,请使用 continue_planning_session_v2".to_string()); - } - let existing_user = messages - .iter() - .rev() - .find(|message| message.client_turn_id == client_turn_id && message.role == "user"); - if existing_user.is_none() { - let current_question_id = session - .current_question - .as_ref() - .and_then(|question| question.get("id")) - .and_then(Value::as_str); - if question_id != current_question_id { - return Err("待回答问题已变更,请刷新策划状态".to_string()); - } - } - let llm = resolve_game_creator_llm_config_for_agent( - &load_game_creator_app_config()?, - "planning-agent-v2", - ); - let context_messages = - build_context_v2(root, &llm, existing_user.map(|_| client_turn_id.as_str()))?; - if let Some(existing_user) = existing_user { - let existing_text = message_text(existing_user).unwrap_or_default(); - if existing_text != prompt { - return Err("同一 clientTurnId 不能提交不同用户输入".to_string()); - } - if session.status != "provider_failed" { - return Err("Planning V2 当前回合不能重复提交".to_string()); - } - session.turn_index = existing_user.turn_index; - } else { - session.turn_index = session.turn_index.saturating_add(1); - } - session.status = "planning".to_string(); - session.current_question = None; - session.last_error = None; - session.updated_at_utc = current_plan_timestamp_utc(); - if existing_user.is_none() { - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: client_turn_id.clone(), - turn_index: session.turn_index, - at_utc: current_plan_timestamp_utc(), - role: "user".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": prompt}), - }, - )?; - } - write_planning_session_v2(root, &session)?; - Ok(PlanningTurnStartV2 { - session, - context_messages, - replay: None, - }) -} - -fn planning_v2_question_policy(session: &PlanningSessionV2) -> String { - if session.question_count >= 3 { - return "已达到 3 轮问询上限,本轮整理当前信息并调用 plan_submit_gdd。".to_string(); - } - match session.question_limit { - Some(limit) if session.question_count >= limit => format!( - "当前已向用户展示 {} 个有效问题;已达到问询上限,禁止再调用 plan_ask_question,必须调用 plan_submit_gdd 提交完整 GDD。", - session.question_count - ), - _ => format!( - "当前已向用户展示 {} 个有效问题。整个会话最多提问 3 轮;本轮可以继续问一个最重要的未决设计问题,也可以在信息足够时直接提交 GDD。", - session.question_count - ), - } -} - -fn planning_v2_system_prompt(session: &PlanningSessionV2) -> String { - // `question_limit` 是 Runtime 对“已展示问题数”的硬上限;模型提示词中的 - // 问询策略只是偏好,两者不要求数值一致。这里仅传当前已展示数, - // 达到硬上限时再明确禁止本次继续提问。 - format!( - "你是立项策划 Agent。当前会话 {},回合 {}。{}\n\n每轮必须且只能调用一个工具:问询用 plan_ask_question,出稿用 plan_submit_gdd。不要在正文输出 JSON、Markdown、解释或代码围栏。\n\n除非用户明确要求直接出稿,否则先进行关键设计澄清。\n整个会话最多提问 3 轮。\n3 轮是上限,不是配额;信息已经足够时允许 0~2 轮提前出稿。\n达到第 3 轮或信息足够时,整理当前信息并调用 plan_submit_gdd。\n审批修改后的续跑沿用本协议:可以继续问询,也可以直接提交新的完整 GDD;已确认问答和问询计数不重置。\n每轮收到用户回答后重新选择最重要的下一个未决决定,不要重复已回答的问题。\n提问优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。\n主题包装、美术、数值和次要系统可以用 assumption_pending,answerSource 记为 agent_inferred。修改以最新用户意见为准,提交完整 GDD,不要打补丁。", - session.session_id, - session.turn_index, - planning_v2_question_policy(session), - ) -} - -fn build_provider_request_v2( - session: &PlanningSessionV2, - context_messages: Vec, - prompt: &str, - llm: &GameCreatorLlmConfig, -) -> Result { - let system = platform_llm::LlmMessage::system(planning_v2_system_prompt(session)); - let mut messages = vec![system]; - messages.extend(context_messages); - messages.push(platform_llm::LlmMessage::user(prompt.to_string())); - let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; - let request = platform_llm::LlmRunRequest::new(messages) - .with_api_kind(api_kind) - .with_model(llm.model.clone()) - .with_max_output_tokens(16_384) - .with_request_timeout_ms(llm.request_timeout_ms) - .with_function_tools(planning_v2_function_tools()) - // 注意:部分模型或端点可能不支持 tool_choice=required(例如思考模式下的 - // DeepSeek 会以 400 “Thinking mode does not support this tool_choice” 拒绝); - // 该 4xx 属硬错误,不进入瞬态重试。接入新模型时必须先确认端点接受 required。 - .with_tool_choice(platform_llm::LlmToolChoice::Required); - apply_game_creator_llm_reasoning_effort(request, llm) -} - -fn planning_turn_result_from_llm(response: &platform_llm::LlmRunResponse) -> PlanningTurnResultV2 { - PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "assistant_text".to_string(), - payload: serde_json::json!({ - "text": response.text, - "toolCalls": response.tool_calls.iter().map(|call| { - serde_json::json!({ - "id": call.id, - "name": call.name, - "arguments": call.arguments, - }) - }).collect::>(), - "finishReason": response.finish_reason, - "responseId": response.response_id, - "model": response.model, - }), - } -} - -struct PlanningProviderFailureV2 { - detail: String, - transient_kind: Option<&'static str>, -} - -impl PlanningProviderFailureV2 { - fn terminal(detail: String) -> Self { - Self { - detail, - transient_kind: None, - } - } -} - -async fn invoke_provider_v2( - root: &Path, - session: &PlanningSessionV2, - client_turn_id: &str, - provider_attempt: u8, - debug_call_id: &str, - prompt: &str, - context_messages: Vec, - mut on_delta: F, -) -> Result -where - F: FnMut(&str, &str, Option<&str>) + Send, -{ - let config = load_game_creator_app_config().map_err(PlanningProviderFailureV2::terminal)?; - let llm = resolve_game_creator_llm_config_for_agent(&config, "planning-agent-v2"); - let client = build_game_creator_llm_client_from_llm_config(&llm, "planning.v2") - .map_err(PlanningProviderFailureV2::terminal)?; - let request = build_provider_request_v2(session, context_messages, prompt, &llm) - .map_err(PlanningProviderFailureV2::terminal)?; - persist_planning_debug_request_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - llm.stream, - &request, - ); - if llm.stream { - let response = client - .stream_run(request, |delta| { - on_delta( - delta.accumulated_text.as_str(), - delta.delta_text.as_str(), - delta.finish_reason.as_deref(), - ); - }) - .await; - match response { - Ok(response) => { - persist_planning_debug_response_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - Some(&response), - None, - ); - Ok(planning_turn_result_from_llm(&response)) - } - Err(error) => { - let transient_kind = - game_creator_agent_runtime_transient_provider_error_kind(&error, false); - let detail = format!("Planning V2 Provider 流式调用失败:{error}"); - persist_planning_debug_response_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - None, - Some(detail.as_str()), - ); - Err(PlanningProviderFailureV2 { - detail, - transient_kind, - }) - } - } - } else { - let response = client.run(request).await; - match response { - Ok(response) => { - let text = response.text.clone(); - on_delta( - text.as_str(), - text.as_str(), - response.finish_reason.as_deref(), - ); - persist_planning_debug_response_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - Some(&response), - None, - ); - Ok(planning_turn_result_from_llm(&response)) - } - Err(error) => { - let transient_kind = - game_creator_agent_runtime_transient_provider_error_kind(&error, false); - let detail = format!("Planning V2 Provider 调用失败:{error}"); - persist_planning_debug_response_v2( - root, - debug_call_id, - session, - client_turn_id, - provider_attempt, - None, - Some(detail.as_str()), - ); - Err(PlanningProviderFailureV2 { - detail, - transient_kind, - }) - } - } - } -} - -pub(crate) fn result_text(result: &PlanningTurnResultV2) -> String { - result - .payload - .get("text") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string() -} - -fn persist_turn_failure_v2( - root: &Path, - client_turn_id: &str, - session_id: &str, - turn_index: u64, - elapsed_seconds: f64, - error: &PlanningErrorV2, -) -> Result { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.v2.turn.fail", - )?; - let mut session = - read_planning_session_v2(root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; - if session.session_id != session_id || session.turn_index != turn_index { - return Err("Planning V2 Session 回合身份发生变化".to_string()); - } - append_planning_message_v2( - root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: format!("msg-{}", Uuid::new_v4().simple()), - client_turn_id: client_turn_id.to_string(), - turn_index, - at_utc: current_plan_timestamp_utc(), - role: "assistant".to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": error.code, "summary": error.summary}), - }, - )?; - session.status = "provider_failed".to_string(); - session.processing_seconds += elapsed_seconds.max(0.0); - session.updated_at_utc = current_plan_timestamp_utc(); - session.last_error = Some(error.clone()); - write_planning_session_v2(root, &session)?; - Ok(session) -} - -async fn run_turn_v2( - root: &Path, - start: PlanningTurnStartV2, - client_turn_id: String, - prompt: String, - mut emit: F, -) -> Result -where - F: FnMut(PlanningSessionStreamEventV2) + Send, -{ - if let Some(result) = start.replay { - return Ok(PlanningSessionCommandResultV2 { - session: start.session, - result: Some(result), - current_artifact: current_planning_artifact_v2(root)?, - replayed: true, - conversation: None, - }); - } - let session_id = start.session.session_id.clone(); - let turn_index = start.session.turn_index; - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "started".to_string(), - delta_text: String::new(), - accumulated_text: String::new(), - finish_reason: None, - result: None, - error: None, - }); - let started_at = Instant::now(); - let mut accumulated = String::new(); - let mut attempt_prompt = prompt.clone(); - let mut policy_retry = 0_u8; - let mut provider_retry = 0_u32; - let mut provider_attempt = 0_u8; - let provider_retry_llm = load_game_creator_app_config() - .map(|config| resolve_game_creator_llm_config_for_agent(&config, "planning-agent-v2")) - .ok(); - let provider_max_retries = provider_retry_llm - .as_ref() - .map(|llm| llm.max_retries) - .unwrap_or(0); - let provider_retry_backoff_ms = provider_retry_llm - .as_ref() - .map(|llm| llm.retry_backoff_ms) - .unwrap_or(0); - let debug_call_id = format!("call-{}", Uuid::new_v4().simple()); - let policy_output = loop { - accumulated.clear(); - provider_attempt = provider_attempt.saturating_add(1); - let provider_result = invoke_provider_v2( - root, - &start.session, - &client_turn_id, - provider_attempt, - &debug_call_id, - &attempt_prompt, - start.context_messages.clone(), - |all, _delta, _finish_reason| { - accumulated = all.to_string(); - }, - ) - .await; - let result = match provider_result { - Ok(result) => result, - Err(failure) => { - if failure.transient_kind.is_some() && provider_retry < provider_max_retries { - provider_retry = provider_retry.saturating_add(1); - let backoff_ms = game_creator_agent_runtime_transient_retry_backoff_ms( - provider_retry_backoff_ms, - provider_retry, - ); - tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await; - continue; - } - let detail = if failure.transient_kind.is_some() && provider_max_retries > 0 { - format!( - "{}(瞬态故障已自动重试 {}/{} 次)", - failure.detail, provider_retry, provider_max_retries - ) - } else { - failure.detail - }; - let error = safe_error("PROVIDER_FAILED", detail); - let session = persist_turn_failure_v2( - root, - &client_turn_id, - &session_id, - turn_index, - started_at.elapsed().as_secs_f64(), - &error, - )?; - emit(PlanningSessionStreamEventV2 { - session_id, - client_turn_id, - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: accumulated, - finish_reason: None, - result: None, - error: Some(error.clone()), - }); - return Ok(PlanningSessionCommandResultV2 { - session, - result: Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": error.code, "summary": error.summary}), - }), - current_artifact: current_planning_artifact_v2(root)?, - replayed: false, - conversation: None, - }); - } - }; - match parse_planning_policy_output_v2(&result).and_then(|output| { - validate_planning_policy_output_v2(&output)?; - if matches!(output, PlanningPolicyOutputV2::Question(_)) - && start - .session - .question_limit - .is_some_and(|limit| start.session.question_count >= limit) - { - return Err( - "PLANNING_QUESTION_LIMIT: questionCount 已达到上限,必须直接输出 GDD" - .to_string(), - ); - } - Ok(output) - }) { - Ok(output) => { - persist_planning_debug_classification_v2( - root, - &debug_call_id, - &start.session, - &client_turn_id, - provider_attempt, - Some(match &output { - PlanningPolicyOutputV2::Question(_) => "question", - PlanningPolicyOutputV2::Gdd(_) => "gdd", - }), - true, - false, - None, - ); - break output; - } - Err(detail) if policy_retry < 1 => { - persist_planning_debug_classification_v2( - root, - &debug_call_id, - &start.session, - &client_turn_id, - provider_attempt, - None, - false, - true, - Some(detail.as_str()), - ); - policy_retry = policy_retry.saturating_add(1); - let retry_detail = detail.replace('\n', "\n- "); - attempt_prompt = format!( - "{}\n\n【阻断校验失败】Runtime 拒绝了上一次输出,具体原因如下:\n- {}\n请针对以上原因逐项修复,保持未涉及内容不变,并调用 plan_ask_question 或 plan_submit_gdd;不要在正文输出 JSON,不要解释。{}", - prompt, - retry_detail, - if start.session.question_limit.is_some_and(|limit| { - start.session.question_count >= limit - }) { - "当前问题数已达到上限,禁止再调用 plan_ask_question,必须调用 plan_submit_gdd。" - } else { - "" - } - ); - } - Err(detail) => { - persist_planning_debug_classification_v2( - root, - &debug_call_id, - &start.session, - &client_turn_id, - provider_attempt, - None, - false, - false, - Some(detail.as_str()), - ); - let error = safe_error("PLANNING_INVALID_OUTPUT", detail); - let session = persist_turn_failure_v2( - root, - &client_turn_id, - &session_id, - turn_index, - started_at.elapsed().as_secs_f64(), - &error, - )?; - emit(PlanningSessionStreamEventV2 { - session_id, - client_turn_id, - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: accumulated, - finish_reason: None, - result: None, - error: Some(error.clone()), - }); - return Ok(PlanningSessionCommandResultV2 { - session, - result: Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": error.code, "summary": error.summary}), - }), - current_artifact: current_planning_artifact_v2(root)?, - replayed: false, - conversation: None, - }); - } - } - }; - if !accumulated.is_empty() { - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "delta".to_string(), - delta_text: accumulated.clone(), - accumulated_text: accumulated.clone(), - finish_reason: None, - result: None, - error: None, - }); - } - let elapsed_seconds = started_at.elapsed().as_secs_f64(); - match persist_planning_policy_output_v2( - root, - &client_turn_id, - &session_id, - turn_index, - elapsed_seconds, - policy_output, - ) { - Ok(persisted) => { - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "completed".to_string(), - delta_text: String::new(), - accumulated_text: if accumulated.is_empty() { - result_text(&persisted.result) - } else { - accumulated - }, - finish_reason: persisted - .result - .payload - .get("finishReason") - .and_then(Value::as_str) - .map(ToString::to_string), - result: Some(persisted.result.clone()), - error: None, - }); - Ok(PlanningSessionCommandResultV2 { - session: persisted.session, - result: Some(persisted.result), - current_artifact: persisted.current_artifact, - replayed: false, - conversation: None, - }) - } - Err(detail) => { - let repaired = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "planning.v2.gdd.reconcile", - )?; - matches!(reconcile_committed_planning_gdd_v2(root), Ok(Some(_))) - }; - if repaired { - let session = read_planning_session_v2(root)? - .ok_or_else(|| "Planning V2 Session 不存在".to_string())?; - let artifact = current_planning_artifact_v2(root)? - .ok_or_else(|| "Planning V2 已提交 GDD 但缺少当前产物投影".to_string())?; - let result = PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "artifact".to_string(), - payload: artifact.clone(), - }; - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "completed".to_string(), - delta_text: String::new(), - accumulated_text: if accumulated.is_empty() { - result_text(&result) - } else { - accumulated - }, - finish_reason: None, - result: Some(result.clone()), - error: None, - }); - return Ok(PlanningSessionCommandResultV2 { - session, - result: Some(result), - current_artifact: Some(artifact), - replayed: false, - conversation: None, - }); - } - let error = safe_error("PLANNING_PERSIST_FAILED", detail); - let session = persist_turn_failure_v2( - root, - &client_turn_id, - &session_id, - turn_index, - elapsed_seconds, - &error, - )?; - emit(PlanningSessionStreamEventV2 { - session_id: session_id.clone(), - client_turn_id: client_turn_id.clone(), - status: "failed".to_string(), - delta_text: String::new(), - accumulated_text: accumulated, - finish_reason: None, - result: None, - error: Some(error.clone()), - }); - Ok(PlanningSessionCommandResultV2 { - session, - result: Some(PlanningTurnResultV2 { - schema_version: PLANNING_TURN_RESULT_V2_SCHEMA_VERSION.to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": error.code, "summary": error.summary}), - }), - current_artifact: current_planning_artifact_v2(root)?, - replayed: false, - conversation: None, - }) - } - } -} - -#[tauri::command] -pub(crate) async fn start_planning_session_v2( - app: tauri::AppHandle, - project_path: String, - client_turn_id: String, - prompt: String, - mode: Option, -) -> Result { - run_planning_session_v2_command( - &app, - project_path, - None, - client_turn_id, - prompt, - mode, - true, - None, - ) - .await -} - -#[tauri::command] -pub(crate) async fn continue_planning_session_v2( - app: tauri::AppHandle, - project_path: String, - session_id: String, - client_turn_id: String, - input: Value, -) -> Result { - let question_id = input - .get("questionId") - .and_then(Value::as_str) - .map(str::to_owned); - run_planning_session_v2_command( - &app, - project_path, - Some(session_id), - client_turn_id, - normalize_planning_input(input)?, - None, - false, - question_id, - ) - .await -} - -async fn run_planning_session_v2_command( - app: &tauri::AppHandle, - project_path: String, - expected_session_id: Option, - client_turn_id: String, - prompt: String, - mode: Option, - is_start: bool, - question_id: Option, -) -> Result { - let root = PathBuf::from(project_path.trim()); - enforce_project_permission_policy(&root, "conversation.read")?; - enforce_project_permission_policy(&root, "conversation.write")?; - let prompt = validate_prompt(&prompt)?; - let client_turn_id = validate_client_turn_id(&client_turn_id)?; - let project_id = read_project_id_v2(&root)?; - let _active_guard = try_acquire_planning_v2_active(&project_id)?; - let start = prepare_turn_v2( - &root, - &client_turn_id, - &prompt, - mode.as_deref(), - is_start, - expected_session_id.as_deref(), - question_id.as_deref(), - )?; - let event_app = app.clone(); - run_turn_v2(&root, start, client_turn_id, prompt, move |event| { - let _ = event_app.emit("planning-session-v2-stream", event); - }) - .await -} - -#[tauri::command] -pub(crate) async fn hydrate_planning_session_v2( - project_path: String, - session_id: Option, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - hydrate_planning_session_v2_sync(project_path, session_id) - }) - .await - .map_err(|error| format!("恢复 Planning V2 后台任务失败:{error}"))? -} - -pub(crate) fn hydrate_planning_session_v2_sync( - project_path: String, - session_id: Option, -) -> Result, String> { - let root = PathBuf::from(project_path.trim()); - enforce_project_permission_policy(&root, "conversation.read")?; - let project_id = read_project_id_v2(&root)?; - if planning_v2_is_active(&project_id) { - return Ok(None); - } - let Some(preflight_session) = read_planning_session_v2(&root)? else { - return Ok(None); - }; - if preflight_session.project_id != project_id { - return Err("Planning V2 Session projectId 与当前项目不一致".to_string()); - } - if let Some(session_id) = session_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - if preflight_session.session_id != session_id { - return Err("Planning V2 Session ID 不匹配".to_string()); - } - } - let _lock = match try_acquire_game_creator_agent_runtime_project_write_lock( - &root, - "planning.v2.hydrate", - ) { - Ok(lock) => lock, - Err(error) if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => { - return Ok(None) - } - Err(error) => return Err(error), - }; - let Some(mut session) = read_planning_session_v2(&root)? else { - return Ok(None); - }; - if session.status == "planning" && !planning_v2_is_active(&session.project_id) { - let messages = read_planning_messages_v2(&root)?; - if let Some(message) = successful_assistant_for_turn(&messages, session.turn_index) { - session.status = "awaiting_user".to_string(); - if message.kind == "question" { - session.current_question = Some(message.payload.clone()); - let count = messages - .iter() - .filter(|candidate| { - candidate.role == "assistant" - && candidate.kind == "question" - && candidate.turn_index <= session.turn_index - && validate_question_value_v2(&candidate.payload).is_ok() - }) - .map(|candidate| candidate.turn_index) - .collect::>() - .len() as u64; - session.question_count = session.question_count.max(count); - } - session.last_error = None; - } else { - session.status = "provider_failed".to_string(); - session.last_error = Some(safe_error( - "RECOVERY_REQUIRED", - "上次 Planning V2 Provider 回合在进程退出前未完成,请重新提交用户输入", - )); - } - session.updated_at_utc = current_plan_timestamp_utc(); - write_planning_session_v2(&root, &session)?; - } - reconcile_committed_planning_gdd_v2(&root)?; - let session = - read_planning_session_v2(&root)?.ok_or_else(|| "Planning V2 Session 不存在".to_string())?; - Ok(Some(PlanningSessionCommandResultV2 { - session, - result: None, - current_artifact: current_planning_artifact_v2(&root)?, - replayed: false, - conversation: Some(read_planning_messages_v2(&root)?), - })) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn client_turn_id_and_prompt_are_trimmed_and_bounded() { - assert_eq!(validate_client_turn_id(" turn-1 ").unwrap(), "turn-1"); - assert_eq!(validate_prompt(" 做方案 ").unwrap(), "做方案"); - assert!(validate_client_turn_id(&"x".repeat(129)).is_err()); - assert!(validate_prompt(" ").is_err()); - } - - #[test] - fn existing_turn_result_replays_assistant_text_or_error() { - let messages = vec![PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: "msg-1".to_string(), - client_turn_id: "turn-1".to_string(), - turn_index: 1, - at_utc: "2026-09-03T00:00:00.000Z".to_string(), - role: "assistant".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": "已完成"}), - }]; - let result = existing_turn_result_v2(&messages, "turn-1").expect("replay result"); - assert_eq!(result.kind, "assistant_text"); - assert_eq!(result_text(&result), "已完成"); - } - - #[test] - fn error_message_is_not_treated_as_successful_idempotent_replay() { - let messages = vec![PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: "msg-1".to_string(), - client_turn_id: "turn-1".to_string(), - turn_index: 1, - at_utc: "2026-09-03T00:00:00.000Z".to_string(), - role: "assistant".to_string(), - kind: "error".to_string(), - payload: serde_json::json!({"code": "PROVIDER_FAILED"}), - }]; - assert!(existing_turn_result_v2(&messages, "turn-1").is_none()); - } - - #[test] - fn session_validation_rejects_unknown_status_or_schema() { - let mut session = new_session_v2("project-1".to_string(), "gdd".to_string()); - assert!(validate_planning_session_v2(&session).is_ok()); - session.status = "unknown".to_string(); - assert!(validate_planning_session_v2(&session).is_err()); - session.status = "idle".to_string(); - session.schema_version = "planning-session.v1".to_string(); - assert!(validate_planning_session_v2(&session).is_err()); - } - - #[test] - fn continue_input_accepts_contract_shaped_option_payload() { - assert_eq!( - normalize_planning_input(serde_json::json!({ - "kind": "option", - "optionIndex": 0, - "optionLabel": "工作台设计迭代" - })) - .unwrap(), - "按选项:工作台设计迭代" - ); - assert_eq!( - normalize_planning_input(serde_json::json!({ "optionIndex": 1 })).unwrap(), - "按第 2 个选项做" - ); - } - - fn v2_revision_session_fixture() -> (tempfile::TempDir, PathBuf, PlanningSessionV2) { - let directory = tempfile::tempdir().expect("create v2 lock wait fixture"); - let root = directory.path().to_path_buf(); - crate::init_local_game_project_at(&root, "project-v2-lock", "V2 锁等待测试") - .expect("init project"); - let project_id = crate::read_manifest_for_project(&root) - .expect("read manifest") - .project_id; - let mut session = new_session_v2(project_id, "gdd".to_string()); - session.status = "revision_requested".to_string(); - session.turn_index = 1; - write_planning_session_v2(&root, &session).expect("write session"); - (directory, root, session) - } - - fn hold_project_lock_briefly(root: &Path, hold_millis: u64) -> std::thread::JoinHandle<()> { - let lock_path = root.join(".agent/project.lock"); - fs::write(&lock_path, b"test lock").expect("hold project lock"); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(hold_millis)); - fs::remove_file(&lock_path).expect("release project lock"); - }) - } - - #[test] - fn turn_start_rides_out_a_briefly_held_project_lock() { - let (_dir, root, _session) = v2_revision_session_fixture(); - let holder = hold_project_lock_briefly(&root, 120); - let start = prepare_turn_v2(&root, "turn-revise-1", "加强节奏", None, false, None, None) - .expect("修订续跑必须等过瞬时锁争用,而不是把失败甩回总控"); - holder.join().expect("lock holder thread"); - assert_eq!(start.session.status, "planning"); - assert!(start.replay.is_none()); - } - - #[test] - fn question_answer_must_target_the_persisted_question() { - let (_dir, root, mut session) = v2_revision_session_fixture(); - session.status = "awaiting_user".to_string(); - session.current_question = Some(serde_json::json!({"id": "question-2"})); - write_planning_session_v2(&root, &session).unwrap(); - for question_id in [Some("question-1"), None] { - let result = prepare_turn_v2( - &root, - "late-answer", - "选择第一个", - None, - false, - Some(&session.session_id), - question_id, - ); - assert!(matches!(result, Err(error) if error.contains("待回答问题已变更"))); - let persisted = read_planning_session_v2(&root).unwrap().unwrap(); - assert_eq!(persisted.status, "awaiting_user"); - assert_eq!(persisted.turn_index, session.turn_index); - assert_eq!(persisted.current_question, session.current_question); - assert!(read_planning_messages_v2(&root).unwrap().is_empty()); - } - let result = prepare_turn_v2( - &root, - "current-answer", - "选择第一个", - None, - false, - Some(&session.session_id), - Some("question-2"), - ) - .expect("当前问题回答应正常推进"); - assert_eq!(result.session.status, "planning"); - } - - #[test] - fn turn_rejects_a_different_session_before_writing() { - let (_dir, root, session) = v2_revision_session_fixture(); - let result = prepare_turn_v2( - &root, - "wrong-session", - "加强节奏", - None, - false, - Some("old-session"), - None, - ); - assert!(matches!(result, Err(error) if error.contains("Session ID 不匹配"))); - assert_eq!( - read_planning_session_v2(&root).unwrap().unwrap().turn_index, - session.turn_index - ); - assert!(read_planning_messages_v2(&root).unwrap().is_empty()); - } - - #[test] - fn completed_answer_replays_after_question_changes() { - let (_dir, root, mut session) = v2_revision_session_fixture(); - session.status = "awaiting_user".to_string(); - session.current_question = Some(serde_json::json!({"id": "question-2"})); - write_planning_session_v2(&root, &session).unwrap(); - append_planning_message_v2( - &root, - &PlanningMessageV2 { - schema_version: PLANNING_MESSAGE_V2_SCHEMA_VERSION.to_string(), - message_id: "completed-answer".to_string(), - client_turn_id: "answer-1".to_string(), - turn_index: 1, - at_utc: current_plan_timestamp_utc(), - role: "assistant".to_string(), - kind: "text".to_string(), - payload: serde_json::json!({"text": "已处理"}), - }, - ) - .unwrap(); - let result = prepare_turn_v2( - &root, - "answer-1", - "选择第一个", - None, - false, - Some(&session.session_id), - Some("question-1"), - ) - .expect("已完成回答继续重放,不再次推进"); - assert!(result.replay.is_some()); - assert_eq!(result.session.turn_index, session.turn_index); - assert_eq!(result.session.current_question, session.current_question); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index bd6f8b6b7..496a90e73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -781,7 +781,7 @@ mod provider_reconciliation_diagnostic_tests { let directory = tempfile::tempdir().expect("diagnostic directory"); let snapshot = AgentRuntimeProviderRequestSnapshot { project_id: "project-1".to_string(), - agent_id: "project-planning".to_string(), + agent_id: "project-supervisor".to_string(), task_id: "task-1".to_string(), session_id: "session-1".to_string(), run_id: "run-1".to_string(), @@ -805,7 +805,7 @@ mod provider_reconciliation_diagnostic_tests { usage: None, tool_calls: vec![platform_llm::LlmToolCall { id: "call-1".to_string(), - name: "runtime_tool_plan_submit_gdd".to_string(), + name: "runtime_tool_agent_message".to_string(), arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(), }], responses_output: Vec::new(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index d9df5d1f0..da28315b7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -201,39 +201,17 @@ pub(crate) fn observe_agent_runtime_agent_message( } } -/// 委派 task 末尾那句「你在这条链路上的位置」。三种跳的语义互不相同,共用一句话 -/// 就会说谎,所以这里把它显式化。 +/// 委派 task 末尾那句「你在这条链路上的位置」。返工和普通委派的语义不同,单独保留。 /// /// - `Repair`:质量返工,`repair_depth` 上限 1,「唯一返工轮」是真的。而且这句话是 /// 做游戏链路的**授权信号**——`design-foundation` / `art-director` / /// `art-asset-plan` 的角色提示词都把「任务正文明确标识这是带 repairOfDelegationId /// 的唯一返工轮」当作 `replaceExisting=true` 的唯一许可(见 agent/prompt.rs 的三处 /// 角色 prompt)。这一支逐字不能动。 -/// - `PlanClarification`:澄清续跑不消耗 `repair_depth`,预算是 -/// `static_delegate_clarification_round_limit_at`(plan 链路 3 轮)。master 只有一道 -/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后 -/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后 -/// 全部直接出稿,没有任何一个 run 走到第 2 轮。 -/// - `UserRevision`:用户在审批卡上点「修改 / 退回」后的修订轮。它同样带 -/// `repairOfDelegationId`,但 `repair_depth` 防的是 runaway agent,而这一跳每一轮 -/// 都由人触发——人本身就是循环边界,所以 `static_delegate_lineage_counters` 早就 -/// 把 depth/round 原样继承了。缺的是这句话:走 `Repair` 分支时用户第一次点修改就 -/// 会被告知「这是唯一返工轮」,和澄清跳当初那个假天花板是同一个错误。原型对应的是 -/// `USER_REVISION_SOFT_LIMIT = 16`,且超过只提示、不拒绝。 /// - `None`:普通委派,不加这一段。 pub(in crate::agent) enum StaticDelegateHopNote<'a> { None, - Repair { - original_delegation_id: &'a str, - }, - UserRevision { - original_delegation_id: &'a str, - }, - PlanClarification { - original_delegation_id: &'a str, - rounds_used: u32, - rounds_limit: u32, - }, + Repair { original_delegation_id: &'a str }, } impl StaticDelegateHopNote<'_> { @@ -247,32 +225,6 @@ impl StaticDelegateHopNote<'_> { StaticDelegateHopNote::Repair { original_delegation_id, } => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"), - StaticDelegateHopNote::UserRevision { - original_delegation_id, - } => format!( - "\n\n这是对已认领委派 {original_delegation_id} 的用户修订轮,由用户在审批卡上提出,不是质量返工,不消耗返工深度,也不重置澄清轮次。按任务正文里的用户意见原文修订同一份 GDD 谱系后重新提交;用户看过新稿还可以再次提出修改,这不是最后一轮,不要因此压缩改动或提前收尾。" - ), - // 预算用尽:planning_coordinator 出卡时会用 - // `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问, - // 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。 - StaticDelegateHopNote::PlanClarification { - original_delegation_id, - rounds_used, - rounds_limit, - } if rounds_used >= rounds_limit => format!( - "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮。已用澄清轮次 {rounds_used}/{rounds_limit},澄清预算已用尽:本轮不得再输出 AGC_NEEDS_USER_INPUT_V1 信封,剩余空白按默认建议补齐并标 default_pending,立即提交 GDD。" - ), - // 轮号必须和 planning_coordinator 出卡时的期望一致:那边用 - // `validate_exact_plan_clarification_question(.., current_round + 1)`, - // current_round 就是本 delivery 的谱系轮次,也就是这里的 rounds_used。 - StaticDelegateHopNote::PlanClarification { - original_delegation_id, - rounds_used, - rounds_limit, - } => format!( - "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 写成「第{next_round}轮·当前要决定:<主题>」,轮号必须是 {next_round},主题写这一轮真正要定的那件事。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", - next_round = rounds_used.saturating_add(1), - ), } } } @@ -1523,122 +1475,3 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated( .map(|value| redact_agent_runtime_project_paths(root, &value, 3_600)), } } - -#[cfg(test)] -mod tests { - use super::*; - - /// 委派 task 末尾那句话是两条链路的合同,不能共用一份文案。 - /// - /// 上半条钉做游戏链路:`design-foundation` / `art-director` / `art-asset-plan` - /// 的角色提示词把「任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮」 - /// 当作 `replaceExisting=true` 的唯一授权信号,改一个字就会让返工轮拿不到许可。 - /// - /// 下半条钉立项策划链路:澄清续跑不是返工轮,套用返工文案等于告诉策划子 Agent - /// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。 - /// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header, - /// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。 - /// 用户修订轮同样不能套返工文案。 - /// - /// 「唯一返工轮」防的是 runaway agent,而这一跳由用户在审批卡上亲手点出来——人本身 - /// 就是循环边界,`static_delegate_lineage_counters` 早就把 depth/round 原样继承了。 - /// 套用返工文案就是告诉策划子 Agent「用户只能改这一次」,和澄清跳当初那个假天花板 - /// 是同一个错误。原型对应的是软阈值 16 次、超过只提示不拒绝。 - #[test] - fn user_revision_hop_note_is_not_the_repair_round_note() { - let revision = render_static_delegate_task_contract( - "任务", - "project-supervisor", - "run-1", - "delegation-new", - &["交付 game/fast_gdd.md".to_string()], - &["game/fast_gdd.md".to_string()], - StaticDelegateHopNote::UserRevision { - original_delegation_id: "delegation-old", - }, - ) - .expect("render user revision hop note"); - assert!( - !revision.contains("唯一返工轮"), - "用户修订轮不得复用返工文案,否则子 Agent 以为用户只能改这一次:{revision}" - ); - assert!( - revision.contains("不消耗返工深度"), - "必须写明它不吃返工额度:{revision}" - ); - assert!( - revision.contains("不是最后一轮"), - "必须写明用户还能再改,否则子 Agent 会把多条意见攒到一轮改完:{revision}" - ); - } - - #[test] - fn plan_clarification_hop_note_is_not_the_repair_round_note() { - let repair = render_static_delegate_task_contract( - "任务", - "project-supervisor", - "run-1", - "delegation-new", - &["交付产物".to_string()], - &["assets/art-spec.png".to_string()], - StaticDelegateHopNote::Repair { - original_delegation_id: "delegation-old", - }, - ) - .expect("render repair"); - assert!( - repair.contains("这是对已认领委派 delegation-old 的唯一返工轮。"), - "返工轮文案是做游戏链路 replaceExisting 的授权信号,必须逐字保留:{repair}" - ); - - let clarification = render_static_delegate_task_contract( - "任务", - "project-supervisor", - "run-1", - "delegation-new", - &["交付 game/fast_gdd.md".to_string()], - &["game/fast_gdd.md".to_string()], - StaticDelegateHopNote::PlanClarification { - original_delegation_id: "delegation-old", - rounds_used: 1, - rounds_limit: 3, - }, - ) - .expect("render clarification"); - assert!( - !clarification.contains("唯一返工轮"), - "澄清续跑不得复用返工文案,否则策划子 Agent 以为只剩这一轮:{clarification}" - ); - assert!( - clarification.contains("已用澄清轮次 1/3"), - "澄清续跑必须写明已用轮次与上限:{clarification}" - ); - assert!( - clarification.contains("第2轮·当前要决定:"), - "task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1:{clarification}" - ); - - let exhausted = render_static_delegate_task_contract( - "任务", - "project-supervisor", - "run-1", - "delegation-new", - &["交付 game/fast_gdd.md".to_string()], - &["game/fast_gdd.md".to_string()], - StaticDelegateHopNote::PlanClarification { - original_delegation_id: "delegation-old", - rounds_used: 3, - rounds_limit: 3, - }, - ) - .expect("render exhausted clarification"); - assert!( - exhausted.contains("澄清预算已用尽"), - "预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}" - ); - assert!( - !exhausted.contains("第4轮·当前要决定:"), - "预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}" - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 4d4616f77..ab8410646 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -10,18 +10,6 @@ pub(in crate::agent) fn agent_role_project_path_mutation_block( if autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false) { return None; } - if is_agent_planning_storage_path(path) || is_plan_fast_gdd_projection_path(path) { - return Some(AgentRuntimeToolObservation { - tool: tool.to_string(), - status: "blocked".to_string(), - summary: if is_agent_planning_storage_path(path) { - "`.agent/planning/**` 只能由立项策划 Runtime 专用存储层写入".to_string() - } else { - "`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入".to_string() - }, - detail: Some(format!("agentId={agent_id} · runId={run_id} · path={path}")), - }); - } match autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id) { Ok(true) => { let allowed = autonomous_manifest_owner_artifact_paths(agent_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 6ac95a993..aa35b80a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -270,8 +270,7 @@ fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegis /// /// `"__all_agents__"` 是个不对应任何真实 Agent 的哨兵:走这条路径拿到的是 /// 未按身份收窄的完整函数目录。生产代码必须调用 `_for_agent` 版本并传入真实 -/// `agentId`,否则按身份收窄的工具面(如 `project-planning` 的 exact -/// allowlist)会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」 +/// `agentId`,否则按身份收窄的工具面会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」 /// 从运行时静默扩权变成编译期错误。 #[cfg(test)] pub(crate) fn build_agent_runtime_native_function_tools() -> Result, String> { 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 5f37f73b6..3f61116b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -952,104 +952,6 @@ pub(crate) fn validated_local_project_directory_path( Ok(path.to_path_buf()) } -/// Open the approved Fast GDD Markdown in whatever application the OS has -/// registered for it. -/// -/// The GDD is the one product artifact the 立项策划 lane hands back, and it is -/// already on disk — `plan.submit_gdd` renders `game/fast_gdd.md` and the -/// approval receipt re-renders it with the approved header. This command only -/// hands that existing path to the shell; it never creates or rewrites it. -#[tauri::command] -pub(crate) fn open_local_project_plan_gdd_markdown( - app: tauri::AppHandle, - project_path: String, -) -> Result<(), String> { - let path = validated_local_project_plan_gdd_markdown_path(project_path.trim())?; - app.opener() - .open_path(path.to_string_lossy().into_owned(), None::<&str>) - .map_err(|error| format!("打开 Fast GDD 文件失败:{error}")) -} - -pub(crate) fn validated_local_project_plan_gdd_markdown_path( - project_path: &str, -) -> Result { - let root = validated_local_project_directory_path(project_path)?; - // `resolve_local_project_path` 是项目内路径的唯一安全入口:它做根校验、相对路径 - // 归一化,并逐段拒绝符号链接。这里的相对路径是常量,但仍然走它——GDD 的渲染侧 - // (`planning_storage`)用的也是同一个解析器,两边对「项目内的这个文件」必须是 - // 同一个判定,不能一边解析一边拼字符串。 - let path = resolve_local_project_path(&root, PLAN_FAST_GDD_PATH)?; - match fs::symlink_metadata(&path) { - Ok(metadata) if metadata.file_type().is_file() => Ok(path), - Ok(_) => Err("Fast GDD 产物不是普通文件".to_string()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - Err("Fast GDD 产物尚未生成,请先完成立项策划审批".to_string()) - } - Err(error) => Err(format!("读取 Fast GDD 产物失败:{error}")), - } -} - -#[cfg(test)] -mod plan_gdd_markdown_path_tests { - use super::*; - - fn fixture() -> tempfile::TempDir { - let temporary = tempfile::tempdir().expect("create GDD path fixture"); - crate::project::init_local_game_project_at( - &temporary.path().join("project"), - "gdd-open", - "打开 GDD 产物", - ) - .expect("initialize GDD path fixture"); - temporary - } - - #[test] - fn resolves_the_rendered_markdown_under_the_project_root() { - let temporary = fixture(); - let root = temporary.path().join("project"); - fs::create_dir_all(root.join("game")).expect("create game directory"); - fs::write(root.join(PLAN_FAST_GDD_PATH), "# Fast GDD").expect("render markdown"); - - let resolved = - validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned()) - .expect("resolve rendered markdown"); - - assert_eq!(resolved, root.join(PLAN_FAST_GDD_PATH)); - } - - #[test] - fn refuses_to_open_a_markdown_that_has_not_been_rendered_yet() { - // 恢复态下 `plan.submit_gdd` 的 Markdown 渲染可能还没落盘。这时按钮必须给出 - // 明确原因,而不是把一个不存在的路径丢给 shell 由系统弹一个无从解释的错误。 - let temporary = fixture(); - let root = temporary.path().join("project"); - - let error = - validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned()) - .expect_err("missing markdown must fail closed"); - - assert!(error.contains("尚未生成"), "unexpected error: {error}"); - } - - #[test] - fn refuses_a_project_path_that_is_not_an_initialized_project() { - let temporary = tempfile::tempdir().expect("create bare fixture"); - let error = validated_local_project_plan_gdd_markdown_path( - &temporary.path().to_string_lossy().into_owned(), - ) - .expect_err("a directory without .agent is not a project root"); - assert!(!error.is_empty()); - } - - #[test] - fn refuses_a_relative_project_path() { - let error = validated_local_project_plan_gdd_markdown_path("relative/project") - .expect_err("relative project path must fail"); - assert!(error.contains("绝对路径"), "unexpected error: {error}"); - } -} - #[tauri::command] pub(crate) async fn get_local_game_manifest( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 80c807e96..2f75bc184 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1756,8 +1756,8 @@ pub(crate) fn build_static_delegate_structured_result_at( // (实测:option 对象里多写了一个 `id` 字段)就把整条委派判成投递失败,父 // Supervisor 直接进 needs-reconciliation 停下等人。 // - // 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」,和 - // plan.submit_gdd 的业务拒绝同类。降级成 needs-repair 并把解析错误当返工理由 + // 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」。 + // 降级成 needs-repair 并把解析错误当返工理由 // 带上:Supervisor 用既有的一次返工额度就能让子 Agent 重写,不需要人工介入。 let (user_input_questions, user_input_questions_sha256, user_input_parse_error) = match parse_static_delegate_user_input_request(error) { @@ -3426,11 +3426,11 @@ mod tests { assert!(failed.contains("客观证据冲突")); let mut missing = base.clone(); - missing.missing_expected_artifacts = vec!["game/fast_gdd.md".to_string()]; + missing.missing_expected_artifacts = vec!["design.md".to_string()]; let missing_error = validate_static_delegate_structured_result( &missing, "completed", - &["game/fast_gdd.md".to_string()], + &["design.md".to_string()], ) .expect_err("missing expected artifact must reject a user revision result"); assert!(missing_error.contains("客观证据冲突")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index a850b381c..79672d028 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2568,7 +2568,6 @@ fn main() { read_agc_plugin_panel, set_agc_plugin_project_path, open_local_project_directory, - open_local_project_plan_gdd_markdown, control_agent_run, generate_local_game_draft, chat_with_game_creator_agent, @@ -2577,10 +2576,6 @@ fn main() { chat_with_game_creator_direct_codex, cancel_direct_codex_turn, select_game_creator_reasoning_effort, - start_planning_session_v2, - continue_planning_session_v2, - decide_planning_artifact_v2, - hydrate_planning_session_v2, hydrate_design_agent_session, reset_design_agent_session, get_design_agent_runtime_mode, diff --git a/apps/ai-game-creator-shell/src-tauri/src/patchset.rs b/apps/ai-game-creator-shell/src-tauri/src/patchset.rs index ca3125e28..dbe032e3e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/patchset.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/patchset.rs @@ -7,9 +7,7 @@ use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use unicode_normalization::UnicodeNormalization; -use crate::project::{ - is_plan_fast_gdd_projection_path, normalize_relative_path, validate_project_root, -}; +use crate::project::{normalize_relative_path, validate_project_root}; const PROJECT_PATCHSET_MAX_CHANGES: usize = 12; const PROJECT_PATCHSET_MAX_TOTAL_BODY_BYTES: usize = 256 * 1024; @@ -466,11 +464,6 @@ fn normalize_and_validate_patchset_inputs( let path = normalize_relative_path(change.path())?; reject_sensitive_patchset_path(&path)?; - if is_plan_fast_gdd_projection_path(&path) { - return Err( - "project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(), - ); - } let change = match change { ParsedProjectPatchsetChange::Create { content, .. } => { validate_input_text(&content, &path)?; @@ -1372,11 +1365,6 @@ fn metadata_is_link_or_reparse(metadata: &Metadata) -> bool { } fn reject_sensitive_patchset_path(relative_path: &str) -> Result<(), String> { - if is_plan_fast_gdd_projection_path(relative_path) { - return Err( - "project.patchset 不得直接修改 Runtime-owned game/fast_gdd.md 投影".to_string(), - ); - } let components = relative_path .split('/') .map(str::to_ascii_lowercase) @@ -1706,8 +1694,6 @@ mod tests { for path in [ ".agent/runtime/state.json", - ".agent/planning/gdd.v1.json", - "game/fast_gdd.md", ".env.local", "config/private.pem", "data/runtime.sqlite", diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs index 380cc2836..70b12ab48 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/checkpoint.rs @@ -674,7 +674,6 @@ pub(crate) fn restore_local_project_checkpoint_at( pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool { should_skip_project_snapshot_path(relative_path) - || is_plan_fast_gdd_projection_path(relative_path) || relative_path == ".agent/agent.db" || relative_path == PROJECT_PERMISSION_POLICY_PATH || relative_path == PROJECT_WRITE_LOCK_PATH diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs index f9704c49a..d81ed7686 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/filesystem.rs @@ -56,7 +56,6 @@ pub(crate) fn list_local_project_files_at( if is_agent_runtime_private_control_path(&relative_path) || is_agent_checkpoint_control_path(&relative_path) || is_agent_workbench_control_path(&relative_path) - || is_agent_planning_storage_path(&relative_path) { continue; } @@ -167,56 +166,6 @@ pub(crate) fn reject_agent_runtime_private_control_path( Ok(()) } -/// `.agent/planning/**` is a Runtime-owned sidecar. It remains readable by -/// the narrow planning read tools, but generic project mutation helpers must -/// never be able to create, replace, patch, or delete it. Keeping this gate -/// separate from `reject_agent_runtime_private_control_path` is deliberate: -/// the planning Agent needs `file.read`/`file.list` observations while its -/// durable writer is still the only component allowed to mutate the sidecar. -pub(crate) fn is_agent_planning_storage_path(normalized_path: &str) -> bool { - let normalized_path = normalized_path.to_ascii_lowercase(); - normalized_path == ".agent/planning" - || normalized_path.starts_with(".agent/planning/") - || normalized_path == ".agent/planning-v2" - || normalized_path.starts_with(".agent/planning-v2/") -} - -pub(crate) fn is_agent_planning_managed_write_path(normalized_path: &str) -> bool { - is_agent_planning_storage_path(normalized_path) - || is_plan_fast_gdd_projection_path(normalized_path) -} - -pub(crate) fn reject_agent_planning_storage_write_path( - normalized_path: &str, -) -> Result<(), String> { - if is_agent_planning_managed_write_path(normalized_path) { - return Err( - "`.agent/planning/**`、`.agent/planning-v2/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝" - .to_string(), - ); - } - Ok(()) -} - -/// `game/fast_gdd.md` is the human-readable planning projection. It lives -/// outside `.agent/planning`, but it is still Runtime-owned and must not be -/// mutated by generic file tools. Keep this predicate write-only so planning -/// observations can continue to read the projection. -pub(crate) fn is_plan_fast_gdd_projection_path(normalized_path: &str) -> bool { - normalized_path.eq_ignore_ascii_case(PLAN_FAST_GDD_PATH) -} - -pub(crate) fn reject_plan_projection_write_path(normalized_path: &str) -> Result<(), String> { - reject_agent_planning_storage_write_path(normalized_path)?; - if is_plan_fast_gdd_projection_path(normalized_path) { - return Err( - "`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入,通用文件写入被拒绝" - .to_string(), - ); - } - Ok(()) -} - fn reject_agent_control_path_delete(normalized_path: &str) -> Result<(), String> { if matches!( normalized_path.split('/').next(), @@ -253,7 +202,6 @@ pub(crate) fn write_local_project_file_at( ) -> Result { let normalized_path = normalize_relative_path(relative_path)?; reject_agent_runtime_private_control_path(&normalized_path)?; - reject_plan_projection_write_path(&normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?; if path.exists() && !path.is_file() { return Err("只能写入文件".to_string()); @@ -273,7 +221,6 @@ pub(crate) fn delete_local_project_file_at( ) -> Result { let normalized_path = normalize_relative_path(relative_path)?; reject_agent_runtime_private_control_path(&normalized_path)?; - reject_plan_projection_write_path(&normalized_path)?; reject_agent_control_path_delete(&normalized_path)?; let path = resolve_local_project_path(root, &normalized_path)?; if !path.exists() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs index bdf5bd33d..883f6b073 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/write_lock.rs @@ -416,7 +416,7 @@ pub(crate) fn project_write_lock_reclaim( } /// `.agent/project.lock` 的争用错误前缀。`project_gates.rs`、`provider_recovery.rs`、 -/// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用 +/// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把争用 /// 识别成"可以等一下"的瞬时状态;文案扩展时要保持前缀逐字不变。 pub(crate) const PROJECT_WRITE_LOCK_CONTENTION_PREFIX: &str = "项目正在被其他写操作占用:"; @@ -520,7 +520,7 @@ impl ProjectWriteLockFailure { } /// 零等待入口的文案。可重试的失败保持争用前缀逐字不变:`provider_recovery.rs`、 - /// `planning_session_v2.rs`、`direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 + /// `direct_runtime.rs` 和前端 `App.tsx` 都按这个前缀把错误 /// 当成可等待的瞬时状态,改前缀等于顺手改掉它们的重试语义。 pub(crate) fn message(&self) -> String { match self { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 42c202fc7..7be35aff2 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -36,7 +36,6 @@ import { CONVERSATION_VISIBLE_STEP, createLocalProjectId, PROJECT_SUPERVISOR_AGENT_ID, - PROJECT_SUPERVISOR_PLAN_SOURCE, seedManifest, } from './app/constants'; import { useEscapeToClose } from './app/dialogs'; @@ -92,8 +91,6 @@ import type { OpenCanvasProjectResult, PendingCommand, PendingUiConfirmation, - PlanGddDecisionAction, - PlanGddStateViewV1, ProjectPermissionPolicy, ProjectPermissionPolicyView, SyncCanvasProjectAssetsResult, @@ -265,16 +262,6 @@ import { parseRememberInput, } from './features/project-workspace/memoryCommands'; import { pendingCommandDetail } from './features/project-workspace/pendingCommandPresentation'; -import { planningStateNeedsRuntimeRefresh } from './features/project-workspace/planningLane'; -import { - type PlanningApprovalCommandResultV2, - planningMessagesToChatMessages, - planningResultDisplayText, - type PlanningSessionCommandResultV2, - type PlanningSessionStreamEventV2, - planningSessionToPlanGddState, - planningSessionToRuntime, -} from './features/project-workspace/planningSessionV2'; import { isAgentTraceFilePath, isProjectPolicyConfirmableCommandId, @@ -512,7 +499,6 @@ type AppProps = { summaries: ProjectAgentRuntimeSummary[], ) => void; onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void; - onMakeGameFromApprovedGdd?: (projectPath: string) => Promise; }; type ExecuteChatAgentReplyInput = { @@ -557,16 +543,14 @@ export function App({ onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, - onMakeGameFromApprovedGdd, }: AppProps = {}) { const { setTitle: setWindowTitle } = useWindowChrome(); - const [planningV2Active, setPlanningV2Active] = useState(planningStartMode); - const planningV2ActiveRef = useRef(planningStartMode); - planningV2ActiveRef.current = planningV2Active; + const [designAgentActive, setDesignAgentActive] = useState(planningStartMode); + const designAgentActiveRef = useRef(planningStartMode); + designAgentActiveRef.current = designAgentActive; const [designAgentView, setDesignAgentView] = useState( null, ); - const designAgentLaneRef = useRef(planningStartMode); const designAgentTurnRef = useRef<{ projectPath: string; clientTurnId: string; @@ -577,15 +561,12 @@ export function App({ const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>( null, ); - // 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于 - // Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。 - // 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。 const directCodexProductRuntime = DIRECT_CODEX_PRODUCT_RUNTIME && projectSupervisorOnly && !supervisorChatOnly && !planningStartMode && - !planningV2Active; + !designAgentActive; const [devMode] = useState(() => projectSupervisorOnly ? false : isDeveloperMode(), ); @@ -799,56 +780,27 @@ export function App({ useState(null); const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] = useState(''); - const [planGddState, setPlanGddState] = useState( - null, - ); - const planGddStateRef = useRef(null); - planGddStateRef.current = planGddState; - const [planningV2Session, setPlanningV2Session] = - useState(null); - const planningV2SessionRef = useRef( - null, - ); - planningV2SessionRef.current = planningV2Session; - const [planningV2TransientReply, setPlanningV2TransientReplyVisible] = + const [designAgentTransientReply, setDesignAgentTransientReplyVisible] = useState(''); - const planningV2TransientReplyTargetRef = useRef(''); - const planningV2VisibleReplyRef = useRef(''); + const designAgentTransientReplyTargetRef = useRef(''); + const designAgentVisibleReplyRef = useRef(''); const designAgentPendingViewRef = useRef<{ clientTurnId: string; projectPath: string; view: DesignView; } | null>(null); - const [planningV2Reasoning, setPlanningV2Reasoning] = useState(''); + const [designAgentReasoning, setDesignAgentReasoning] = useState(''); const designAgentReasoningTurnRef = useRef<{ projectPath: string; clientTurnId: string; text: string; } | null>(null); - const planningV2TurnRef = useRef<{ - projectPath: string; - clientTurnId: string; - } | null>(null); - const planGddHydrateSequenceRef = useRef(0); - const [planGddHydrateBusy, setPlanGddHydrateBusy] = useState(false); - const [planGddDecisionBusy, setPlanGddDecisionBusy] = useState(false); - const [planGddError, setPlanGddError] = useState(null); - const planGddDecisionResponseIdsRef = useRef( - new Map< - string, - { - action: PlanGddDecisionAction; - comment: string | null; - responseId: string; - } - >(), - ); - function setPlanningV2TransientReplyTarget(next: string) { - planningV2TransientReplyTargetRef.current = next; + function setDesignAgentTransientReplyTarget(next: string) { + designAgentTransientReplyTargetRef.current = next; if (!next) { - planningV2VisibleReplyRef.current = ''; - setPlanningV2TransientReplyVisible(''); + designAgentVisibleReplyRef.current = ''; + setDesignAgentTransientReplyVisible(''); } } @@ -876,154 +828,27 @@ export function App({ useEffect(() => { const timer = window.setInterval(() => { - const target = planningV2TransientReplyTargetRef.current; - setPlanningV2TransientReplyVisible((current) => { + const target = designAgentTransientReplyTargetRef.current; + setDesignAgentTransientReplyVisible((current) => { if (!target) { - planningV2VisibleReplyRef.current = ''; + designAgentVisibleReplyRef.current = ''; return ''; } const prefix = target.startsWith(current) ? current : ''; if (prefix === target) { - planningV2VisibleReplyRef.current = target; + designAgentVisibleReplyRef.current = target; return target; } const remaining = target.length - prefix.length; const step = remaining > 160 ? 4 : remaining > 48 ? 2 : 1; const next = target.slice(0, prefix.length + step); - planningV2VisibleReplyRef.current = next; + designAgentVisibleReplyRef.current = next; return next; }); }, 50); return () => window.clearInterval(timer); }, []); - function applyPlanningV2CommandResult( - result: PlanningSessionCommandResultV2, - clientTurnId?: string, - ) { - planningV2ActiveRef.current = true; - setPlanningV2Active(true); - planningV2SessionRef.current = result; - setPlanningV2Session(result); - projectSupervisorSessionIdRef.current = result.session.sessionId; - setProjectSupervisorSessionId(result.session.sessionId); - setPlanGddState( - planningSessionToPlanGddState(result.session, result.currentArtifact), - ); - updateProjectSupervisorRuntime(planningSessionToRuntime(result.session)); - const resultError = - result.result?.kind === 'error' - ? (() => { - const payload = result.result.payload; - if ( - payload && - typeof payload === 'object' && - typeof (payload as { summary?: unknown }).summary === 'string' - ) { - return (payload as { summary: string }).summary.trim(); - } - return ''; - })() - : ''; - const sessionError = result.session.lastError?.summary ?? resultError; - setProjectSupervisorRuntimeError(sessionError); - if (result.conversation) { - let conversationMessages = planningMessagesToChatMessages( - result.conversation, - ); - // 创建项目后的首条需求可能先于规划会话快照到达;不能让后到的空快照 - // 把用户刚发出的内容覆盖掉。 - const initialPrompt = initialSupervisorMessageLatchRef.current.prompt; - if ( - initialPrompt && - !conversationMessages.some( - (message) => - message.role === 'user' && message.text.trim() === initialPrompt, - ) - ) { - conversationMessages = [ - { - role: 'user', - text: initialPrompt, - runtimeOwned: true, - updatedAt: Date.now(), - }, - ...conversationMessages, - ]; - } - setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setMessages(conversationMessages); - savedConversationProjectPathRef.current = localProjectPathRef.current; - savedConversationCountRef.current = conversationMessages.length; - latestMessagesRef.current = conversationMessages; - } else if (result.result && clientTurnId) { - const displayText = planningResultDisplayText( - result.result, - result.currentArtifact, - ); - if (displayText.trim()) { - const messageId = `planning-v2:${clientTurnId}:assistant`; - setMessages((current) => { - const existingIndex = current.findIndex( - (message) => message.messageId === messageId, - ); - const nextMessage: ChatMessage = { - role: 'assistant', - text: displayText, - runtimeOwned: true, - messageId, - updatedAt: Date.now(), - }; - if (existingIndex < 0) { - return [...current, nextMessage]; - } - return current.map((message, index) => - index === existingIndex ? nextMessage : message, - ); - }); - } - } - } - - async function hydratePlanningV2Session( - nextProjectPath: string, - requestSequence: number, - ) { - const invoke = resolveTauriInvoke(); - if (!invoke || !nextProjectPath.trim()) { - return null; - } - const sessionId = planningV2SessionRef.current?.session.sessionId; - const result = await invoke( - 'hydrate_planning_session_v2', - { - projectPath: nextProjectPath, - ...(sessionId ? { sessionId } : {}), - }, - ); - if ( - requestSequence !== planGddHydrateSequenceRef.current || - localProjectPathRef.current !== nextProjectPath - ) { - return null; - } - if (!result) { - planningV2SessionRef.current = null; - setPlanningV2Session(null); - setPlanGddState(null); - if (planningStartMode) { - planningV2ActiveRef.current = true; - setPlanningV2Active(true); - designAgentLaneRef.current = true; - } - return null; - } - designAgentLaneRef.current = false; - setDesignAgentView(null); - applyPlanningV2CommandResult(result); - return result; - } - function designMessagesToChat(view: DesignView): ChatMessage[] { const reasoningByMessageId = new Map(); for (const entry of view.reasoningEntries ?? []) { @@ -1062,11 +887,10 @@ export function App({ } function applyDesignView(view: DesignView, projectPath: string) { - designAgentLaneRef.current = true; + designAgentActiveRef.current = true; + setDesignAgentActive(true); setDesignAgentView(view); - planningV2ActiveRef.current = true; - setPlanningV2Active(true); - setPlanGddError(view.session.lastError); + setProjectSupervisorRuntimeError(view.session.lastError ?? ''); setChatAgentBusy(view.running); const conversation = designMessagesToChat(view); setMessages(conversation); @@ -1079,8 +903,8 @@ export function App({ const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId; designAgentPendingViewRef.current = null; applyDesignView(view, projectPath); - setPlanningV2Reasoning(''); - setPlanningV2TransientReplyTarget(''); + setDesignAgentReasoning(''); + setDesignAgentTransientReplyTarget(''); if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) { designAgentTurnRef.current = null; } @@ -1091,7 +915,7 @@ export function App({ projectPath: string, clientTurnId: string, ) { - let target = planningV2TransientReplyTargetRef.current; + let target = designAgentTransientReplyTargetRef.current; const tracked = designAgentTurnRef.current; if (!target.trim() && !view.running) { const latestAssistantText = [...view.messages] @@ -1099,7 +923,7 @@ export function App({ .find((message) => message.role !== 'user' && message.text.trim()) ?.text.trim(); if (latestAssistantText) { - setPlanningV2TransientReplyTarget(latestAssistantText); + setDesignAgentTransientReplyTarget(latestAssistantText); target = latestAssistantText; } } @@ -1107,7 +931,7 @@ export function App({ !view.running && tracked?.clientTurnId === clientTurnId && target.trim() && - planningV2VisibleReplyRef.current !== target + designAgentVisibleReplyRef.current !== target ) { designAgentPendingViewRef.current = { clientTurnId, @@ -1125,8 +949,8 @@ export function App({ if (!pending) { return; } - const target = planningV2TransientReplyTargetRef.current; - if (target && planningV2VisibleReplyRef.current !== target) { + const target = designAgentTransientReplyTargetRef.current; + if (target && designAgentVisibleReplyRef.current !== target) { return; } commitDesignAgentView(pending.view, pending.projectPath); @@ -1178,8 +1002,8 @@ export function App({ await designAgentEventSubscriptionReady(); setChatAgentBusy(true); setProjectSupervisorRuntimeError(''); - setPlanningV2TransientReplyTarget(''); - setPlanningV2Reasoning(''); + setDesignAgentTransientReplyTarget(''); + setDesignAgentReasoning(''); try { const view = await invoke('continue_design_agent_session', { projectPath: nextProjectPath, @@ -1199,197 +1023,15 @@ export function App({ requestRuntimeConfigOpen(); } setProjectSupervisorRuntimeError(message); - setPlanGddError(message); } finally { if (!designAgentPendingViewRef.current) { designAgentTurnRef.current = null; - setPlanningV2TransientReplyTarget(''); + setDesignAgentTransientReplyTarget(''); } setChatAgentBusy(false); } } - const hydratePlanGddState = useCallback( - async (nextProjectPath?: string) => { - const targetProjectPath = - nextProjectPath?.trim() || localProjectPathRef.current || projectPath; - const invoke = resolveTauriInvoke(); - if (!invoke || !targetProjectPath.trim()) { - setPlanGddState(null); - return; - } - const requestSequence = ++planGddHydrateSequenceRef.current; - setPlanGddHydrateBusy(true); - setPlanGddError(null); - try { - await hydratePlanningV2Session(targetProjectPath, requestSequence); - } catch (error) { - // 项目写锁争用是瞬时的:后端已经等过一个短窗口,仍然没抢到只说明此刻 - // 运行时正在写盘。这条 effect 每次监工状态变化都会再跑一次,下一拍就能 - // 拿到,所以保留上一份状态、不动错误位——否则一次刚落盘成功的审批会在 - // 卡里显示成失败(decide 成功后紧跟的这次 hydrate 正是最容易撞上的时刻)。 - const transientContention = - String(error).includes('项目正在被其他写操作占用:'); - if ( - !transientContention && - requestSequence === planGddHydrateSequenceRef.current && - localProjectPathRef.current === targetProjectPath - ) { - setPlanGddError(String(error)); - } - } finally { - if ( - requestSequence === planGddHydrateSequenceRef.current && - localProjectPathRef.current === targetProjectPath - ) { - setPlanGddHydrateBusy(false); - } - } - }, - // V2 helpers intentionally read current refs/setters; keeping this callback - // stable prevents hydrate effects from running on every render. - // eslint-disable-next-line react-hooks/exhaustive-deps - [projectPath], - ); - - const decidePlanGdd = useCallback( - async (action: PlanGddDecisionAction, comment: string | null) => { - const current = planGddStateRef.current; - const pending = current?.pendingApproval; - const targetProjectPath = - resolveChatProjectPath(localProject) ?? projectPath; - const invoke = resolveTauriInvoke(); - if (!pending || !current?.displayGdd || !invoke || !targetProjectPath) { - throw new Error('当前没有可提交的 GDD 审批决定'); - } - // 方案 §13.2:busy、超时与网络重试复用同一 responseId,但用户改变 action 或 - // comment 后必须换新的。旧键只含 `approvalRequestId:action`,改写修改意见时会 - // 带着旧 responseId 提交,命中后端「同 responseId 的审批意图不一致」硬错误。 - // 判据方向是宁可多换不可少换:多换的最坏后果是 receipt 已存在时把 replayed 降级 - // 成 already-decided,两者都是 Ok;少换是硬错误。 - const previousDecision = planGddDecisionResponseIdsRef.current.get( - pending.approvalRequestId, - ); - const responseId = - previousDecision && - previousDecision.action === action && - previousDecision.comment === comment - ? previousDecision.responseId - : `gdd-response-${crypto.randomUUID()}`; - planGddDecisionResponseIdsRef.current.set(pending.approvalRequestId, { - action, - comment, - responseId, - }); - setPlanGddDecisionBusy(true); - setPlanGddError(null); - try { - const planningSessionId = current.session?.sessionId; - if (!planningSessionId) { - throw new Error('Planning V2 Session 不存在'); - } - const approval = await invoke( - 'decide_planning_artifact_v2', - { - projectPath: targetProjectPath, - sessionId: planningSessionId, - artifactId: pending.gddRef.gddId, - version: pending.gddRef.version, - fingerprint: pending.gddRef.fingerprint, - decisionId: responseId, - action, - comment, - }, - ); - applyPlanningV2CommandResult({ - session: approval.session, - result: null, - currentArtifact: approval.currentArtifact, - replayed: approval.replayed, - }); - setPlanGddError(null); - if (action === 'revise' && comment?.trim()) { - await executePlanningV2Turn( - targetProjectPath, - `审批:revise ${comment.trim()}`, - `planning-v2-revision-${crypto.randomUUID()}`, - ); - } - } catch (error) { - // 方案 §18.3 要求 decision 返回后以 hydrate 对权威文件的重验为准,失败分支同样 - // 适用:不重灌就会让卡片停在已失效的 pending 身份上,三个决定按钮仍可点,且 - // `recoveryPending` 永远翻不成真、「重试同步」入口不渲染,卡内没有出路。 - // 两句顺序不能反——`hydratePlanGddState` 入口会 `setPlanGddError(null)`, - // 先写错误再 hydrate 等于把这条错误擦掉。它自身从不抛出,不需要再包一层。 - await hydratePlanGddState(targetProjectPath); - setPlanGddError(String(error)); - throw error; - } finally { - setPlanGddDecisionBusy(false); - } - }, - // V2 decision handling also reads the current session through refs. - // eslint-disable-next-line react-hooks/exhaustive-deps - [hydratePlanGddState, localProject, projectPath], - ); - - useEffect(() => { - const targetProjectPath = localProject?.projectPath; - if (!targetProjectPath) { - setPlanGddState(null); - setPlanGddError(null); - return; - } - void hydratePlanGddState(targetProjectPath); - }, [hydratePlanGddState, localProject?.projectPath]); - - useEffect(() => { - // 存在性判据故意走 `status`(必选字段,为 `undefined` 当且仅当 runtime 为 null)而不是整个 - // 对象:依赖里只挖 phase/status/updatedAt 三个标量,是为了只在监工状态真的动了时 - // 重灌。把 `projectSupervisorRuntime` 本体写进依赖会让每一轮轮询新建的对象身份都触发一次 - // hydrate,白烧 IPC。 - if ( - !localProject?.projectPath || - projectSupervisorRuntime?.status === undefined - ) { - return; - } - // 后端 hydrate 会抢项目写锁并扫 authority,不是纯内存读。没有这道门,做游戏和做 - // 素材链路的每一拍监工心跳都会去抢一次项目写锁——而那两条链路根本不产生策划状态。 - // 策划状态读 ref 而不进依赖:hydrate 成功就会换一个 `planGddState` 对象身份,写进 - // 依赖等于 hydrate 触发 hydrate。 - if ( - !planningStateNeedsRuntimeRefresh( - projectSupervisorRuntime?.source, - planGddStateRef.current, - ) - ) { - return; - } - void hydratePlanGddState(localProject.projectPath); - }, [ - hydratePlanGddState, - localProject?.projectPath, - projectSupervisorRuntime?.phase, - projectSupervisorRuntime?.source, - projectSupervisorRuntime?.status, - projectSupervisorRuntime?.updatedAt, - ]); - - useEffect(() => { - const hydrateOnResume = () => { - if (document.visibilityState === 'hidden' || !localProject?.projectPath) { - return; - } - void hydratePlanGddState(localProject.projectPath); - }; - window.addEventListener('focus', hydrateOnResume); - document.addEventListener('visibilitychange', hydrateOnResume); - return () => { - window.removeEventListener('focus', hydrateOnResume); - document.removeEventListener('visibilitychange', hydrateOnResume); - }; - }, [hydratePlanGddState, localProject?.projectPath]); const [projectSupervisorExpectedRunId, setProjectSupervisorExpectedRunId] = useState(null); const chatInputRef = useRef(null); @@ -1692,22 +1334,17 @@ export function App({ projectSupervisorRuntimeRef.current = null; projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; - planningV2SessionRef.current = null; - planningV2TurnRef.current = null; projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); setProjectSupervisorRuntime(null); setProjectSupervisorExpectedRunId(null); setProjectSupervisorResponseStream(null); setProjectSupervisorRuntimeError(''); - setPlanningV2Session(null); - setPlanningV2TransientReplyTarget(''); designAgentPendingViewRef.current = null; designAgentReasoningTurnRef.current = null; - setPlanningV2Reasoning(''); - setPlanningV2Active(planningStartMode); - planningV2ActiveRef.current = planningStartMode; - designAgentLaneRef.current = planningStartMode; + setDesignAgentReasoning(''); + setDesignAgentActive(planningStartMode); + designAgentActiveRef.current = planningStartMode; designAgentTurnRef.current = null; setDesignAgentView(null); } @@ -2008,7 +1645,7 @@ export function App({ if ( !canSubscribeTauriEvents() || directCodexProductRuntime || - planningV2Active + designAgentActive ) { return; } @@ -2111,71 +1748,15 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ directCodexProductRuntime, - planningV2Active, + designAgentActive, refreshManifest, updateProjectSupervisorResponseStream, updateProjectSupervisorRuntime, ]); - useEffect(() => { - if (!canSubscribeTauriEvents() || !planningV2Active) { - return; - } - let cleanup: (() => void) | null = null; - let disposed = false; - void subscribeTauriEvent( - 'planning-session-v2-stream', - (event) => { - const payload = event.payload; - const trackedTurn = planningV2TurnRef.current; - if (!trackedTurn || payload.clientTurnId !== trackedTurn.clientTurnId) { - return; - } - if (payload.status === 'started') { - setPlanningV2TransientReplyTarget( - payload.accumulatedText || '正在生成策划方案…', - ); - setProjectSupervisorRuntimeError(''); - } - if ( - payload.status === 'delta' && - (payload.accumulatedText || payload.deltaText) - ) { - setPlanningV2TransientReplyTarget( - payload.accumulatedText || payload.deltaText, - ); - setProjectSupervisorRuntimeError(''); - } - if (payload.status === 'failed' && payload.error?.summary) { - setProjectSupervisorRuntimeError(payload.error.summary); - } - }, - ) - .then((unlisten) => { - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - }) - .catch((error) => { - if (!disposed) { - setProjectSupervisorRuntimeError( - `Planning V2 实时回复不可用:${ - error instanceof Error ? error.message : String(error) - }`, - ); - } - }); - return () => { - disposed = true; - cleanup?.(); - }; - }, [planningV2Active]); - useEffect(() => { const ready = createDesignAgentEventSubscriptionReady(); - if (!canSubscribeTauriEvents() || !planningV2Active) { + if (!canSubscribeTauriEvents() || !designAgentActive) { resolveDesignAgentEventSubscriptionReady(); return () => { if (designAgentEventSubscriptionReadyRef.current === ready) { @@ -2196,7 +1777,7 @@ export function App({ return; } if (payload.kind === 'text' && payload.text != null) { - setPlanningV2TransientReplyTarget(payload.text); + setDesignAgentTransientReplyTarget(payload.text); } if (payload.reasoningText != null) { const reasoningTurn = designAgentReasoningTurnRef.current; @@ -2206,11 +1787,11 @@ export function App({ reasoningTurn.clientTurnId === payload.clientTurnId ) { reasoningTurn.text = payload.reasoningText; - setPlanningV2Reasoning(payload.reasoningText); + setDesignAgentReasoning(payload.reasoningText); } } if (payload.kind === 'tool' && payload.text) { - setPlanningV2TransientReplyTarget(payload.text); + setDesignAgentTransientReplyTarget(payload.text); } if (payload.view) { applyDesignAgentViewAfterTransient( @@ -2243,7 +1824,7 @@ export function App({ // applyDesignView 读的是 refs 和当前项目路径, // 把它写进依赖会在每轮回复时重订事件。 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [planningV2Active]); + }, [designAgentActive]); useEffect(() => { if (!canSubscribeTauriEvents()) { @@ -2292,7 +1873,7 @@ export function App({ if ( !invoke || directCodexProductRuntime || - planningV2Active || + designAgentActive || !nextProjectPath || !sessionId || !trackedRunId || @@ -2388,7 +1969,7 @@ export function App({ }, [ localProject?.projectPath, directCodexProductRuntime, - planningV2Active, + designAgentActive, projectSupervisorSessionId, projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, @@ -2403,7 +1984,7 @@ export function App({ if ( !projectSupervisorOnly || directCodexProductRuntime || - planningV2Active || + designAgentActive || !invoke || !nextProjectPath || !supervisorRunId @@ -2484,7 +2065,7 @@ export function App({ }, [ localProject?.projectPath, directCodexProductRuntime, - planningV2Active, + designAgentActive, projectSupervisorOnly, projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, @@ -2496,7 +2077,7 @@ export function App({ (!supervisorChatOnly && !( projectSupervisorOnly && - (directCodexProductRuntime || planningV2Active) + (directCodexProductRuntime || designAgentActive) )) || !supervisorChatShouldFollowLatestRef.current ) { @@ -2513,7 +2094,7 @@ export function App({ projectSupervisorRuntimeError, directThreadChat, directCodexProductRuntime, - planningV2Active, + designAgentActive, projectSupervisorOnly, supervisorChatOnly, ]); @@ -3438,11 +3019,7 @@ export function App({ const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; try { - // V2 projects do not have a Supervisor run or legacy conversation. Probe the - // V2 authority first; a missing V2 session returns null and preserves the - // existing open-project behavior for older projects. - let planningV2: PlanningSessionCommandResultV2 | null = null; - if (projectSupervisorOnly || planningStartMode) { + if (planningStartMode) { try { const design = await hydrateDesignAgentSession(nextProjectPath); if (design) { @@ -3459,44 +3036,9 @@ export function App({ ); return; } - } catch (error) { - if (planningStartMode) { - setPlanGddError( - `策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`, - ); - } - } - try { - planningV2 = await invoke( - 'hydrate_planning_session_v2', - { projectPath: nextProjectPath }, - ); - } catch (error) { - if (planningStartMode) { - throw error; - } - // Older projects/binaries may not expose V2 yet; continue with the - // legacy read path unless the caller explicitly entered planning V2. - } - } - if (planningStartMode || (projectSupervisorOnly && planningV2)) { - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath - ) { - return; - } - planningV2ActiveRef.current = true; - setPlanningV2Active(true); - if (planningV2) { - designAgentLaneRef.current = false; + designAgentActiveRef.current = true; + setDesignAgentActive(true); setDesignAgentView(null); - applyPlanningV2CommandResult(planningV2); - } else { - designAgentLaneRef.current = true; - planningV2SessionRef.current = null; - setPlanningV2Session(null); - setPlanGddState(null); projectSupervisorSessionIdRef.current = null; setProjectSupervisorSessionId(null); updateProjectSupervisorRuntime(null); @@ -3504,16 +3046,18 @@ export function App({ savedConversationProjectPathRef.current = nextProjectPath; savedConversationCountRef.current = 0; latestMessagesRef.current = []; + setWorkspaceStatus((workspaceStatus) => + workspaceStatus === '等待确认' + ? `已打开:${nextProjectPath}` + : workspaceStatus, + ); + return; + } catch (error) { + setProjectSupervisorRuntimeError( + `策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`, + ); + return; } - setProjectSupervisorRuntimeError( - planningV2?.session.lastError?.summary ?? '', - ); - setWorkspaceStatus((workspaceStatus) => - workspaceStatus === '等待确认' - ? `已打开:${nextProjectPath}` - : workspaceStatus, - ); - return; } const resumeError = directCodexProductRuntime ? '' @@ -6360,87 +5904,6 @@ export function App({ } } - async function executePlanningV2Turn( - nextProjectPath: string, - prompt: string, - clientTurnId = createAgentChatRunId('planning-v2-turn'), - questionId = planningV2SessionRef.current?.session.currentQuestion?.id, - ) { - const invoke = resolveTauriInvoke(); - const normalizedPrompt = prompt.trim(); - if (!invoke) { - setProjectSupervisorRuntimeError('需要在 Tauri App 内运行。'); - return; - } - if (!nextProjectPath.trim() || !normalizedPrompt) { - return; - } - const currentSessionId = planningV2SessionRef.current?.session.sessionId; - planningV2TurnRef.current = { - projectPath: nextProjectPath, - clientTurnId, - }; - setChatAgentBusy(true); - setProjectSupervisorRuntimeError(''); - setPlanningV2TransientReplyTarget(''); - setPlanningV2Reasoning(''); - try { - const result = currentSessionId - ? await invoke( - 'continue_planning_session_v2', - { - projectPath: nextProjectPath, - sessionId: currentSessionId, - clientTurnId, - input: { - text: normalizedPrompt, - ...(questionId ? { questionId } : {}), - }, - }, - ) - : await invoke( - 'start_planning_session_v2', - { - projectPath: nextProjectPath, - clientTurnId, - prompt: normalizedPrompt, - mode: 'gdd', - }, - ); - if (localProjectPathRef.current !== nextProjectPath) { - return; - } - applyPlanningV2CommandResult(result, clientTurnId); - setCommandLog((current) => [ - ...current, - currentSessionId ? 'planning.v2.continue' : 'planning.v2.start', - ]); - } catch (error) { - if (localProjectPathRef.current !== nextProjectPath) { - return; - } - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { - requestRuntimeConfigOpen(); - } - setProjectSupervisorRuntimeError(message); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: message, - runtimeOwned: true, - messageId: `planning-v2:${clientTurnId}:error`, - updatedAt: Date.now(), - }, - ]); - } finally { - planningV2TurnRef.current = null; - setPlanningV2TransientReplyTarget(''); - setChatAgentBusy(false); - } - } - async function executeChatAgentReply({ prompt, clientTurnId: directConversationTurnId, @@ -6450,13 +5913,13 @@ export function App({ references, userItem, }: ExecuteChatAgentReplyInput) { - if (planningV2ActiveRef.current || planningStartMode) { + if (designAgentActiveRef.current) { const nextProjectPath = resolveChatProjectPath(localProject); if (!nextProjectPath) { setProjectSupervisorRuntimeError('请先初始化本地项目'); return; } - if (designAgentLaneRef.current) { + if (designAgentActiveRef.current) { await executeDesignAgentTurn( nextProjectPath, { type: 'message', text: prompt }, @@ -6464,11 +5927,6 @@ export function App({ ); return; } - await executePlanningV2Turn( - nextProjectPath, - prompt, - directConversationTurnId ?? createAgentChatRunId('planning-v2-turn'), - ); return; } // Product default: send the conversation directly to Codex app-server. @@ -6773,27 +6231,10 @@ export function App({ return; } const runtimeAtSubmission = projectSupervisorRuntimeRef.current; - // 做方案入口独立成链。首次提交时本项目还没有任何 Supervisor run,只能靠首页 - // 带下来的 startMode 判断;之后由 plan 根 run 自己的 source 接管,做游戏与 - // 做素材两条链路不受影响。 - const planningEntry = - workspaceProjectKind === 'web' && - (runtimeAtSubmission?.source === PROJECT_SUPERVISOR_PLAN_SOURCE || - (planningStartMode && !runtimeAtSubmission)); - if ( - planningEntry && - runtimeAtSubmission && - runtimeAtSubmission.source === PROJECT_SUPERVISOR_PLAN_SOURCE && - !isAgentRuntimeTerminalState(runtimeAtSubmission) - ) { - setProjectSupervisorRuntimeError('请先完成当前立项策划步骤'); - return; - } const submissionRoute = resolveProjectSupervisorRuntimeSubmission({ workspaceProjectKind, orchestrationMode, supervisorChatOnly, - planningEntry, }); const submission = await submitProjectSupervisorRuntimeTask({ invoke, @@ -7335,42 +6776,6 @@ export function App({ answers: Record, ) { const nextProjectPath = resolveChatProjectPath(localProject); - if (planningV2ActiveRef.current || planningStartMode) { - const current = planningV2SessionRef.current; - const currentQuestion = current?.session.currentQuestion; - const answer = Object.values(answers) - .map((value) => value.trim()) - .find((value) => value.length > 0); - if ( - !nextProjectPath || - !current || - !currentQuestion || - request.sessionId !== current.session.sessionId || - request.questions[0]?.id !== currentQuestion.id || - !answer || - chatAgentBusy - ) { - setProjectSupervisorRuntimeError('待回答问题已变更,请刷新策划状态'); - return; - } - setMessages((current) => [ - ...current, - { - role: 'user', - text: answer, - runtimeOwned: true, - messageId: `planning-v2:${responseId}:user`, - updatedAt: Date.now(), - }, - ]); - await executePlanningV2Turn( - nextProjectPath, - answer, - responseId, - request.questions[0].id, - ); - return; - } const runtime = projectSupervisorRuntimeRef.current; const sessionId = projectSupervisorSessionIdRef.current; const currentRequest = runtime?.userInputRequest; @@ -12296,27 +11701,7 @@ export function App({ void loadProjectConversation(nextProjectPath, false, 'replace'); return; } - if (planningV2ActiveRef.current || planningStartMode) { - if (!prompt || chatAgentBusy) { - return; - } - supervisorChatShouldFollowLatestRef.current = true; - const clientTurnId = createAgentChatRunId('planning-v2-turn'); - setChatInput(''); - setMessages((current) => [ - ...current, - { - role: 'user', - text: prompt, - runtimeOwned: true, - messageId: `planning-v2:${clientTurnId}:user`, - updatedAt: Date.now(), - }, - ]); - void executeChatAgentReply({ prompt, clientTurnId }); - return; - } - if (supervisorChatOnly || directCodexProductRuntime) { + if (supervisorChatOnly || directCodexProductRuntime || designAgentActive) { supervisorChatShouldFollowLatestRef.current = true; } if (directCodexProductRuntime) { @@ -12355,8 +11740,7 @@ export function App({ agent.id !== PROJECT_SUPERVISOR_AGENT_ID && (agent.runtimeStatus !== null || agent.hasRecentEvidence), ); - const useDesignAgentSurface = - Boolean(designAgentView) || (planningStartMode && !planningV2Session); + const useDesignAgentSurface = Boolean(designAgentView) || designAgentActive; if (projectSupervisorOnly && supervisorChatOnly) { const supervisorProjectPath = @@ -12390,8 +11774,8 @@ export function App({ runtimeConfigOpen={runtimeConfigOpen} runtimeError={projectSupervisorRuntimeError} transientReply={ - planningV2Active - ? planningV2TransientReply + designAgentActive + ? designAgentTransientReply : projectSupervisorTransientReply } hasConversationControls={projectSupervisorHasConversationControls} @@ -12452,14 +11836,14 @@ export function App({ projectPath={localProject?.projectPath ?? projectPath} conversationMessages={messages} transientReply={ - planningV2Active - ? planningV2TransientReply + designAgentActive + ? designAgentTransientReply : directCodexProductRuntime ? '' : projectSupervisorTransientReply } - showDesignReasoning={planningV2Active} - designReasoning={planningV2Reasoning} + showDesignReasoning={designAgentActive} + designReasoning={designAgentReasoning} designReasoningEntries={ useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : [] } @@ -12469,13 +11853,6 @@ export function App({ !directCodexProductRuntime && orchestrationMode === 'professional-dag' } workspaceStatus={workspaceStatus} - planGddState={planGddState} - planGddHydrateBusy={planGddHydrateBusy} - planGddDecisionBusy={planGddDecisionBusy} - planGddError={planGddError} - onPlanGddRefresh={() => void hydratePlanGddState()} - onPlanGddDecision={decidePlanGdd} - planningLane={planningV2Active} designView={useDesignAgentSurface ? designAgentView : null} onDesignApprove={ useDesignAgentSurface @@ -12496,10 +11873,9 @@ export function App({ text: '', }; designAgentPendingViewRef.current = null; - setPlanningV2TransientReplyTarget(''); - setPlanningV2Reasoning(''); + setDesignAgentTransientReplyTarget(''); + setDesignAgentReasoning(''); setChatAgentBusy(true); - setPlanGddDecisionBusy(true); void designAgentEventSubscriptionReady() .then(() => invoke('decide_design_phase', { @@ -12533,7 +11909,7 @@ export function App({ ) { return; } - setPlanGddError(String(error)); + setProjectSupervisorRuntimeError(String(error)); }) .finally(() => { if ( @@ -12546,10 +11922,9 @@ export function App({ } if (!designAgentPendingViewRef.current) { designAgentTurnRef.current = null; - setPlanningV2TransientReplyTarget(''); + setDesignAgentTransientReplyTarget(''); } setChatAgentBusy(false); - setPlanGddDecisionBusy(false); }); } : undefined @@ -12581,14 +11956,6 @@ export function App({ } : undefined } - onMakeGameFromApprovedGdd={ - onMakeGameFromApprovedGdd - ? () => - onMakeGameFromApprovedGdd( - localProject?.projectPath ?? projectPath, - ) - : undefined - } runtime={projectSupervisorRuntime} error={projectSupervisorRuntimeError} runtimeByAgentId={agentRuntimeById} diff --git a/apps/ai-game-creator-shell/src/app/constants.ts b/apps/ai-game-creator-shell/src/app/constants.ts index 91bb2b6f1..62fbb6f0c 100644 --- a/apps/ai-game-creator-shell/src/app/constants.ts +++ b/apps/ai-game-creator-shell/src/app/constants.ts @@ -18,15 +18,6 @@ export const CONVERSATION_VISIBLE_STEP = 20; export const DIRECT_HISTORY_MAX_PAGES_PER_ACTION = 5; export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48; export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor'; -/** - * 立项策划链路的 run `source`。 - * - * 这是全前端唯一的字面量出处:`AgentRuntimeState.source` 在类型上只是 `string`, - * 改名不会有任何编译期提示,所以判据必须收敛到这一个常量上。`as const` 让它同时 - * 能充当 `ProjectSupervisorRuntimeSubmission['source']` 的成员。 - */ -export const PROJECT_SUPERVISOR_PLAN_SOURCE = - 'project-supervisor-plan' as const; export const launcherNotifications: Array<{ label: string; detail: string; diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index c30ab26f8..822fe1bb8 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -220,170 +220,6 @@ export interface GenerateLocalGameDraftResult { manifest: GameCreationAppManifest; } -export type PlanGddDecisionAction = 'approve' | 'revise' | 'reject'; - -export interface PlanGddStateViewV1 { - schemaVersion: 'plan-gdd-state-view.v1'; - projectId: string; - gddId: string | null; - state: - | 'not_started' - | 'draft' - | 'ready_for_approval' - | 'revision_requested' - | 'approved' - | 'rejected'; - session: { - sessionId: string; - sessionRevision: number; - sessionFingerprint: string; - phase: - | 'collecting' - | 'awaiting_user_input' - | 'awaiting_gdd_approval' - | 'revision_requested' - | 'approved' - | 'rejected' - | 'recovery_required'; - clarificationRound: number; - questionLimit?: number | null; - repairDepth: number; - accumulatedAgentMillis: number; - activeRunId: string | null; - awaitingAnswerFor: { - delegationId: string; - requestId: string; - questionId: string; - round: number; - } | null; - decisionStateCounts: { - confirmed: number; - defaultPending: number; - assumptionPending?: number; - prototypePending: number; - }; - } | null; - versions: Array<{ - gddRef: { gddId: string; version: number; fingerprint: string }; - status: - | 'ready_for_approval' - | 'revision_requested' - | 'approved' - | 'rejected' - | 'superseded'; - approvalRequestId: string; - createdAtUtc: string; - decision: { action: PlanGddDecisionAction; decidedAtUtc: string } | null; - }>; - displayGdd: { - schemaVersion: string; - projectId: string; - gddId: string; - version: number; - submissionId: string; - approvalRequestId: string; - actionFingerprint: string; - agentId: string; - source: string; - runProfile: string; - runProfileBindingFingerprint: string; - rootAgentId: string; - rootRunId: string; - delegationId: string; - sessionId: string; - sourceSessionRevision: number; - sourceSessionFingerprint: string; - createdByRunId: string; - createdAtUtc: string; - fingerprint: string; - game: { - title: string; - oneLiner: string; - genre: { primary: string; fusion: string | null }; - artStyle: { - visualType: string; - keywords: string[]; - moodAndColor: string; - mvpArtBoundary: string; - }; - pillars: Array<{ - name: string; - playerFeel: string; - mechanism: string; - decisionState: string; - basis: null; - }>; - coreLoop: string[]; - targetUsers: { - coreUsers: string; - preferences: string; - sessionLength: string; - referenceGames: string[]; - }; - platformFacts: { - runtime: string; - viewports: string[]; - inputs: string[]; - preview: string; - }; - mvpSystems: Array<{ - system: string; - minimalFunction: string; - whyRequired: string; - verifyMethod: string; - decisionState: string; - basis: null; - }>; - outOfScope: string[]; - creatorTips: { - doFirst: string; - deferForNow: string; - howToVerify: string; - expandWhen: string; - }; - }; - decisions: Array<{ - id: string; - topic: string; - state: - | 'confirmed' - | 'assumption_pending' - | 'default_pending' - | 'prototype_pending'; - answerSource: - | 'user_option' - | 'user_freeform' - | 'user_revision' - | 'agent_inferred' - | 'default'; - round: number; - answerSummary: string; - basis: null; - }>; - prototypeValidationItems: Array<{ - id: string; - question: string; - microPrototype: string; - observation: string; - passCriterion: string; - }>; - } | null; - pendingApproval: { - gddRef: { gddId: string; version: number; fingerprint: string }; - pendingActionId: string; - actionFingerprint: string; - approvalRequestId: string; - sessionId: string; - runId: string; - } | null; - approvedGddRef: { - gddId: string; - version: number; - fingerprint: string; - } | null; - recoveryPending: boolean; -} - export interface GameCreatorChatAgentReply { replyText: string; } @@ -1105,11 +941,8 @@ export interface GameCreatorDirectToolCallDetail { } /** - * 一条工具调用(Codex item 的结构化投影)。 - * - * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`:字段形状与 - * Rust 侧 `DirectToolCall`、独立历史文件 `.agent/conversations/tool-calls.jsonl` 的 payload - * 一致(DirectProject 聊天卡片用 `Omit` 这一变体)。 + * 一条工具调用的展示投影,由 DirectProject 原始条目生成。 + * `directThreadChat` 的回合内卡片省略 `turnId`,回合身份由所属回合承载。 */ export interface GameCreatorDirectToolCall { schemaVersion: string; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 44dd62ff5..c8baf7bb4 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -2,11 +2,7 @@ import type { GameCreationAppManifest, GameCreationAppTaskState, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; -import { - PROJECT_SUPERVISOR_AGENT_ID, - PROJECT_SUPERVISOR_PLAN_SOURCE, - seedManifest, -} from '../../app/constants'; +import { PROJECT_SUPERVISOR_AGENT_ID, seedManifest } from '../../app/constants'; import type { AgentConversationSessionListResult, AgentConversationSessionRecord, @@ -32,28 +28,18 @@ const AGENT_RUNTIME_MESSAGE_CORRELATION_PATTERN = /^[0-9a-f]{32}$/; export type ProjectSupervisorRuntimeSubmission = { runProfile: 'standard' | 'autonomous-game-build'; - source: 'project-supervisor-gui' | typeof PROJECT_SUPERVISOR_PLAN_SOURCE; + source: 'project-supervisor-gui'; }; export function resolveProjectSupervisorRuntimeSubmission({ workspaceProjectKind, orchestrationMode, supervisorChatOnly, - planningEntry = false, }: { workspaceProjectKind: LocalProjectKind; orchestrationMode: 'single-supervisor' | 'professional-dag'; supervisorChatOnly: boolean; - planningEntry?: boolean; }): ProjectSupervisorRuntimeSubmission { - // 立项策划入口独立成链:命中时固定 standard + plan source,不参与下面按 - // godot / chat / DAG 的分流,也不改动做游戏与做素材的既有路由。 - if (planningEntry && workspaceProjectKind === 'web') { - return { - runProfile: 'standard', - source: PROJECT_SUPERVISOR_PLAN_SOURCE, - }; - } if (workspaceProjectKind === 'godot' || supervisorChatOnly) { return { runProfile: 'standard', @@ -810,34 +796,11 @@ export function agentRuntimeNeedsUserInput( } export const AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL = 'user.input_request'; -export const AGENT_RUNTIME_PLAN_SUBMIT_GDD_TOOL = 'plan.submit_gdd'; - -// 有两个 pending 动作不是「等待批准的动作」,而是某张专用卡的载体:Runtime 把内容停在 -// pending 上,真正的交互面是另一个组件。 -// -// - `user.input_request`:子 Agent 以 needs-user-input 终态退出后,Runtime 在 parent-wake -// 屏障处按信封原文构造这个 pending(`ensure_static_delegate_user_input_wait_at_locked`), -// 同一份问题再投影成 `userInputRequest`,交互面是问答卡。 -// - `plan.submit_gdd`:策划子 Agent 提交 Fast GDD 后,`planning/pending.json` 的 -// `submission.pendingActionId` 指向的正是这个 pending,交互面是 `GddApprovalCard` -// 的批准 / 修改 / 退回。 -// -// 两者都没有「确认 / 拒绝」语义:`user.input_request` 从来不在 -// `GAME_CREATION_APP_COMMANDS` 的 confirm 集合里;`plan.submit_gdd` 更是被运行时策略明确 -// 拒绝转成通用确认 pending(「Runtime-owned create-only 提交」),点确认必然失败。 -// -// 通用待确认卡按 `tool` + `inputSummary` 渲染,套到它们身上就是把同一个请求画两遍——标题 -// 是原始工具名,副标题是 `questionCount=… · questionsSha256=…` 这种给日志看的取证摘要, -// 还会把真正该看的那张卡压在下面。所以这里统一把通用确认面判掉。 -const AGENT_RUNTIME_DEDICATED_CARD_TOOLS = new Set([ - AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL, - AGENT_RUNTIME_PLAN_SUBMIT_GDD_TOOL, -]); export function agentRuntimePendingActionHasDedicatedCard( action: AgentRuntimePendingToolActionSummary | null | undefined, ) { - return Boolean(action && AGENT_RUNTIME_DEDICATED_CARD_TOOLS.has(action.tool)); + return action?.tool === AGENT_RUNTIME_USER_INPUT_REQUEST_TOOL; } export function matchingAgentRuntimeForSteer( @@ -1557,9 +1520,6 @@ export function projectSupervisorCollaboratingAgentRuntimes( } export function projectProfessionalAgentLabel(agentId: string) { - if (agentId === 'project-planning') { - return '立项策划 Agent'; - } if (agentId === 'design-foundation') { return '玩法策划 Agent'; } @@ -2097,13 +2057,6 @@ export function projectRuntimeVisibleError( ) { return `${subject} 保存运行记录失败,请检查项目目录后重试`; } - if ( - normalized.includes('planning_invalid') || - normalized.includes('gdd 结构无效') || - normalized.includes('策划输出格式') - ) { - return `${subject} 输出格式不符合当前 GDD 结构,请重试`; - } const containsInternalDiagnostics = normalized.includes('agentllm.') || /(?:^|[\s::])kind=/.test(normalized) || diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx index 157f1392b..95b50ccbe 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/panels.tsx @@ -676,7 +676,6 @@ export function ProjectSupervisorRuntimePanel({ error: string; runtimeByAgentId: Record; controlBusy: boolean; - planGddAwaitingDecision?: boolean; readOnly?: boolean; professionalResultsByAgentId: Record< string, diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx index 1df87f2bb..e4af6c8be 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -166,10 +166,10 @@ export function ActiveProjectRunsPanel({ const elapsed = formatActiveTurnElapsed(turn.startedAt, now); const isCurrent = Boolean( currentProjectPath && - projectPathsMatchForInvalidation( - turn.projectPath, - currentProjectPath, - ), + projectPathsMatchForInvalidation( + turn.projectPath, + currentProjectPath, + ), ); return (
  • @@ -215,10 +215,10 @@ export function ActiveProjectRunsPanel({ const elapsed = formatActiveTurnElapsed(turn.startedAt, now); const isCurrent = Boolean( currentProjectPath && - projectPathsMatchForInvalidation( - turn.projectPath, - currentProjectPath, - ), + projectPathsMatchForInvalidation( + turn.projectPath, + currentProjectPath, + ), ); return (
  • diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index ed96b3dda..6d016e649 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -116,7 +116,6 @@ export function WorkspaceLauncherShell({ activeProjectAgentResults, setAgentResults: setActiveProjectAgentResults, resetLauncherHomeDraft, - startGameFromApprovedGdd, createHomeDraftAutomatically, openProject, homeCreationBusy, @@ -755,7 +754,6 @@ export function WorkspaceLauncherShell({ setActiveProjectAgentRuntimeSummaries } onAgentResultsChange={setActiveProjectAgentResults} - onMakeGameFromApprovedGdd={startGameFromApprovedGdd} onSwitchToGameRuntime={switchToGameRuntime} /> } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index a997fb341..4653f9695 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -67,7 +67,6 @@ export type ProjectSupervisorComponentProps = { summaries: ProjectAgentRuntimeSummary[], ) => void; onAgentResultsChange?: (results: ProjectAgentResultSummary[]) => void; - onMakeGameFromApprovedGdd?: (projectPath: string) => Promise; onSwitchToGameRuntime?: (projectPath: string) => Promise; }; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 6da9f8a7f..dc4717ec6 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -19,7 +19,6 @@ import type { LauncherProjectContext, LocalGameProjectRevisionStatus, LocalProjectDirectoryStatus, - LocalProjectFileResult, PendingNonEmptyProject, ProjectStartMode, RenameLocalProjectResult, @@ -67,30 +66,9 @@ type UseHomeProjectCreationOptions = { rememberRecentWorkspace: (projectPath: string) => void; }; -export const APPROVED_GDD_BUILD_PROMPT = [ - '请按照附件中的已批准 GDD 开始建造这款游戏。', - '', - '这份 GDD 已覆盖游戏定位与一句话概念、类型与美术方向、游戏支柱、核心循环、目标用户、平台与输入事实、MVP 系统、暂不纳入范围、创作者提示和原型验证项。', - '', - '请先阅读并理解附件中的 fast_gdd.md,以它作为本次建造的主要依据,优先实现其中 MVP 范围内的可运行游戏原型。', -].join('\n'); - export const DESIGN_ARTIFACTS_BUILD_PROMPT = '查看当前项目 design_artifacts/ 目录及其子目录下的文档,理解其游戏设计,并按照这些文档将游戏实现出来。'; -function createTextAttachmentFile(content: string) { - const file = new File([content], 'fast_gdd.md', { - type: 'text/markdown', - lastModified: Date.now(), - }); - if (typeof file.arrayBuffer !== 'function') { - Object.defineProperty(file, 'arrayBuffer', { - value: async () => new TextEncoder().encode(content).buffer, - }); - } - return file; -} - async function suggestAutomaticProjectName( invoke: TauriInvoke, draft: HomeDraft, @@ -211,7 +189,6 @@ export function useHomeProjectCreation({ const resetLauncherHomeDraft = useLauncherHomeDraftStore( (state) => state.reset, ); - const approvedGddStartInFlightRef = useRef(false); const [homeCreationOperation, setHomeCreationOperation] = useState( - 'read_local_project_file', - { - projectPath, - relativePath: 'game/fast_gdd.md', - commandId: 'file.read', - }, - ); - const file = createTextAttachmentFile(result.content); - - await createHomeDraftAutomaticallyWithOptions( - { - creationType: 'game', - prompt: APPROVED_GDD_BUILD_PROMPT, - attachments: [ - { - id: `approved-gdd-${Date.now().toString(36)}`, - file, - }, - ], - }, - 'direct-build', - { suggestName: false }, - ); - } finally { - approvedGddStartInFlightRef.current = false; - } - } - async function enterProjectDevelopment( context: LauncherProjectContext, isCurrent: () => boolean = () => true, @@ -1054,7 +986,6 @@ export function useHomeProjectCreation({ ), pendingNonEmptyProject, resetLauncherHomeDraft, - startGameFromApprovedGdd, createHomeDraft, createHomeDraftAutomatically, openProject, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx deleted file mode 100644 index 1273bb017..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ /dev/null @@ -1,569 +0,0 @@ -import { useState } from 'react'; - -import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration'; -import { resolveTauriInvoke } from '../../app/tauri'; -import type { - PlanGddDecisionAction, - PlanGddStateViewV1, -} from '../../app/types'; - -/** - * 立项策划唯一的产品产物,由策划会话提交与审批回执渲染到项目内。 - * - * 权威定义在 Rust 侧 `planning_gdd_model.rs` 的 `PLAN_FAST_GDD_PATH`,那里同时管着渲染 - * 落盘和写入守卫。跨语言没有共享常量的通道,这里只能重复一份;改路径时两处要一起动。 - */ -const PLAN_FAST_GDD_RELATIVE_PATH = 'game/fast_gdd.md'; - -/** - * 拼出交付文件的绝对路径。 - * - * 用户要拿这行去资源管理器里找文件,所以跟随项目路径本身的分隔符:Windows 下项目 - * 路径是反斜杠,混排出来的 `C:\...\project/game/fast_gdd.md` 虽然能用,但复制到 - * 地址栏之外的地方就不像一个路径了。 - */ -function planGddMarkdownDisplayPath(projectPath: string) { - const trimmed = projectPath.trim().replace(/[\\/]+$/u, ''); - if (!trimmed) { - return PLAN_FAST_GDD_RELATIVE_PATH; - } - const separator = trimmed.includes('\\') ? '\\' : '/'; - return `${trimmed}${separator}${PLAN_FAST_GDD_RELATIVE_PATH.split('/').join(separator)}`; -} - -type GddApprovalCardProps = { - state: PlanGddStateViewV1 | null; - hydrateBusy: boolean; - decisionBusy: boolean; - error: string | null; - onRefresh: () => void; - onDecision: ( - action: PlanGddDecisionAction, - comment: string | null, - ) => Promise; - onMakeGame?: () => Promise; -}; - -const stateLabels: Record = { - not_started: '未开始', - draft: '策划中', - ready_for_approval: '待审批', - revision_requested: '待修订', - approved: '已批准', - rejected: '已退回', -}; - -function stageProgressVisible( - state: PlanGddStateViewV1 | null, - active: boolean, -) { - return Boolean(state && (active || state.state !== 'not_started')); -} - -function approvalCardVisible(state: PlanGddStateViewV1 | null) { - return Boolean( - state?.displayGdd && (state.pendingApproval || state.recoveryPending), - ); -} - -/** - * 策划区的外壳:阶段进度是它的标题栏,审批卡是它的正文。 - * - * 两者原本各画一个带边框的盒子,叠在一起时「立项策划 / 状态」在标题栏和卡头各出现 - * 一次。合成一个面之后卡头只剩游戏标题和一句话,状态与版本只在标题栏画一遍。两个 - * 挂载点都从这里进,不再各自拼装。 - */ -export function PlanGddSurface({ - state, - active = false, - projectPath, - hydrateBusy, - decisionBusy, - error, - onRefresh, - onDecision, - onMakeGame, -}: GddApprovalCardProps & { active?: boolean; projectPath: string }) { - const showProgress = stageProgressVisible(state, active); - const showCard = approvalCardVisible(state); - if (!showProgress && !showCard) { - return null; - } - return ( -
    - {showProgress ? ( - - ) : null} - {showCard ? ( - - ) : null} -
    - ); -} - -export function PlanGddStageProgress({ - state, - active = false, - projectPath = '', - onMakeGame, -}: { - state: PlanGddStateViewV1 | null; - active?: boolean; - projectPath?: string; - onMakeGame?: () => Promise; -}) { - const [detailsOpen, setDetailsOpen] = useState(false); - const [openError, setOpenError] = useState(''); - const [opening, setOpening] = useState(false); - const [makingGame, setMakingGame] = useState(false); - const [makeGameError, setMakeGameError] = useState(''); - if (!state || !stageProgressVisible(state, active)) { - return null; - } - const latestVersion = - state.displayGdd?.version ?? - state.versions[state.versions.length - 1]?.gddRef.version ?? - null; - const answeredRounds = state.session?.clarificationRound ?? 0; - const questionLimit = state.session?.questionLimit ?? 3; - const questionLimitLabel = - questionLimit === null ? '不限' : String(questionLimit); - // `clarificationRound` 是已完成的问询数;等待回答时加一表示当前正在展示的问题, - // 避免把「已答 N 个」误显示成当前第 N 轮。旧状态没有 questionLimit 时沿用旧 UI 的 3 轮 - // 文案,V2 状态直接使用会话快照里的策略值。 - const roundLabel = state.session?.awaitingAnswerFor - ? `第 ${answeredRounds + 1} 轮 / 共 ${questionLimitLabel} 轮` - : `已完成 ${answeredRounds}/${questionLimitLabel} 轮澄清`; - const processingSeconds = (state.session?.accumulatedAgentMillis ?? 0) / 1000; - // 批准之后审批卡整张收掉,交付出口就落在这条标题栏上:GDD 的 Markdown 已经在项目 - // 里(提交时渲染、审批回执重渲染带上 approved 头),这里只是把它指出来并交给系统 - // 打开。恢复态不给出口——那时权威投影还没收敛,路径上的内容可能不是用户批的那版。 - const deliveredGdd = - state.state === 'approved' && !state.recoveryPending - ? state.displayGdd - : null; - const markdownPath = planGddMarkdownDisplayPath(projectPath); - return ( -
    -
    - 立项策划 - {stateLabels[state.state]} -
    -
    - {roundLabel} - - {latestVersion === null - ? '当前版本:草稿' - : `当前版本:v${latestVersion}`} - - {processingSeconds > 0 ? ( - {`处理耗时:${formatElapsedDuration(processingSeconds * 1000) ?? '—'}`} - ) : null} -
    - {deliveredGdd ? ( -
    - {markdownPath} -
    - - - {onMakeGame ? ( - - ) : null} -
    - {openError ? ( - - {openError} - - ) : null} - {makeGameError ? ( - - {makeGameError} - - ) : null} -
    - ) : null} - {detailsOpen && deliveredGdd ? ( - setDetailsOpen(false)} - /> - ) : null} -
    - ); -} - -const decisionStateLabels = { - confirmed: '已确认', - assumption_pending: 'Agent 推断,待确认', - default_pending: '待确认默认项', - prototype_pending: '待原型验证', -}; - -const visibleGddSections = ( - gdd: NonNullable, -) => [ - { - title: '类型与美术', - content: [ - `类型:${gdd.game.genre.primary}${gdd.game.genre.fusion ? ` / ${gdd.game.genre.fusion}` : ''}`, - `视觉:${gdd.game.artStyle.visualType}`, - `关键词:${gdd.game.artStyle.keywords.join('、')}`, - `氛围:${gdd.game.artStyle.moodAndColor}`, - `MVP 美术边界:${gdd.game.artStyle.mvpArtBoundary}`, - ].join('\n'), - }, - { - title: '游戏支柱', - content: gdd.game.pillars - .map( - (pillar) => `${pillar.name}:${pillar.playerFeel};${pillar.mechanism}`, - ) - .join('\n'), - }, - { title: '核心循环', content: gdd.game.coreLoop.join(' → ') }, - { - title: '目标用户', - content: [ - `核心用户:${gdd.game.targetUsers.coreUsers}`, - `偏好:${gdd.game.targetUsers.preferences}`, - `单局时长:${gdd.game.targetUsers.sessionLength}`, - `参考作品:${gdd.game.targetUsers.referenceGames.join('、') || '无'}`, - ].join('\n'), - }, - { - title: '平台事实', - content: [ - `运行时:${gdd.game.platformFacts.runtime}`, - `视口:${gdd.game.platformFacts.viewports.join('、')}`, - `输入:${gdd.game.platformFacts.inputs.join('、')}`, - `预览:${gdd.game.platformFacts.preview}`, - ].join('\n'), - }, - { - title: 'MVP 系统', - content: gdd.game.mvpSystems - .map( - (system) => - `${system.system}:${system.minimalFunction}\n为什么需要:${system.whyRequired}\n验证:${system.verifyMethod}`, - ) - .join('\n'), - }, - { title: '暂不纳入', content: gdd.game.outOfScope.join('、') }, - { - title: '创作者提示', - content: [ - `先做:${gdd.game.creatorTips.doFirst}`, - `暂缓:${gdd.game.creatorTips.deferForNow}`, - `验证:${gdd.game.creatorTips.howToVerify}`, - `扩展条件:${gdd.game.creatorTips.expandWhen}`, - ].join('\n'), - }, - { - title: '原型验证项', - content: gdd.prototypeValidationItems - .map( - (item) => - `${item.question}\n微型原型:${item.microPrototype}\n观察:${item.observation}\n通过标准:${item.passCriterion}`, - ) - .join('\n'), - }, -]; - -/** - * Fast GDD 的正文视图。 - * - * 审批时挂在审批卡上,批准之后审批卡收掉、改由阶段进度条那条交付行触发——同一份 - * 弹层两处共用,用户在批准前后看到的是同一个正文。 - */ -export function GddDetailsDialog({ - gdd, - onClose, -}: { - gdd: NonNullable; - onClose: () => void; -}) { - return ( -
    { - if (event.target === event.currentTarget) { - onClose(); - } - }} - > -
    -
    -

    {gdd.game.title}

    -

    {gdd.game.oneLiner}

    -
    - {visibleGddSections(gdd) - .filter((section) => section.content) - .map((section) => ( -
    - {section.title} -

    {section.content}

    -
    - ))} - - {`Fast GDD v${gdd.version} · ${gdd.fingerprint}`} - -
    - -
    -
    -
    - ); -} - -export function GddApprovalCard({ - state, - hydrateBusy, - decisionBusy, - error, - onRefresh, - onDecision, -}: GddApprovalCardProps) { - const [commentAction, setCommentAction] = useState | null>(null); - const [comment, setComment] = useState(''); - const [gddDetailsOpen, setGddDetailsOpen] = useState(false); - - if (!state?.displayGdd || !approvalCardVisible(state)) { - return null; - } - - const gdd = state.displayGdd; - const pending = state.pendingApproval; - const canDecide = Boolean( - pending && state.state === 'ready_for_approval' && !state.recoveryPending, - ); - // `hydrateBusy` only describes an in-flight background read. Once a card is rendered, the - // loaded projection remains the actionable snapshot until it explicitly reports - // `recoveryPending`; otherwise a refresh racing the first render can transiently disable an - // already-open dialog before the user has had a chance to submit it. - const decisionDisabled = !canDecide || decisionBusy; - - const submitComment = () => { - // 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮 - // 已经由 `canDecide` 门住,但弹层是打开后才可能被后台 hydrate 翻掉资格的, - // 所以提交口要自己再判一次,不能只靠按钮 disabled。 - if (decisionDisabled || !commentAction || !comment.trim()) { - return; - } - void onDecision(commentAction, comment.trim()) - .then(() => { - setCommentAction(null); - setComment(''); - }) - .catch(() => undefined); - }; - - return ( -
    -
    -

    {gdd.game.title || `Fast GDD v${gdd.version}`}

    -

    {gdd.game.oneLiner}

    -
    - - {gdd.decisions.length > 0 ? ( -
    - {gdd.decisions.map((decision) => ( -
    - {decision.topic} - {decisionStateLabels[decision.state]} - {decision.answerSummary} -
    - ))} -
    - ) : null} - - - - {state.recoveryPending ? ( -
    - 审批状态正在同步,请不要重复提交当前版本。 - -
    - ) : null} - - {error ? ( -
    - {error} -
    - ) : null} - - {pending ? ( -
    - - - -
    - ) : null} - - {commentAction ? ( -
    { - if (event.target === event.currentTarget) { - setCommentAction(null); - setComment(''); - } - }} - > -
    -

    - {commentAction === 'revise' ? '填写修改意见' : '填写退回原因'} -

    -