diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 2875c99c7..873b2655c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -112,6 +112,7 @@ pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli"; pub(crate) const AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE: &str = "project-supervisor-plan"; +pub(crate) const LEGACY_PLANNING_RETIRED_ERROR: &str = "旧版策划链路已退役,请重新创建 V2 策划会话"; pub(super) const AUTONOMOUS_GAME_BUILD_FIXED_TASK_GRAPH_STALLED_ERROR: &str = "自主构建任务图无法继续推进"; pub(crate) const AGENT_RUNTIME_PLAN_AUTONOMOUS_PROFILE_UNSUPPORTED_KIND: &str = @@ -234,6 +235,13 @@ pub(crate) fn agent_runtime_supervisor_source_is_plan(source: &str) -> bool { source.trim() == AGENT_RUNTIME_SUPERVISOR_PLAN_SOURCE } +pub(crate) fn reject_legacy_planning_source(source: &str) -> Result<(), String> { + if agent_runtime_supervisor_source_is_plan(source) { + return Err(LEGACY_PLANNING_RETIRED_ERROR.to_string()); + } + Ok(()) +} + pub(crate) fn reject_supervisor_plan_autonomous_profile( source: &str, run_profile: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 45571d8c9..cc37d37b0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -126,6 +126,7 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at( if !agent_runtime_supervisor_source_is_trusted(source) { return Err("Project Supervisor 提交 source 不受信任".to_string()); } + reject_legacy_planning_source(source)?; let run_profile = normalize_agent_runtime_run_profile(Some(run_profile))?; reject_supervisor_plan_autonomous_profile(source, &run_profile)?; if agent_runtime_supervisor_source_is_plan(source) 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 8a560b34b..6bc59608e 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 @@ -9,7 +9,9 @@ mod models; mod planning_approval; mod planning_coordinator; mod planning_hydrate; +mod planning_policy_v2; mod planning_provider_usage; +mod planning_session_v2; mod planning_storage; mod planning_submit; mod provider_control; @@ -28,7 +30,9 @@ pub(in crate::agent) use models::*; pub(crate) use planning_approval::*; pub(crate) use planning_coordinator::*; pub(crate) use planning_hydrate::*; +pub(crate) use planning_policy_v2::*; pub(crate) use planning_provider_usage::*; +pub(crate) use planning_session_v2::*; pub(crate) use planning_storage::*; pub(crate) use planning_submit::*; pub(in crate::agent) use provider_control::*; 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 new file mode 100644 index 000000000..ced67a86f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs @@ -0,0 +1,2015 @@ +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(()) +} + +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.decision_id == input.decision_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( + 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 new file mode 100644 index 000000000..98d366271 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs @@ -0,0 +1,1615 @@ +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 has_successful_assistant_for_turn(messages: &[PlanningMessageV2], turn_index: u64) -> bool { + messages.iter().any(|message| { + message.turn_index == turn_index + && message.role == "assistant" + && message.kind != "error" + && message_text(message).is_some_and(|text| !text.trim().is_empty()) + }) +} + +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, +) -> 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 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"); + 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) + .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 { + run_planning_session_v2_command( + &app, + project_path, + Some(session_id), + client_turn_id, + normalize_planning_input(input)?, + None, + false, + ) + .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, +) -> 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)?; + if let Some(expected) = expected_session_id.as_deref() { + let actual = read_planning_session_v2(&root)? + .ok_or_else(|| "Planning V2 Session 不存在".to_string())?; + if actual.session_id != expected.trim() { + return Err("Planning V2 Session ID 不匹配".to_string()); + } + } + let start = prepare_turn_v2(&root, &client_turn_id, &prompt, mode.as_deref(), is_start)?; + 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) fn hydrate_planning_session_v2( + project_path: String, + session_id: Option, +) -> Result, String> { + let root = PathBuf::from(project_path.trim()); + enforce_project_permission_policy(&root, "conversation.read")?; + // GUI 在审批落盘后会立刻重灌。短窗口等过瞬时争用;下一拍轮询还会再跑, + // 不能占满完整写锁等待把面板卡住。 + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_short_wait( + &root, + "planning.v2.hydrate", + )?; + let Some(mut session) = read_planning_session_v2(&root)? else { + return Ok(None); + }; + let project_id = read_project_id_v2(&root)?; + if 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 session.session_id != session_id { + return Err("Planning V2 Session ID 不匹配".to_string()); + } + } + if session.status == "planning" && !planning_v2_is_active(&session.project_id) { + let messages = read_planning_messages_v2(&root)?; + if has_successful_assistant_for_turn(&messages, session.turn_index) { + session.status = "awaiting_user".to_string(); + 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 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"); + }) + } + + 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) + } + + #[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) + .expect("修订续跑必须等过瞬时锁争用,而不是把失败甩回总控"); + holder.join().expect("lock holder thread"); + assert_eq!(start.session.status, "planning"); + assert!(start.replay.is_none()); + } + + #[test] + fn hydrate_rides_out_a_briefly_held_project_lock() { + let (_dir, root, session) = v2_revision_session_fixture(); + let holder = hold_project_lock_briefly(&root, 120); + let hydrated = hydrate_planning_session_v2( + root.to_string_lossy().to_string(), + Some(session.session_id.clone()), + ) + .expect("hydrate 必须等过瞬时锁争用") + .expect("session"); + holder.join().expect("lock holder thread"); + assert_eq!(hydrated.session.session_id, session.session_id); + assert_eq!(hydrated.session.status, "revision_requested"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index 1d6e98691..c3d7fb40c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -118,7 +118,7 @@ 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 = 400; +pub(crate) const PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS: usize = 800; /// `initial-request` 那条决定的 answerSummary 上限,也就是立项策划入口原始需求的上限。 /// @@ -658,11 +658,11 @@ fn validate_plan_platform_facts(value: &PlanPlatformFacts) -> Result<(), Plannin Ok(()) } -fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { +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, 40)?; + validate_text(&game.genre.primary, "game.genre.primary", 1, 80)?; if let Some(fusion) = &game.genre.fusion { - validate_text(fusion, "game.genre.fusion", 1, 40)?; + validate_text(fusion, "game.genre.fusion", 1, 80)?; } validate_text( &game.art_style.visual_type, @@ -670,50 +670,44 @@ fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { 1, 80, )?; - if !(3..=5).contains(&game.art_style.keywords.len()) { - return Err(invalid("game.artStyle.keywords 必须有 3~5 项")); + if !(1..=6).contains(&game.art_style.keywords.len()) { + return Err(invalid("game.artStyle.keywords 必须有 1~6 项")); } - validate_unique( - game.art_style.keywords.iter().map(String::as_str), - "game.artStyle.keywords", - )?; for (index, keyword) in game.art_style.keywords.iter().enumerate() { - validate_text(keyword, &format!("game.artStyle.keywords[{index}]"), 1, 32)?; + validate_text(keyword, &format!("game.artStyle.keywords[{index}]"), 1, 64)?; } validate_text( &game.art_style.mood_and_color, "game.artStyle.moodAndColor", 1, - 400, + 1000, )?; validate_text( &game.art_style.mvp_art_boundary, "game.artStyle.mvpArtBoundary", 1, - 400, + 1200, )?; - validate_text(&game.one_liner, "game.oneLiner", 45, 90)?; + // 设计约定:Runtime 接受范围为 10~160,模型 schema 提示范围收紧为 25~90; + // 两者有意不对称,用于避免过短概念同时保留对已有/人工 GDD 的兼容,不是缺陷或 bug。 + validate_text(&game.one_liner, "game.oneLiner", 10, 160)?; - if !(2..=4).contains(&game.pillars.len()) { - return Err(invalid("game.pillars 必须有 2~4 条")); + if !(1..=6).contains(&game.pillars.len()) { + return Err(invalid("game.pillars 必须有 1~6 条")); } - validate_unique( - game.pillars.iter().map(|item| item.name.as_str()), - "game.pillars.name", - )?; for (index, pillar) in game.pillars.iter().enumerate() { - validate_text(&pillar.name, &format!("game.pillars[{index}].name"), 1, 40)?; + validate_text(&pillar.name, &format!("game.pillars[{index}].name"), 1, 80)?; validate_text( &pillar.player_feel, &format!("game.pillars[{index}].playerFeel"), 1, - 240, + 400, )?; validate_text( &pillar.mechanism, &format!("game.pillars[{index}].mechanism"), 1, - 240, + 400, )?; validate_decision_state(&pillar.decision_state)?; if pillar.basis.is_some() { @@ -721,33 +715,33 @@ fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { } } - if !(4..=8).contains(&game.core_loop.len()) { - return Err(invalid("game.coreLoop 必须有 4~8 步")); + 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, 120)?; + validate_text(step, &format!("game.coreLoop[{index}]"), 1, 400)?; } validate_text( &game.target_users.core_users, "game.targetUsers.coreUsers", 1, - 240, + 400, )?; validate_text( &game.target_users.preferences, "game.targetUsers.preferences", 1, - 240, + 400, )?; validate_text( &game.target_users.session_length, "game.targetUsers.sessionLength", 1, - 240, + 400, )?; - if game.target_users.reference_games.len() > 5 { - return Err(invalid("game.targetUsers.referenceGames 最多 5 项")); + 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( @@ -759,50 +753,42 @@ fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { } validate_plan_platform_facts(&game.platform_facts)?; - if !(3..=6).contains(&game.mvp_systems.len()) { - return Err(invalid("game.mvpSystems 必须有 3~6 项")); + if !(1..=8).contains(&game.mvp_systems.len()) { + return Err(invalid("game.mvpSystems 必须有 1~8 项")); } - validate_unique( - game.mvp_systems.iter().map(|item| item.system.as_str()), - "game.mvpSystems.system", - )?; for (index, system) in game.mvp_systems.iter().enumerate() { validate_text( &system.system, &format!("game.mvpSystems[{index}].system"), 1, - 40, + 80, )?; validate_text( &system.minimal_function, &format!("game.mvpSystems[{index}].minimalFunction"), 1, - 240, + 400, )?; validate_text( &system.why_required, &format!("game.mvpSystems[{index}].whyRequired"), 1, - 240, + 400, )?; validate_text( &system.verify_method, &format!("game.mvpSystems[{index}].verifyMethod"), 1, - 240, + 400, )?; validate_decision_state(&system.decision_state)?; if system.basis.is_some() { return Err(invalid("v1 的 mvpSystem.basis 必须为 null")); } } - if !(1..=12).contains(&game.out_of_scope.len()) { - return Err(invalid("game.outOfScope 必须有 1~12 项")); + if game.out_of_scope.len() > 12 { + return Err(invalid("game.outOfScope 必须有 0~12 项")); } - validate_unique( - game.out_of_scope.iter().map(String::as_str), - "game.outOfScope", - )?; for (index, item) in game.out_of_scope.iter().enumerate() { validate_text(item, &format!("game.outOfScope[{index}]"), 1, 80)?; } @@ -810,25 +796,25 @@ fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { &game.creator_tips.do_first, "game.creatorTips.doFirst", 1, - 400, + 1000, )?; validate_text( &game.creator_tips.defer_for_now, "game.creatorTips.deferForNow", 1, - 400, + 1000, )?; validate_text( &game.creator_tips.how_to_verify, "game.creatorTips.howToVerify", 1, - 400, + 1000, )?; validate_text( &game.creator_tips.expand_when, "game.creatorTips.expandWhen", 1, - 400, + 1000, )?; Ok(()) } @@ -932,8 +918,8 @@ fn validate_decisions( prototype_decisions.insert(decision.id.as_str()); } } - if prototype_items.len() > 3 { - return Err(invalid("prototypeValidationItems 最多 3 项")); + if prototype_items.len() > 16 { + return Err(invalid("prototypeValidationItems 最多 16 项")); } validate_unique( prototype_items.iter().map(|item| item.id.as_str()), @@ -1952,7 +1938,7 @@ fn validate_plan_session_shape(value: &PlanSessionV1) -> Result<(), PlanningStor &decision.topic, &format!("session.decisionsSummary[{index}].topic"), 1, - 80, + 400, )?; validate_decision_state(&decision.state)?; validate_answer_source(&decision.answer_source)?; 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 2f564e863..4587f4545 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1390,6 +1390,7 @@ pub(crate) fn start_game_creator_supervisor_runtime_task( if !agent_runtime_supervisor_source_is_trusted(source) { return Err("Project Supervisor 提交 source 不受信任".to_string()); } + reject_legacy_planning_source(source)?; reject_supervisor_plan_autonomous_profile(source, run_profile)?; if agent_runtime_supervisor_source_is_plan(source) && !crate::config::game_creator_planning_capability_enabled()? diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 8fbfff37b..1491e0cd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -1417,24 +1417,15 @@ pub(crate) fn harden_new_game_creator_private_path( path.display() )); } - // A newly-created object normally inherits the creator's security - // descriptor. AGC-owned roots may request the one-shot UAC repair; - // a user-selected path is still hardened strictly after creation so - // a race cannot turn an attacker-owned object into a credential file. - if game_creator_private_path_allows_auto_elevation(path) { - secure_windows_game_creator_path_for_current_user_with_auto_elevation( - path, - is_directory, - true, - )?; - } else { - secure_windows_game_creator_path_for_current_user_with_owner_policy( - path, - is_directory, - true, - true, - )?; - } + // This invocation created the object, so its owner is the current + // user. Tighten the inherited descriptor in-process; UAC repair is + // reserved for existing, externally-owned objects. + secure_windows_game_creator_path_for_current_user_with_owner_policy( + path, + is_directory, + true, + true, + )?; } #[cfg(unix)] { @@ -2588,6 +2579,34 @@ pub(crate) fn consume_windows_acl_repair_authorization( Ok(()) } +#[cfg(windows)] +fn windows_command_line_quote(value: &str) -> String { + format!("\"{}\"", value.replace('"', "\\\"")) +} + +#[cfg(windows)] +fn windows_acl_repair_argument_list( + path: &str, + target_user_sid: &str, + nonce: &str, + scope: WindowsAclRepairScope, +) -> String { + [ + "--repair-private-acl", + path, + "--target-user-sid", + target_user_sid, + "--authorization", + nonce, + "--scope", + scope.wire_name(), + ] + .into_iter() + .map(windows_command_line_quote) + .collect::>() + .join(" ") +} + /// Starts a one-shot elevated copy of the current executable. The elevated /// process performs only the allow-listed ACL repair command and exits with a /// truthful status; UAC cancellation is never treated as success. @@ -2612,12 +2631,15 @@ fn attempt_elevated_windows_acl_repair( let repair_path = windows_acl_repair_target(path, scope); let nonce = create_windows_acl_repair_authorization(&repair_path, target_user_sid, scope)?; let escaped_executable = executable.to_string_lossy().replace('\'', "''"); - let escaped_path = repair_path.to_string_lossy().replace('\'', "''"); - let escaped_target_user_sid = target_user_sid.replace('\'', "''"); - let escaped_nonce = nonce.replace('\'', "''"); + let arguments = windows_acl_repair_argument_list( + &repair_path.to_string_lossy(), + target_user_sid, + &nonce, + scope, + ) + .replace('\'', "''"); let script = format!( - "$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList @('--repair-private-acl','{escaped_path}','--target-user-sid','{escaped_target_user_sid}','--authorization','{escaped_nonce}','--scope','{}'); if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}", - scope.wire_name() + "$ErrorActionPreference = 'Stop'; try {{ $p = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{escaped_executable}' -ArgumentList '{arguments}'; if ($null -eq $p) {{ exit 1223 }}; exit $p.ExitCode }} catch {{ exit 1223 }}" ); use std::os::windows::process::CommandExt; let status = std::process::Command::new("powershell.exe") @@ -4321,6 +4343,24 @@ mod private_path_elevation_policy_tests { assert!(windows_acl_error_may_need_elevation(detail)); } + #[cfg(windows)] + #[test] + fn acl_repair_argument_list_keeps_space_containing_path_quoted() { + let path = r"C:\Users\lingh\Documents\Genarrative GameAgent\gameagent-f84a5353\.agent\project.lock"; + let arguments = windows_acl_repair_argument_list( + path, + "S-1-5-21-1-2-3-1001", + "0123456789abcdef0123456789abcdef", + WindowsAclRepairScope::Managed, + ); + assert_eq!( + arguments, + format!( + "\"--repair-private-acl\" \"{path}\" \"--target-user-sid\" \"S-1-5-21-1-2-3-1001\" \"--authorization\" \"0123456789abcdef0123456789abcdef\" \"--scope\" \"managed\"" + ) + ); + } + #[cfg(windows)] #[test] fn custom_runtime_config_path_uses_explicit_user_selected_scope() { 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 90ffd9333..9ff2e207f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2510,6 +2510,10 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, chat_with_game_creator_direct_codex, + start_planning_session_v2, + continue_planning_session_v2, + decide_planning_artifact_v2, + hydrate_planning_session_v2, start_game_creator_agent_runtime_task, start_game_creator_supervisor_runtime_task, compact_game_creator_agent_runtime_context, 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 062a74e3c..ca1a45b15 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 @@ -178,6 +178,28 @@ fn project_write_lock_treats_windows_target_races_as_contention() { } } +#[cfg(all(test, windows))] +#[test] +fn project_write_lock_hardens_space_containing_path_in_process() { + let parent = tempfile::tempdir().expect("create spaced lock parent"); + let root = parent + .path() + .join("Genarrative GameAgent") + .join("gameagent-space"); + fs::create_dir_all(&root).expect("create spaced project root"); + let lock = acquire_project_write_lock(&root, "planning.v2.approval") + .expect("acquire project lock under a space-containing path"); + let lock_path = root.join(".agent").join("project.lock"); + assert!(lock_path.is_file(), "project lock must exist while held"); + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false) + .expect("new project lock must already satisfy the private DACL contract"); + drop(lock); + assert!( + !lock_path.exists(), + "project lock must be removed when the guard is dropped" + ); +} + fn resolve_project_write_lock_path(root: &Path) -> Result { let normalized = normalize_relative_path(PROJECT_WRITE_LOCK_PATH)?; let (parent_relative, file_name) = normalized @@ -222,17 +244,33 @@ pub(crate) fn acquire_project_write_lock( } match options.open(&path) { Ok(mut file) => { - if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") - { - drop(file); - let _ = fs::remove_file(&path); - return Err(error); - } if let Err(error) = file.write_all(content.as_bytes()) { + drop(file); let _ = fs::remove_file(&path); return Err(format!("写入项目写锁失败:{}: {error}", path.display())); } - prepare_game_creator_private_path_for_read(&path, false, "项目写锁")?; + if let Err(error) = file.sync_all() { + drop(file); + let _ = fs::remove_file(&path); + return Err(format!("落盘项目写锁失败:{}: {error}", path.display())); + } + drop(file); + if let Err(error) = harden_new_game_creator_private_path(&path, false, "项目写锁") + { + let _ = fs::remove_file(&path); + return Err(error); + } + let actual = match fs::read_to_string(&path) { + Ok(actual) => actual, + Err(error) => { + let _ = fs::remove_file(&path); + return Err(format!("读取项目写锁失败:{}: {error}", path.display())); + } + }; + if actual != content { + let _ = fs::remove_file(&path); + return Err(format!("项目写锁内容校验失败:{}", path.display())); + } return Ok(ProjectWriteLock { path, content: content.clone(), @@ -305,6 +343,7 @@ 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; } @@ -408,10 +447,11 @@ pub(crate) fn reject_agent_runtime_private_control_path( /// 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 { - normalized_path.eq_ignore_ascii_case(".agent/planning") - || normalized_path - .to_ascii_lowercase() - .starts_with(".agent/planning/") + 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 { @@ -424,7 +464,7 @@ pub(crate) fn reject_agent_planning_storage_write_path( ) -> Result<(), String> { if is_agent_planning_managed_write_path(normalized_path) { return Err( - "`.agent/planning/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝" + "`.agent/planning/**`、`.agent/planning-v2/**` 与 `game/fast_gdd.md` 只能由立项策划 Runtime 专用存储层写入,通用文件写入被拒绝" .to_string(), ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs index 1ce915e4e..8571c7133 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/tool_planning.rs @@ -1260,25 +1260,19 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { assert!(plan_request.contains("后台分析当前玩法循环")); assert!(!plan_request.contains("核心循环:收集月光食材并躲避暗影")); assert!(!plan_request.contains("黑板:必须先确认核心循环")); - let final_request = receiver - .recv_timeout(Duration::from_secs(2)) - .expect("final reply llm request"); + let final_request = wait_for_captured_mock_request(&receiver, "final reply llm request").await; assert!(final_request.contains("工具观察")); assert!(final_request.contains("核心循环:收集月光食材并躲避暗影")); assert!(final_request.contains("黑板:必须先确认核心循环")); - let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") - .expect("read runtime") - .state; - for _ in 0..250 { - if runtime.status == "idle" { - break; - } - std::thread::sleep(Duration::from_millis(20)); - runtime = read_game_creator_agent_runtime_at(&root, "design-director") - .expect("read runtime") - .state; - } + let runtime = wait_for_agent_runtime_terminal_and_lane_release( + &root, + "design-director", + "design-loop-run", + "idle", + "completed", + ) + .state; assert_eq!(runtime.status, "idle"); assert_eq!(runtime.phase, "completed"); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9f53b0868..9dbff41bb 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -226,6 +226,15 @@ import { } 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, @@ -511,6 +520,9 @@ export function App({ onMakeGameFromApprovedGdd, }: AppProps = {}) { const { setTitle: setWindowTitle } = useWindowChrome(); + const [planningV2Active, setPlanningV2Active] = useState(planningStartMode); + const planningV2ActiveRef = useRef(planningStartMode); + planningV2ActiveRef.current = planningV2Active; // 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于 // Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。 // 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。 @@ -518,7 +530,8 @@ export function App({ DIRECT_CODEX_PRODUCT_RUNTIME && projectSupervisorOnly && !supervisorChatOnly && - !planningStartMode; + !planningStartMode && + !planningV2Active; const [devMode] = useState(() => projectSupervisorOnly ? false : isDeveloperMode(), ); @@ -678,6 +691,17 @@ export function App({ ); const planGddStateRef = useRef(null); planGddStateRef.current = planGddState; + const [planningV2Session, setPlanningV2Session] = + useState(null); + const planningV2SessionRef = useRef( + null, + ); + planningV2SessionRef.current = planningV2Session; + const [planningV2TransientReply, setPlanningV2TransientReply] = useState(''); + const planningV2TurnRef = useRef<{ + projectPath: string; + clientTurnId: string; + } | null>(null); const planGddHydrateSequenceRef = useRef(0); const [planGddHydrateBusy, setPlanGddHydrateBusy] = useState(false); const [planGddDecisionBusy, setPlanGddDecisionBusy] = useState(false); @@ -693,6 +717,98 @@ export function App({ >(), ); + 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) { + const conversationMessages = planningMessagesToChatMessages( + result.conversation, + ); + 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) { + 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 (!result) { + if (planningStartMode) { + planningV2ActiveRef.current = true; + setPlanningV2Active(true); + } + return null; + } + applyPlanningV2CommandResult(result); + return result; + } + const hydratePlanGddState = useCallback( async (nextProjectPath?: string) => { const targetProjectPath = @@ -702,6 +818,49 @@ export function App({ setPlanGddState(null); return; } + if ( + projectSupervisorOnly && + !planningV2ActiveRef.current && + !planningStartMode + ) { + try { + const existingV2 = + await invoke( + 'hydrate_planning_session_v2', + { projectPath: targetProjectPath }, + ); + if (existingV2) { + applyPlanningV2CommandResult(existingV2); + return; + } + } catch { + // Fall through to the legacy read for projects without V2 authority. + } + } + if (planningV2ActiveRef.current || planningStartMode) { + const requestSequence = ++planGddHydrateSequenceRef.current; + setPlanGddHydrateBusy(true); + setPlanGddError(null); + try { + await hydratePlanningV2Session(targetProjectPath); + } catch (error) { + // 与旧 hydrate 相同:项目写锁争用是瞬时的。V2 审批修改后会立刻续跑并 + // 重灌,下一拍还能拿到;把占用画进错误位会让刚提交的修改意见看起来失败。 + const transientContention = + String(error).includes('项目正在被其他写操作占用:'); + if ( + !transientContention && + requestSequence === planGddHydrateSequenceRef.current + ) { + setPlanGddError(String(error)); + } + } finally { + if (requestSequence === planGddHydrateSequenceRef.current) { + setPlanGddHydrateBusy(false); + } + } + return; + } const requestSequence = ++planGddHydrateSequenceRef.current; setPlanGddHydrateBusy(true); setPlanGddError(null); @@ -736,6 +895,9 @@ export function App({ } } }, + // 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], ); @@ -771,6 +933,40 @@ export function App({ setPlanGddDecisionBusy(true); setPlanGddError(null); try { + if (planningV2ActiveRef.current || planningStartMode) { + const planningSessionId = current.session?.sessionId; + if (!planningSessionId) { + throw new Error('Planning V2 Session 不存在'); + } + const approval = await invoke( + 'decide_planning_artifact_v2', + { + projectPath: targetProjectPath, + sessionId: planningSessionId, + artifactId: pending.gddRef.gddId, + version: pending.gddRef.version, + fingerprint: pending.gddRef.fingerprint, + decisionId: responseId, + action, + comment, + }, + ); + applyPlanningV2CommandResult({ + session: approval.session, + result: null, + currentArtifact: approval.currentArtifact, + replayed: approval.replayed, + }); + setPlanGddError(null); + if (action === 'revise' && comment?.trim()) { + await executePlanningV2Turn( + targetProjectPath, + `审批:revise ${comment.trim()}`, + `planning-v2-revision-${crypto.randomUUID()}`, + ); + } + return; + } await invokeDiagnostic(invoke, 'decide_game_creator_plan_gdd', { projectPath: targetProjectPath, gddId: pending.gddRef.gddId, @@ -796,6 +992,8 @@ export function App({ setPlanGddDecisionBusy(false); } }, + // V2 decision handling also reads the current session through refs. + // eslint-disable-next-line react-hooks/exhaustive-deps [hydratePlanGddState, localProject, projectPath], ); @@ -1152,12 +1350,18 @@ 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); + setPlanningV2TransientReply(''); + setPlanningV2Active(planningStartMode); + planningV2ActiveRef.current = planningStartMode; } function syncTerminalProjectSupervisorConversation( @@ -1251,7 +1455,10 @@ export function App({ } localProjectPathRef.current = initialProjectPath; void loadProjectConversation(initialProjectPath).finally(() => { - if (localProjectPathRef.current === initialProjectPath) { + if ( + localProjectPathRef.current === initialProjectPath && + !planningStartMode + ) { void refreshAgentRunTrace(initialProjectPath); } }); @@ -1412,7 +1619,7 @@ export function App({ useEffect(() => { const listen = window.__TAURI__?.event?.listen; const invoke = resolveTauriInvoke(); - if (!listen || directCodexProductRuntime) { + if (!listen || directCodexProductRuntime || planningV2Active) { return; } let cleanup: (() => void) | null = null; @@ -1514,11 +1721,69 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ directCodexProductRuntime, + planningV2Active, refreshManifest, updateProjectSupervisorResponseStream, updateProjectSupervisorRuntime, ]); + useEffect(() => { + const listen = window.__TAURI__?.event?.listen; + if (!listen || !planningV2Active) { + return; + } + let cleanup: (() => void) | null = null; + let disposed = false; + void listen( + 'planning-session-v2-stream', + (event) => { + const payload = event.payload; + const trackedTurn = planningV2TurnRef.current; + if (!trackedTurn || payload.clientTurnId !== trackedTurn.clientTurnId) { + return; + } + if (payload.status === 'started') { + setPlanningV2TransientReply( + payload.accumulatedText || '正在生成策划方案…', + ); + setProjectSupervisorRuntimeError(''); + } + if ( + payload.status === 'delta' && + (payload.accumulatedText || payload.deltaText) + ) { + setPlanningV2TransientReply( + 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 listen = window.__TAURI__?.event?.listen; if (!listen) { @@ -1560,6 +1825,7 @@ export function App({ if ( !invoke || directCodexProductRuntime || + planningV2Active || !nextProjectPath || !sessionId || !trackedRunId || @@ -1655,6 +1921,7 @@ export function App({ }, [ localProject?.projectPath, directCodexProductRuntime, + planningV2Active, projectSupervisorSessionId, projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, @@ -1669,6 +1936,7 @@ export function App({ if ( !projectSupervisorOnly || directCodexProductRuntime || + planningV2Active || !invoke || !nextProjectPath || !supervisorRunId @@ -1749,6 +2017,7 @@ export function App({ }, [ localProject?.projectPath, directCodexProductRuntime, + planningV2Active, projectSupervisorOnly, projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, @@ -1758,7 +2027,10 @@ export function App({ useLayoutEffect(() => { if ( (!supervisorChatOnly && - !(projectSupervisorOnly && directCodexProductRuntime)) || + !( + projectSupervisorOnly && + (directCodexProductRuntime || planningV2Active) + )) || !supervisorChatShouldFollowLatestRef.current ) { return; @@ -1777,6 +2049,7 @@ export function App({ directCodexTransientReply, directCodexTransientReplyUpdatedAt, directCodexProductRuntime, + planningV2Active, projectSupervisorOnly, supervisorChatOnly, ]); @@ -2723,6 +2996,57 @@ 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) { + 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) { + applyPlanningV2CommandResult(planningV2); + } else { + planningV2SessionRef.current = null; + setPlanningV2Session(null); + setPlanGddState(null); + projectSupervisorSessionIdRef.current = null; + setProjectSupervisorSessionId(null); + updateProjectSupervisorRuntime(null); + setMessages(createDefaultChatMessages()); + savedConversationProjectPathRef.current = nextProjectPath; + savedConversationCountRef.current = 0; + latestMessagesRef.current = []; + } + setProjectSupervisorRuntimeError( + planningV2?.session.lastError?.summary ?? '', + ); + setWorkspaceStatus((workspaceStatus) => + workspaceStatus === '等待确认' + ? `已打开:${nextProjectPath}` + : workspaceStatus, + ); + return; + } const resumeError = directCodexProductRuntime ? '' : await resumeProjectSupervisorRuntimeTasksIfNeeded( @@ -5445,12 +5769,101 @@ export function App({ } } + async function executePlanningV2Turn( + nextProjectPath: string, + prompt: string, + clientTurnId = createAgentChatRunId('planning-v2-turn'), + ) { + 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(''); + setPlanningV2TransientReply(''); + try { + const result = currentSessionId + ? await invoke( + 'continue_planning_session_v2', + { + projectPath: nextProjectPath, + sessionId: currentSessionId, + clientTurnId, + input: { text: normalizedPrompt }, + }, + ) + : 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; + setPlanningV2TransientReply(''); + setChatAgentBusy(false); + } + } + async function executeChatAgentReply({ prompt, clientTurnId: directConversationTurnId, creationType, attachments, }: ExecuteChatAgentReplyInput) { + if (planningV2ActiveRef.current || planningStartMode) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { + setProjectSupervisorRuntimeError('请先初始化本地项目'); + return; + } + await executePlanningV2Turn( + nextProjectPath, + prompt, + directConversationTurnId ?? createAgentChatRunId('planning-v2-turn'), + ); + return; + } // Product default: send the conversation directly to Codex app-server. // The legacy Supervisor/harness path remains below for rollback and tests. if (directCodexProductRuntime) { @@ -6345,6 +6758,37 @@ 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); + return; + } const runtime = projectSupervisorRuntimeRef.current; const sessionId = projectSupervisorSessionIdRef.current; const currentRequest = runtime?.userInputRequest; @@ -10939,6 +11383,26 @@ 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) { supervisorChatShouldFollowLatestRef.current = true; } @@ -11002,9 +11466,11 @@ export function App({ runtimeConfigOpen={runtimeConfigOpen} runtimeError={projectSupervisorRuntimeError} transientReply={ - directCodexProductRuntime - ? directCodexTransientReply - : projectSupervisorTransientReply + planningV2Active + ? planningV2TransientReply + : directCodexProductRuntime + ? directCodexTransientReply + : projectSupervisorTransientReply } hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} @@ -11045,9 +11511,11 @@ export function App({ pendingCommand={directCodexProductRuntime ? pendingCommand : null} projectPath={localProject?.projectPath ?? projectPath} transientReply={ - directCodexProductRuntime - ? directCodexTransientReply - : projectSupervisorTransientReply + planningV2Active + ? planningV2TransientReply + : directCodexProductRuntime + ? directCodexTransientReply + : projectSupervisorTransientReply } visibleMessages={visibleMessages} visibleProfessionalAgentCards={visibleProfessionalAgentCards} @@ -11061,6 +11529,7 @@ export function App({ planGddError={planGddError} onPlanGddRefresh={() => void hydratePlanGddState()} onPlanGddDecision={decidePlanGdd} + planningLane={planningV2Active} onMakeGameFromApprovedGdd={ onMakeGameFromApprovedGdd ? () => diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 493fdb748..6e1ba555c 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -185,6 +185,7 @@ export interface PlanGddStateViewV1 { | 'rejected' | 'recovery_required'; clarificationRound: number; + questionLimit?: number | null; repairDepth: number; accumulatedAgentMillis: number; activeRunId: string | null; @@ -197,6 +198,7 @@ export interface PlanGddStateViewV1 { decisionStateCounts: { confirmed: number; defaultPending: number; + assumptionPending?: number; prototypePending: number; }; } | null; @@ -282,11 +284,16 @@ export interface PlanGddStateViewV1 { decisions: Array<{ id: string; topic: string; - state: 'confirmed' | 'default_pending' | 'prototype_pending'; + state: + | 'confirmed' + | 'assumption_pending' + | 'default_pending' + | 'prototype_pending'; answerSource: | 'user_option' | 'user_freeform' | 'user_revision' + | 'agent_inferred' | 'default'; round: number; answerSummary: 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 d8df6bc98..abc2b4ed9 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 @@ -2099,6 +2099,13 @@ 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/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index da4bbd7b2..99ec9ac5d 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 @@ -574,7 +574,9 @@ export function useHomeProjectCreation({ startMode: ProjectStartMode, ) { return createHomeDraftAutomaticallyWithOptions(draft, startMode, { - suggestName: true, + // 做方案的首轮还要调用一次策划 Provider;项目名称不是策划输入的 + // 前置条件,避免在进入工作区前再额外等待一次模型请求。 + suggestName: startMode !== 'planning', }); } 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 index 94cfb07e0..5deaaae93 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -141,14 +141,16 @@ export function PlanGddStageProgress({ state.versions[state.versions.length - 1]?.gddRef.version ?? null; const answeredRounds = state.session?.clarificationRound ?? 0; - // `clarificationRound` 是 0-indexed 的「已答轮数」:`static_delegate_lineage_counters` - // 排除目标自身、只数祖先里的澄清跳数,后端判上限用的也是 `current_round + 1`。所以直接 - // 按「轮次 X/3」渲染会整体差一格——问最后一轮时显示「轮次 2/3」,字面暗示还剩一轮。 - // 等待回答时 `latestDelegationId` 就是当前那条 delivery,+1 恰好是正在问的轮次;其余 - // 状态(含 `awaitingAnswerFor` 为 null 的恢复态)退回「已完成」表述,不去猜当前轮。 + 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} 轮 / 共 3 轮` - : `已完成 ${answeredRounds}/3 轮澄清`; + ? `第 ${answeredRounds + 1} 轮 / 共 ${questionLimitLabel} 轮` + : `已完成 ${answeredRounds}/${questionLimitLabel} 轮澄清`; + const processingSeconds = (state.session?.accumulatedAgentMillis ?? 0) / 1000; // 批准之后审批卡整张收掉,交付出口就落在这条标题栏上:GDD 的 Markdown 已经在项目 // 里(提交时渲染、审批回执重渲染带上 approved 头),这里只是把它指出来并交给系统 // 打开。恢复态不给出口——那时权威投影还没收敛,路径上的内容可能不是用户批的那版。 @@ -170,6 +172,9 @@ export function PlanGddStageProgress({ ? '当前版本:草稿' : `当前版本:v${latestVersion}`} + {processingSeconds > 0 ? ( + {`处理耗时:${processingSeconds.toFixed(1)} 秒`} + ) : null} {deliveredGdd ? (
) : null} - {userInputRequest ? ( + {userInputRequest && !controlBusy ? ( void; onPlanGddDecision: ( action: PlanGddDecisionAction, @@ -124,11 +125,14 @@ export function ProjectSupervisorView({ planGddHydrateBusy, planGddDecisionBusy, planGddError, + planningLane = false, onPlanGddRefresh, onPlanGddDecision, onMakeGameFromApprovedGdd, ...runtimePanelProps }: ProjectSupervisorViewProps) { + const planningSurfaceActive = + planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime); const [expandedProcessKey, setExpandedProcessKey] = useState( null, ); @@ -137,7 +141,6 @@ export function ProjectSupervisorView({ }, [directProcessKey]); const processDetailExpanded = Boolean(directProcessKey) && expandedProcessKey === directProcessKey; - const submitLabel = needsUserInput ? '等待回答' : runtimePanelProps.controlBusy @@ -153,7 +156,7 @@ export function ProjectSupervisorView({
) : null}
- {directCodex ? null : isPlanningLaneRuntime( - runtimePanelProps.runtime, - ) ? ( + {directCodex ? null : planningSurfaceActive ? ( ; +}; + +export type PlanningSessionV2 = { + schemaVersion: 'planning-session.v2'; + engine: 'planning-session-v2'; + sessionId: string; + projectId: string; + mode: string; + status: string; + turnIndex: number; + questionCount: number; + questionLimit: number | null; + revisionCount: number; + currentArtifactVersion: number | null; + currentQuestion: PlanningQuestionV2 | null; + capabilities: { tools: string[]; skills: string[] }; + processingSeconds: number; + createdAtUtc: string; + updatedAtUtc: string; + lastError: { code: string; summary: string } | null; +}; + +export type PlanningMessageV2 = { + schemaVersion: 'planning-message.v2'; + messageId: string; + clientTurnId: string; + turnIndex: number; + atUtc: string; + role: 'user' | 'assistant' | 'system' | 'tool'; + kind: string; + payload: Record; +}; + +export type PlanningTurnResultV2 = { + schemaVersion: 'planning-turn-result.v2'; + kind: 'question' | 'artifact' | 'assistant_text' | 'error' | string; + payload: Record; +}; + +export type PlanningGddPayloadV2 = { + schemaVersion: string; + projectId: string; + gddId: string; + version: number; + createdAtUtc: 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' | 'prototype_pending'; + answerSource: + | 'user_option' + | 'user_freeform' + | 'user_revision' + | 'agent_inferred'; + round: number; + answerSummary: string; + basis: null; + }>; + prototypeValidationItems: Array<{ + id: string; + question: string; + microPrototype: string; + observation: string; + passCriterion: string; + }>; + fingerprint: string; +}; + +export type PlanningArtifactV2 = { + artifactId: string; + kind: 'gdd' | string; + version: number; + status: string; + fingerprint: string; + payload: PlanningGddPayloadV2; +}; + +export type PlanningSessionCommandResultV2 = { + session: PlanningSessionV2; + result: PlanningTurnResultV2 | null; + currentArtifact: PlanningArtifactV2 | null; + replayed: boolean; + conversation?: PlanningMessageV2[]; +}; + +export type PlanningApprovalCommandResultV2 = { + approval: { + schemaVersion: string; + projectId: string; + sessionId: string; + artifactId: string; + version: number; + fingerprint: string; + decisionId: string; + action: PlanGddDecisionAction; + comment: string | null; + decidedAtUtc: string; + receiptFingerprint: string; + }; + session: PlanningSessionV2; + currentArtifact: PlanningArtifactV2 | null; + replayed: boolean; +}; + +export type PlanningSessionStreamEventV2 = { + sessionId: string; + clientTurnId: string; + status: 'started' | 'delta' | 'completed' | 'failed' | string; + deltaText: string; + accumulatedText: string; + finishReason: string | null; + result: PlanningTurnResultV2 | null; + error: { code: string; summary: string } | null; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function questionFromPayload(payloadValue: unknown) { + const payload = asRecord(payloadValue); + // Turn results wrap the question in `payload.question`; persisted + // conversation messages store the question object directly in `payload`. + const question = asRecord(payload?.question) ?? payload; + if (!question) { + return null; + } + const options = Array.isArray(question.options) + ? question.options + .map((option) => { + const item = asRecord(option); + return item && + typeof item.label === 'string' && + typeof item.description === 'string' + ? { label: item.label, description: item.description } + : null; + }) + .filter((option): option is { label: string; description: string } => + Boolean(option), + ) + : []; + if ( + typeof question.id !== 'string' || + typeof question.header !== 'string' || + typeof question.question !== 'string' || + options.length === 0 + ) { + return null; + } + return { + id: question.id, + header: question.header, + question: question.question, + options, + } satisfies PlanningQuestionV2; +} + +function resultQuestion(result: PlanningTurnResultV2 | null) { + return questionFromPayload(result?.payload); +} + +export function planningResultText(result: PlanningTurnResultV2 | null) { + const text = result?.payload?.text; + return typeof text === 'string' ? text : ''; +} + +function questionDisplayText(question: PlanningQuestionV2) { + const options = question.options + .map( + (option, index) => + `${String.fromCharCode(65 + index)}. ${option.label}\n ${option.description}`, + ) + .join('\n'); + return `${question.header}\n${question.question}\n${options}`; +} + +function artifactDisplayText(artifact: PlanningArtifactV2) { + const title = artifact.payload.game.title.trim(); + const oneLiner = artifact.payload.game.oneLiner.trim(); + return `已生成 GDD v${artifact.version}${title ? `:${title}` : ''}${ + oneLiner ? `\n${oneLiner}` : '' + }`; +} + +export function planningResultDisplayText( + result: PlanningTurnResultV2 | null, + artifact: PlanningArtifactV2 | null, +) { + const question = resultQuestion(result); + if (question) { + return questionDisplayText(question); + } + if ( + artifact && + (result?.kind === 'artifact' || + result?.payload?.kind === 'gdd' || + typeof result?.payload?.artifactId === 'string') + ) { + return artifactDisplayText(artifact); + } + if (result?.kind === 'error') { + const payload = asRecord(result.payload); + const summary = + typeof payload?.summary === 'string' ? payload.summary.trim() : ''; + return summary + ? `后台任务失败:${summary}` + : '后台任务失败:策划回合未完成'; + } + return planningResultText(result); +} + +function messageDisplayText(message: PlanningMessageV2) { + const payload = asRecord(message.payload); + if (typeof payload?.text === 'string') { + return payload.text; + } + if (message.kind === 'question') { + const question = questionFromPayload(payload); + return question ? questionDisplayText(question) : ''; + } + if (message.kind === 'artifact') { + const artifact = asRecord(payload); + const nested = asRecord(artifact?.payload); + const game = asRecord(nested?.game); + const title = typeof game?.title === 'string' ? game.title.trim() : ''; + const version = + typeof artifact?.version === 'number' ? artifact.version : null; + const oneLiner = + typeof game?.oneLiner === 'string' ? game.oneLiner.trim() : ''; + const decisions = Array.isArray(nested?.decisions) + ? nested.decisions + .map((value) => { + const decision = asRecord(value); + const topic = + typeof decision?.topic === 'string' ? decision.topic.trim() : ''; + const summary = + typeof decision?.answerSummary === 'string' + ? decision.answerSummary.trim() + : ''; + return topic && summary ? `- ${topic}:${summary}` : null; + }) + .filter((value): value is string => Boolean(value)) + : []; + return [ + `已生成 GDD${version === null ? '' : ` v${version}`}${title ? `:${title}` : ''}`, + oneLiner, + decisions.length > 0 ? `本轮决策:\n${decisions.join('\n')}` : '', + ] + .filter(Boolean) + .join('\n'); + } + if (message.kind === 'error') { + const summary = typeof payload?.summary === 'string' ? payload.summary : ''; + return summary + ? `后台任务失败:${summary}` + : '后台任务失败:策划回合未完成'; + } + return ''; +} + +function timestampToMillis(value: string) { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : Date.now(); +} + +export function planningMessagesToChatMessages( + messages: PlanningMessageV2[] | undefined, +): ChatMessage[] { + return (messages ?? []) + .filter( + ( + message, + ): message is PlanningMessageV2 & { role: 'user' | 'assistant' } => + (message.role === 'user' || message.role === 'assistant') && + message.kind !== 'error', + ) + .map((message) => { + const text = messageDisplayText(message); + return { + role: message.role, + text, + runtimeOwned: true, + messageId: `planning-v2:${message.messageId}`, + updatedAt: timestampToMillis(message.atUtc), + } satisfies ChatMessage; + }) + .filter((message) => message.text.trim().length > 0); +} + +type PlanningSessionPhase = NonNullable['phase']; + +function sessionPhase(session: PlanningSessionV2): PlanningSessionPhase { + switch (session.status) { + case 'awaiting_user': + return 'awaiting_user_input'; + case 'awaiting_approval': + return 'awaiting_gdd_approval'; + case 'revision_requested': + return 'revision_requested'; + case 'approved': + return 'approved'; + case 'rejected': + return 'rejected'; + case 'provider_failed': + return 'recovery_required'; + default: + return 'collecting'; + } +} + +function gddState(status: string): PlanGddStateViewV1['state'] { + switch (status) { + case 'approved': + return 'approved'; + case 'rejected': + return 'rejected'; + case 'revision_requested': + return 'revision_requested'; + case 'ready_for_approval': + return 'ready_for_approval'; + default: + return 'draft'; + } +} + +function gddDisplayFromArtifact(artifact: PlanningArtifactV2) { + const payload = artifact.payload; + return { + schemaVersion: payload.schemaVersion, + projectId: payload.projectId, + gddId: payload.gddId, + version: payload.version, + submissionId: `planning-v2-submission-${payload.gddId}`, + approvalRequestId: `planning-v2-approval-${payload.gddId}-v${payload.version}`, + actionFingerprint: artifact.fingerprint, + agentId: 'planning-agent-v2', + source: PROJECT_SUPERVISOR_PLAN_SOURCE, + runProfile: 'standard', + runProfileBindingFingerprint: '', + rootAgentId: PROJECT_SUPERVISOR_AGENT_ID, + rootRunId: `planning-v2-${payload.projectId}`, + delegationId: '', + sessionId: `planning-v2-${payload.projectId}`, + sourceSessionRevision: 0, + sourceSessionFingerprint: '', + createdByRunId: `planning-v2-${payload.gddId}`, + createdAtUtc: payload.createdAtUtc, + fingerprint: payload.fingerprint, + game: payload.game, + decisions: payload.decisions.map((decision) => ({ + ...decision, + state: decision.state as + | 'confirmed' + | 'assumption_pending' + | 'prototype_pending', + answerSource: decision.answerSource as + | 'user_option' + | 'user_freeform' + | 'user_revision' + | 'agent_inferred', + })), + prototypeValidationItems: payload.prototypeValidationItems, + } satisfies NonNullable; +} + +export function planningSessionToPlanGddState( + session: PlanningSessionV2, + artifact: PlanningArtifactV2 | null, +): PlanGddStateViewV1 { + const artifactStatus = artifact?.status ?? ''; + const displayGdd = artifact ? gddDisplayFromArtifact(artifact) : null; + const awaitingQuestion = session.currentQuestion; + const questionCount = Math.max(0, Math.trunc(session.questionCount)); + const clarificationRound = awaitingQuestion + ? Math.max(0, questionCount - 1) + : questionCount; + const sessionView = { + sessionId: session.sessionId, + sessionRevision: Math.max(1, session.turnIndex), + sessionFingerprint: '', + phase: sessionPhase(session), + clarificationRound, + questionLimit: session.questionLimit, + repairDepth: session.revisionCount, + accumulatedAgentMillis: Math.round(session.processingSeconds * 1000), + activeRunId: session.status === 'planning' ? session.sessionId : null, + awaitingAnswerFor: awaitingQuestion + ? { + delegationId: `planning-v2-${session.sessionId}`, + requestId: `planning-v2-question-${session.turnIndex}`, + questionId: awaitingQuestion.id, + round: clarificationRound, + } + : null, + decisionStateCounts: { + confirmed: + displayGdd?.decisions.filter( + (decision) => decision.state === 'confirmed', + ).length ?? 0, + defaultPending: 0, + assumptionPending: + displayGdd?.decisions.filter( + (decision) => decision.state === 'assumption_pending', + ).length ?? 0, + prototypePending: + displayGdd?.decisions.filter( + (decision) => decision.state === 'prototype_pending', + ).length ?? 0, + }, + } satisfies NonNullable; + const state = artifact + ? gddState(artifactStatus) + : session.status === 'approved' + ? 'approved' + : session.status === 'rejected' + ? 'rejected' + : 'draft'; + const gddRef = artifact + ? { + gddId: artifact.artifactId, + version: artifact.version, + fingerprint: artifact.fingerprint, + } + : null; + const approvalRequestId = gddRef + ? `planning-v2-approval-${gddRef.gddId}-v${gddRef.version}` + : ''; + return { + schemaVersion: 'plan-gdd-state-view.v1', + projectId: session.projectId, + gddId: artifact?.artifactId ?? null, + state, + session: sessionView, + versions: gddRef + ? [ + { + gddRef, + status: + state === 'approved' + ? 'approved' + : state === 'rejected' + ? 'rejected' + : state === 'revision_requested' + ? 'revision_requested' + : 'ready_for_approval', + approvalRequestId, + createdAtUtc: + artifact?.payload.createdAtUtc ?? session.updatedAtUtc, + decision: null, + }, + ] + : [], + displayGdd, + pendingApproval: + artifact && gddRef && state === 'ready_for_approval' + ? { + gddRef, + pendingActionId: `planning-v2-action-${artifact.artifactId}-v${artifact.version}`, + actionFingerprint: artifact.fingerprint, + approvalRequestId, + sessionId: session.sessionId, + runId: session.sessionId, + } + : null, + approvedGddRef: state === 'approved' ? gddRef : null, + recoveryPending: false, + }; +} + +export function planningSessionToRuntime( + session: PlanningSessionV2, +): AgentRuntimeState { + const isQuestion = + session.status === 'awaiting_user' && session.currentQuestion; + const status = + session.status === 'planning' + ? 'running' + : isQuestion + ? 'waiting-for-user-input' + : session.status === 'provider_failed' + ? 'failed' + : session.status === 'approved' || session.status === 'rejected' + ? 'completed' + : 'idle'; + const phase = isQuestion + ? 'waiting-for-user-input' + : session.status === 'planning' + ? 'planning' + : session.status === 'provider_failed' + ? 'failed' + : status === 'completed' + ? 'completed' + : 'idle'; + const question = session.currentQuestion; + const userInputRequest: AgentRuntimeUserInputRequest | null = question + ? { + schemaVersion: 'planning-session-v2-user-input.v1', + requestId: `planning-v2-question-${session.turnIndex}`, + agentId: PROJECT_SUPERVISOR_AGENT_ID, + taskId: session.sessionId, + sessionId: session.sessionId, + runId: session.sessionId, + actionId: `planning-v2-question-${session.turnIndex}`, + status: 'pending', + questions: [question], + allowFreeform: true, + responseId: null, + requestedAt: timestampToMillis(session.updatedAtUtc), + updatedAt: timestampToMillis(session.updatedAtUtc), + } + : null; + return { + schemaVersion: 'planning-session-v2-runtime.v1', + agentId: PROJECT_SUPERVISOR_AGENT_ID, + taskId: session.sessionId, + sessionId: session.sessionId, + runId: session.sessionId, + source: PROJECT_SUPERVISOR_PLAN_SOURCE, + runProfile: 'standard', + status, + phase, + currentTask: '立项策划', + currentAction: session.status === 'planning' ? '正在整理策划方案' : '', + waitingOn: isQuestion ? '等待用户回答策划问题' : '', + nextStep: isQuestion ? '提交回答后继续策划' : '', + plan: [], + observations: [], + allowedTools: [], + pendingToolAction: null, + userInputRequest, + taskQueue: { + total: session.status === 'planning' ? 1 : 0, + pending: 0, + running: session.status === 'planning' ? 1 : 0, + waitingForConfirmation: 0, + waitingForUserInput: isQuestion ? 1 : 0, + paused: 0, + cancelled: 0, + completed: status === 'completed' ? 1 : 0, + failed: 0, + latestRunId: session.sessionId, + updatedAt: timestampToMillis(session.updatedAtUtc), + }, + lastResponse: null, + error: session.lastError?.summary ?? null, + startedAt: timestampToMillis(session.createdAtUtc), + updatedAt: timestampToMillis(session.updatedAtUtc), + }; +} diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 76ae72518..db156dfb5 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -459,6 +459,8 @@ function createProjectSupervisorRuntimeHarness({ initialProjectRevision = 0, runtimeMapLoader, expectedRunProfile = 'autonomous-game-build', + planningV2Result = null, + planningV2StartResult = null, }: { projectPath?: string; sessionId?: string; @@ -470,6 +472,8 @@ function createProjectSupervisorRuntimeHarness({ initialProjectRevision?: number; runtimeMapLoader?: () => Promise>>; expectedRunProfile?: 'standard' | 'autonomous-game-build'; + planningV2Result?: Record | null; + planningV2StartResult?: Record | null; } = {}) { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -518,6 +522,8 @@ function createProjectSupervisorRuntimeHarness({ let currentPlanGddState: PlanGddStateViewV1 | null = null; let planGddDecisionError: string | null = null; let planGddHydrateCount = 0; + let currentPlanningV2Result = planningV2Result; + let currentPlanningV2StartResult = planningV2StartResult; const planGddDecisionCalls: Array> = []; let runtimeUpdateHandler: | ((event: { @@ -843,6 +849,29 @@ function createProjectSupervisorRuntimeHarness({ } return currentPlanGddState; } + if (command === 'hydrate_planning_session_v2') { + return currentPlanningV2Result; + } + if ( + command === 'start_planning_session_v2' || + command === 'continue_planning_session_v2' + ) { + const nextResult = + command === 'start_planning_session_v2' + ? (currentPlanningV2StartResult ?? currentPlanningV2Result) + : currentPlanningV2Result; + if (!nextResult) { + throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED'); + } + currentPlanningV2Result = nextResult; + return nextResult; + } + if (command === 'decide_planning_artifact_v2') { + if (!currentPlanningV2Result) { + throw new Error('PLANNING_V2_RESULT_NOT_CONFIGURED'); + } + return currentPlanningV2Result; + } if (command === 'decide_game_creator_plan_gdd') { planGddDecisionCalls.push({ ...(args ?? {}) }); if (planGddDecisionError) { @@ -919,6 +948,12 @@ function createProjectSupervisorRuntimeHarness({ setPlanGddState(state: PlanGddStateViewV1 | null) { currentPlanGddState = state; }, + setPlanningV2Result(state: Record | null) { + currentPlanningV2Result = state; + }, + setPlanningV2StartResult(state: Record | null) { + currentPlanningV2StartResult = state; + }, failNextPlanGddDecision(message: string) { planGddDecisionError = message; }, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index e83a74396..96a1f8ab9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1,11 +1,9 @@ -import { PROJECT_SUPERVISOR_PLAN_SOURCE } from '../../src/app/constants'; import type { ProjectSupervisorComponentProps } from '../../src/features/app-shell/model'; import { useHomeProjectCreation } from '../../src/features/app-shell/useHomeProjectCreation'; import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher'; import type { LauncherView } from '../../src/view/layout'; import { act, - agentRuntimeUserInputRequest, App, cleanup, createGameCreationAppManifest, @@ -29,6 +27,33 @@ import { within, } from './harness'; +function emptyPlanningV2StartResult(projectId = 'local-project-draft') { + return { + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: 'home-planning-v2-session', + projectId, + mode: 'gdd', + status: 'planning', + turnIndex: 1, + questionCount: 0, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: null, + currentQuestion: null, + capabilities: { tools: [], skills: [] }, + processingSeconds: 0.5, + createdAtUtc: '2026-09-03T00:00:00Z', + updatedAtUtc: '2026-09-03T00:00:01Z', + lastError: null, + }, + result: null, + currentArtifact: null, + replayed: false, + }; +} + function ApprovedGddStartHarness() { const [, setLauncherView] = React.useState( 'project-development', @@ -1700,11 +1725,11 @@ export function registerHomeProjectCreationTests() { }); it.each([ - ['做方案', false, 'standard', PROJECT_SUPERVISOR_PLAN_SOURCE], - ['做方案', true, 'standard', PROJECT_SUPERVISOR_PLAN_SOURCE], + ['做方案', false], + ['做方案', true], ] as const)( - 'routes %s %s creation to the expected root run', - async (modeLabel, automatic, runProfile, source) => { + 'routes %s %s creation to Planning Session V2', + async (modeLabel, automatic) => { const projectPath = `/tmp/home-${modeLabel}-${automatic ? 'enter' : 'submit'}`; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1712,7 +1737,7 @@ export function registerHomeProjectCreationTests() { ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, - expectedRunProfile: runProfile, + planningV2StartResult: emptyPlanningV2StartResult(), }); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -1752,29 +1777,23 @@ export function registerHomeProjectCreationTests() { } else { fireEvent.click( screen.getByRole('button', { - name: source ? '进入立项策划' : '开启创作', + name: '进入立项策划', }), ); } await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', + 'start_planning_session_v2', expect.objectContaining({ projectPath, - runProfile, - ...(source ? { source } : {}), + mode: 'gdd', }), ); }); const startCall = invoke.mock.calls.find( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ([command]) => command === 'start_planning_session_v2', ); - if (source) { - expect(startCall?.[1]).toMatchObject({ source }); - } else { - expect(startCall?.[1]).not.toHaveProperty('source'); - } expect(startCall?.[1]).not.toHaveProperty('attachments'); expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain( '本轮用户附件', @@ -1787,6 +1806,10 @@ export function registerHomeProjectCreationTests() { 'chat_with_game_creator_agent', expect.anything(), ); + expect(invoke).not.toHaveBeenCalledWith( + 'suggest_automatic_project_name', + expect.anything(), + ); expect( invoke.mock.calls.filter( ([command]) => command === 'create_automatic_local_game_project', @@ -1803,7 +1826,9 @@ export function registerHomeProjectCreationTests() { ); const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, - expectedRunProfile: 'standard', + planningV2StartResult: emptyPlanningV2StartResult( + 'home-planning-attachment', + ), }); const fileBytes = Array.from(new TextEncoder().encode('png')); const invoke = vi.fn( @@ -1856,10 +1881,10 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', + 'start_planning_session_v2', expect.objectContaining({ projectPath, - source: PROJECT_SUPERVISOR_PLAN_SOURCE, + mode: 'gdd', }), ); }); @@ -1870,7 +1895,7 @@ export function registerHomeProjectCreationTests() { bytes: fileBytes, }); const startCall = invoke.mock.calls.find( - ([command]) => command === 'start_game_creator_supervisor_runtime_task', + ([command]) => command === 'start_planning_session_v2', ); expect(startCall?.[1]).not.toHaveProperty('attachments'); expect(JSON.stringify(startCall?.[1] ?? {})).not.toContain('本轮用户附件'); @@ -1901,7 +1926,52 @@ export function registerHomeProjectCreationTests() { projectPath, expectedRunProfile: 'standard', }); - let planRootRunId = ''; + supervisorHarness.setPlanningV2StartResult({ + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: 'home-planning-v2-session', + projectId: 'local-project-draft', + mode: 'gdd', + status: 'awaiting_user', + turnIndex: 1, + questionCount: 1, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: null, + currentQuestion: { + id: 'visual_direction', + header: '当前要决定:首版美术方向', + question: '首版角色规范图采用哪种美术方向?', + options: [ + { label: '像素', description: '低成本像素风。' }, + { label: '扁平', description: '清晰的扁平插画风。' }, + ], + }, + capabilities: { tools: [], skills: [] }, + processingSeconds: 1, + createdAtUtc: '2026-09-03T00:00:00Z', + updatedAtUtc: '2026-09-03T00:00:01Z', + lastError: null, + }, + result: { + schemaVersion: 'planning-turn-result.v2', + kind: 'question', + payload: { + question: { + id: 'visual_direction', + header: '当前要决定:首版美术方向', + question: '首版角色规范图采用哪种美术方向?', + options: [ + { label: '像素', description: '低成本像素风。' }, + { label: '扁平', description: '清晰的扁平插画风。' }, + ], + }, + }, + }, + currentArtifact: null, + replayed: false, + }); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'pick_local_project_directory') { @@ -1917,33 +1987,7 @@ export function registerHomeProjectCreationTests() { manifest, }; } - if (command === 'start_game_creator_supervisor_runtime_task') { - planRootRunId = String(args?.runId ?? ''); - } - const result = await supervisorHarness.invoke(command, args); - if (command !== 'read_game_creator_agent_runtime' || !planRootRunId) { - return result; - } - // 后端此刻的真实形态:pending 是 user.input_request,读命令把澄清请求投影在 - // 结果的**顶层**(`AgentRuntimeResult.user_input_request`,与 `state` 平级), - // 前端的 agentRuntimeStateFromResult 也优先读顶层。放进 state 会被顶层的 null - // 盖掉,那是 fixture 写错,不是产品缺陷。 - const runtimeResult = result as { state: Record }; - return { - ...runtimeResult, - state: { - ...runtimeResult.state, - status: 'waiting-for-user-input', - phase: 'waiting-for-user-input', - }, - userInputRequest: agentRuntimeUserInputRequest({ - agentId: 'project-supervisor', - sessionId: supervisorHarness.sessionId, - runId: planRootRunId, - requestId: 'request-plan-round-1', - actionId: 'action-plan-round-1', - }), - }; + return supervisorHarness.invoke(command, args); }, ); window.__TAURI__ = { @@ -1963,8 +2007,8 @@ export function registerHomeProjectCreationTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith( - 'start_game_creator_supervisor_runtime_task', - expect.objectContaining({ source: PROJECT_SUPERVISOR_PLAN_SOURCE }), + 'start_planning_session_v2', + expect.objectContaining({ prompt: '2D射击游戏', mode: 'gdd' }), ); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts index d63bb13b1..181b971a1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts @@ -3,7 +3,6 @@ import { resolve } from 'node:path'; import { PlanGddStageProgress } from '../../src/features/project-workspace/GddApprovalCard'; import { - agentRuntimeUserInputRequest, App, createPlanGddStateView, createProjectSupervisorRuntimeHarness, @@ -20,37 +19,140 @@ import { function mountFormalSupervisor( harness: ReturnType, + planningStartMode = false, ) { window.history.pushState({}, '', '/'); render( React.createElement(App, { initialProjectPath: harness.projectPath, orchestrationMode: 'single-supervisor', - planningStartMode: true, + planningStartMode, projectSupervisorOnly: true, }), ); } +function planningV2ApprovalResult() { + const state = createPlanGddStateView(); + const displayGdd = state.displayGdd; + const session = state.session; + if (!displayGdd || !session) { + throw new Error('fixture 应当带 GDD 和 session'); + } + return { + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: session.sessionId, + projectId: state.projectId, + mode: 'gdd', + status: 'awaiting_approval', + turnIndex: 3, + questionCount: session.clarificationRound, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: displayGdd.version, + currentQuestion: null, + capabilities: { tools: [], skills: [] }, + processingSeconds: session.accumulatedAgentMillis / 1000, + createdAtUtc: displayGdd.createdAtUtc, + updatedAtUtc: displayGdd.createdAtUtc, + lastError: null, + }, + result: null, + currentArtifact: { + artifactId: displayGdd.gddId, + kind: 'gdd', + version: displayGdd.version, + status: 'ready_for_approval', + fingerprint: displayGdd.fingerprint, + payload: { + schemaVersion: 'plan-gdd.v2', + projectId: displayGdd.projectId, + gddId: displayGdd.gddId, + version: displayGdd.version, + createdAtUtc: displayGdd.createdAtUtc, + game: displayGdd.game, + decisions: displayGdd.decisions, + prototypeValidationItems: displayGdd.prototypeValidationItems, + fingerprint: displayGdd.fingerprint, + }, + }, + replayed: false, + conversation: [], + }; +} + +function planningV2QuestionResult() { + return { + session: { + schemaVersion: 'planning-session.v2', + engine: 'planning-session-v2', + sessionId: 'plan-session-v2-question', + projectId: 'local-project-draft', + mode: 'gdd', + status: 'awaiting_user', + turnIndex: 1, + questionCount: 1, + questionLimit: 8, + revisionCount: 0, + currentArtifactVersion: null, + currentQuestion: { + id: 'core_loop', + header: '当前要决定:核心循环', + question: '玩家在一局中主要反复做什么?', + options: [ + { label: '持续闪避', description: '保持移动并躲避来袭弹幕。' }, + { label: '规划路线', description: '观察弹幕并选择安全路线。' }, + ], + }, + capabilities: { tools: [], skills: [] }, + processingSeconds: 1.2, + createdAtUtc: '2026-09-03T00:00:00Z', + updatedAtUtc: '2026-09-03T00:00:01Z', + lastError: null, + }, + result: { + schemaVersion: 'planning-turn-result.v2', + kind: 'question', + payload: { + question: { + id: 'core_loop', + header: '当前要决定:核心循环', + question: '玩家在一局中主要反复做什么?', + options: [ + { label: '持续闪避', description: '保持移动并躲避来袭弹幕。' }, + { label: '规划路线', description: '观察弹幕并选择安全路线。' }, + ], + }, + }, + }, + currentArtifact: null, + replayed: false, + }; +} + async function mountApprovalCard( harness: ReturnType, + planningStartMode = false, ) { window.__TAURI__ = { core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - mountFormalSupervisor(harness); + mountFormalSupervisor(harness, planningStartMode); return await screen.findByLabelText('GDD 审批卡'); } async function mountPlanningSurface( harness: ReturnType, + planningStartMode = false, ) { window.__TAURI__ = { core: { invoke: harness.invoke }, event: { listen: harness.listen }, }; - mountFormalSupervisor(harness); + mountFormalSupervisor(harness, planningStartMode); return await screen.findByLabelText('立项策划阶段进度'); } @@ -502,77 +604,51 @@ export function registerPlanGddApprovalTests() { }); it('still surfaces the clarification card on the planning lane', async () => { - // 澄清卡是策划链路唯一需要用户动手的交互面之一,瘦身不能把它一起收掉。 - const supervisorRunId = 'plan-root-clarifying-run'; - const request = agentRuntimeUserInputRequest({ - agentId: 'project-supervisor', - sessionId: 'supervisor-session-active', - runId: supervisorRunId, - requestId: 'request-plan-round-1', - actionId: 'action-plan-round-1', - }); + // 澄清卡是 V2 策划链路唯一需要用户动手的交互面之一。 const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: supervisorRunId, - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'waiting-for-user-input', - phase: 'waiting-for-user-input', - currentTask: '剧情向恋爱养成游戏', - currentAction: '等待用户回答第 1 轮澄清', - userInputRequest: request, - updatedAt: 7100, - }, + planningV2Result: planningV2QuestionResult(), }); - harness.setPlanGddState( - draftPlanGddState({ - clarificationRound: 0, - awaitingAnswerFor: { - delegationId: 'delegation-0001', - requestId: 'request-plan-round-1', - questionId: 'visual_direction', - round: 0, - }, - }), - ); - await mountPlanningSurface(harness); + await mountPlanningSurface(harness, true); const strip = await screen.findByLabelText('立项策划运行状态'); expect(within(strip).getByLabelText('Needs input')).not.toBeNull(); expect( - within(strip).getByText('首版角色规范图采用哪种美术方向?'), + within(strip).getByText('玩家在一局中主要反复做什么?'), ).not.toBeNull(); expectSupervisorRuntimePanelAbsent(); - expect(screen.queryByText('等待用户回答第 1 轮澄清')).toBeNull(); + expect(screen.queryByText(/agent\.delegate/)).toBeNull(); }); - it('offers a restart on the planning lane once the planning run has failed', async () => { - // 失败是另一处需要用户动手的时刻。恢复入口跟着搬进窄条,而不是随面板一起消失。 + it('surfaces a V2 provider failure on the planning lane', async () => { + // V2 provider_failed 由同一策划会话承接,错误显示在窄条中,用户可用输入框重新提交。 const harness = createProjectSupervisorRuntimeHarness({ - expectedRunProfile: 'standard', - initialRuntime: { - runId: 'plan-root-failed-run', - source: 'project-supervisor-plan', - runProfile: 'standard', - status: 'failed', - phase: 'failed', - currentTask: '剧情向恋爱养成游戏', - currentAction: '策划子 Run 退出', - error: '项目总控 Agent Codex 执行失败,请查看运行详情后重试', - updatedAt: 7200, + planningV2Result: { + ...planningV2QuestionResult(), + session: { + ...planningV2QuestionResult().session, + status: 'provider_failed', + currentQuestion: null, + lastError: { + code: 'PROVIDER_FAILED', + summary: 'Planning V2 Provider 调用失败', + }, + }, + result: { + schemaVersion: 'planning-turn-result.v2', + kind: 'error', + payload: { + code: 'PROVIDER_FAILED', + summary: 'Planning V2 Provider 调用失败', + }, + }, }, }); - harness.setPlanGddState(draftPlanGddState()); - await mountPlanningSurface(harness); + await mountPlanningSurface(harness, true); - const recovery = await screen.findByLabelText('立项策划失败恢复'); - expect( - within(recovery).getByRole('button', { name: '重新启动策划' }), - ).not.toBeNull(); - expect(screen.getByRole('alert').textContent).toContain('执行失败'); + const strip = await screen.findByLabelText('立项策划运行状态'); + expect(within(strip).getByRole('alert').textContent).toContain('执行失败'); expectSupervisorRuntimePanelAbsent(); - expect(screen.queryByText('策划子 Run 退出')).toBeNull(); + expect(screen.queryByText(/project-planning/)).toBeNull(); }); it('keeps a stylesheet rule for every class the planning components reference', () => { @@ -661,4 +737,66 @@ export function registerPlanGddApprovalTests() { /\.game-workbench-chat\s+\.plan-gdd-surface--with-card\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)/s, ); }); + + it('routes a formal planning entry through Runtime V2 commands', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + planningV2Result: planningV2ApprovalResult(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + await screen.findByLabelText('GDD 审批卡'); + const planningHydrateCall = harness.invoke.mock.calls.find( + ([command]) => command === 'hydrate_planning_session_v2', + ); + expect(planningHydrateCall).toBeDefined(); + fireEvent.click(screen.getByRole('button', { name: '批准 v1' })); + await waitFor(() => { + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'decide_planning_artifact_v2', + ), + ).toBe(true); + }); + }); + + it('starts a new V2 planning session and renders its question card', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + planningV2StartResult: planningV2QuestionResult(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + initialSupervisorMessage: '做一个2D弹幕射击游戏', + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + await screen.findByLabelText('Needs input'); + expect( + harness.invoke.mock.calls.some( + ([command]) => command === 'start_planning_session_v2', + ), + ).toBe(true); + expect(screen.getByText('玩家在一局中主要反复做什么?')).not.toBeNull(); + expect(screen.queryByText(/agent\.delegate/)).toBeNull(); + }); } diff --git a/docs/README.md b/docs/README.md index 7453573fe..07902b67a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,13 +22,14 @@ - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 +- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 - [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 - [项目开发工作台 PRD](./prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md):当前工作台页面和验收边界。 - [AGC 错误报告与诊断上传](./technical/【技术方案】AGC错误报告与诊断上传-2026-08-31.md):当前进程错误事件、应用级日志和管理员查看器合同。 -- [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):当前策划入口、审批和恢复合同。 +- [立项策划 Agent(Fast GDD)](<./technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md>):旧 `project-supervisor-plan` / `project-planning` 历史会话的入口、审批和恢复合同;V2 切换时未完成旧会话强制失败。 - [GameAgent 资源自由画板与快速编辑](./technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md) - [UI 工作流资源桥接与 Runtime 执行](./【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md) - [UI 编辑器 Godot 容器布局](./technical/【技术方案】UI编辑器Godot容器布局模型-2026-08-18.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 00fe2141a..bc7953782 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,97 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-05 Planning V2 一次性写路径对齐 V1 的项目锁等待窗口 + +- 背景:V2 审批修改意见后立即 `continue_planning_session_v2`。审批、回合启动、策略落盘和 hydrate 原先无等待取锁,和 GUI 重灌或其它写操作撞车就返回 `项目正在被其他写操作占用`,前端再映射成总控失败。V1 已用完整/短窗口处理同一形状。 +- 决策:V2 审批、回合启动、策略落盘、失败投影和 GDD 认领使用完整等待窗口;V2 hydrate 使用短窗口。不引入可重入项目锁,不放宽失效回收。 +- 影响范围:`planning_policy_v2.rs`、`planning_session_v2.rs`、V2 hydrate 前端瞬时争用处理。 +- 验证方式:Rust 定向测试覆盖短暂占用下的审批、修订续跑和 hydrate。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`docs/project-memory/shared-memory/pitfalls.md`。 + +## 2026-09-05 本进程新建 Windows 私有对象不因继承 DACL 自动 UAC + +- 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。 +- 决策:`harden_new_game_creator_private_path` 只在本进程收紧 owner/DACL,失败则删除刚创建的对象,不 UAC 接管。项目锁先写再释放句柄再 harden,并用内容回读防换绑;UAC 仍只用于允许范围内的已有外人本对象。提权 helper 的 ArgumentList 改为一条按 Windows 规则加引号的字符串。 +- 影响范围:`config.rs` 的新建 harden 与提权命令行、`filesystem.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。 +- 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。 + +## 2026-09-05 Planning V2 的 3 轮策略与 8 个问题门禁有意不对称 + +- 决策:模型提示最多提问 3 轮,并在达到 3 后要求出稿;Runtime `question_limit` 默认 8,对偏离模型策略的合法问题保留接收空间,达到 8 才拒绝新 question。前者是模型行为指令,后者是运行时接收边界,数值有意不同,不是缺陷或配置不一致。 +- 评审口径:Session/UI 的 8 是容量,不是必须问满的配额;正常路径在 3 轮或更早出稿符合设计。不得仅因数值不同,把模型提示改为按 `question_limit` 出稿、把 3 改成可继续到 8 的软目标,或把 Runtime 门禁收紧为 3。 +- 影响范围:仅补充文档解释,现有提示词、运行逻辑、校验和测试保持不变。 +- 关联文档:[策划会话 Runtime V2 接入与旧链路退役方案 §1.3](../../technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md#13-问询策略与-runtime-门禁的不对称设计)。 + +## 2026-09-04 Planning V2 将决定 ID 从 Provider 输入移回 Runtime + +- 背景:`plan_submit_gdd` 原先要求模型生成 `decisions[].id` 及原型验证项引用 ID。该字段既不是方案内容,又容易出现 `initial_request`、`initialRequest` 或错误层级,导致合法 GDD 在 Runtime 事后校验阶段失败。 +- 决策:Provider-facing `plan_submit_gdd` schema 和入参删除决定/原型验证项 ID。Runtime 按决定数组顺序生成首项 `initial-request`、后续 `decision-{序号}`,并按 `prototype_pending` 决定顺序给原型验证项绑定同一 ID。最终持久化 `plan-gdd.v2` 仍保留 ID,供审批、引用和 fingerprint 使用。V2 尚未上线,不为旧 Provider 输入或历史 V2 artifact 增加兼容转换;不符合新契约的历史数据按现有失败策略处理。 +- 影响范围:`planning_policy_v2.rs` 的工具 schema、Provider 入参解析、Runtime 产物构建与定向测试;Planning V2 技术方案。 +- 验证方式:定向 Rust 测试确认 schema 不含 ID、无 ID 输入可生成 Runtime ID;并运行 `cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`。 + +## 2026-09-04 Planning V2 持久化每次 Provider 尝试的诊断产物 + +- 背景:Provider 已成功返回但策略解析失败时,原 V2 只保留最终错误,无法核对实际请求、原始工具参数和单次重试结果。 +- 决策:在 `.agent/planning-v2/debug//` 保存每次尝试的 request、response 和分类事件;诊断文件不进入会话上下文,不参与恢复、重试或 GDD 业务判断,写入失败不改变主流程结果。 +- 影响范围:`planning_session_v2.rs` 的 Provider 调用外围和 Planning V2 技术方案持久化目录说明。 +- 验证方式:通过 Provider 请求/响应产物可还原每次尝试及 `toolCalls.arguments`,并确认主流程仍按原有解析、重试和状态转换执行。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`。 + +## 2026-09-04 Planning V2 把 `gdd.vN.json` 创建成功当作提交点 + +- 背景:V2 persist 先 create-only 写入不可变 GDD,再更新 index、Markdown、conversation 和 session。后续任一步失败会把 session 标成 `provider_failed`,但不回滚已创建文件;重试会重新生成 UUID/时间戳并撞上“已存在且内容不同”,hydrate 又只信 `current_artifact_version`,项目会卡死。 +- 决策:`gdd.v{N}.json` 创建成功即提交点,禁止回滚不可变文件。persist / hydrate / 回合启动若发现 session 指针的下一个连续版本已在磁盘,必须读取既有 GDD 补投影,不得用新的 LLM 入参重建身份。session 指针写成功前的投影失败仍可返回 persist 错误,但恢复路径必须认领该版本。 +- 影响范围:`planning_policy_v2.rs` persist/认领、`planning_session_v2.rs` hydrate 与回合启动;V2 技术方案。 +- 验证方式:Rust 测试覆盖孤儿 GDD 重试认领、hydrate 认领、成功提交后仍分配下一版本;`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`。 + +## 2026-09-04 PlanningSessionRuntime V2 用协议工具输出问询和 GDD + +- 背景:原型已验证 `plan_ask_question` / `plan_submit_gdd` 两个协议工具、深层 schema、提示词只留策略、`tool_choice=auto` 可跑通;生产 V2 仍解析正文 `{kind,question|gdd}` JSON,并把形状骨架写在 system prompt 里。浅 schema + 正文 JSON 会误导模型把 GDD 写成普通文本;`tool_choice=required` 与 DeepSeek thinking 不能同时使用。 +- 决策:V2 Provider 请求固定挂这两个协议工具,`tool_choice=auto`,`strict=false`。模型必须恰好调用其中一个;Runtime 解析 `toolCalls` 归一为 Question/Artifact,正文 JSON 视为非法。system prompt 只保留问询/出稿策略和当前问询进度,不再附 JSON 骨架或数量清单。入参不再要求模型回声 `schemaVersion`,落盘 GDD 仍由 Runtime 写入 `plan-gdd.v2`。既有结构门禁(含 `initial-request` 首项、`validate_plan_game` 数量/字数)不变,失败仍回灌一次。不把协议工具写入 `capabilities.tools`,不执行 MCP/Skill,不把 `tool_call`/`tool_result` 写入会话消息。 +- 影响范围:`planning_session_v2.rs` 请求构造、重试文案与 Provider 结果投影;`planning_policy_v2.rs` 工具 schema、解析和入参 `schemaVersion`;V2 技术方案。 +- 验证方式:Planning V2 定向 Rust 测试覆盖工具解析、缺 `schemaVersion` 的合法 GDD、正文 JSON 拒收、工具 schema 含嵌套 `game` 字段、提示词不再含骨架;`cargo fmt --check`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`。 + +## 2026-09-03 新建策划会话采用 PlanningSessionRuntime V2,旧 Supervisor 链路直接退役 + +- 背景:现有“做方案”依赖 `project-supervisor-plan` 根 Run、`project-planning` 子 Run、静态委派、delivery、Acceptance Graph 和审批前 evidence。新策划 Agent 只需要单 Agent 会话、问询、GDD 和审批;继续在旧 Runtime 上逐条放宽会保留身份/编排耦合。未来策划 Agent 可能支持无限多轮、MCP 和 Skill,需要避免把当前 8 题/GDD/no-tools 固化为 Runtime 根结构。 +- 决策:新增独立 `PlanningSessionRuntime`,复用 Provider/流式、会话持久化、项目锁、原子写和基础错误恢复;当前启用 `mode=gdd`、最多展示 8 个有效问题、GDD 审批和用户修改。新建“做方案”会话不创建 Supervisor root、planning child、delegation 或 acceptance evidence。V2 使用独立 `.agent/planning-v2/` 与 V2 schema,继续输出 `game/fast_gdd.md`;不自动转换旧会话。 +- 兼容性:Session 保存 `mode`、可空 `questionLimit`、`capabilities.tools/skills`;完整会话记录与 Provider 请求上下文分离;消息模型预留 tool/skill 事件类型但本期不执行 MCP/Skill。无限问询、上下文摘要、多产物和能力执行以后作为策略/能力层扩展,不重新引入 Supervisor 身份模型。 +- 当前进度:P0 合同冻结、P1 会话内核和 P2 GDD/审批核心已落地;P3 正式入口/UI 接入已开始,P5 旧链路退役尚未开始。 +- 退役:V2 切换时旧链路直接封存;所有未完成旧会话投影为 `legacy_retired` 失败,禁止继续问询、审批、恢复或 continuation。旧 GDD、approval、conversation 和 `.agent/planning` 文件只读保留;旧入口 caller 关闭,但不删除旧代码、旧测试或旧数据。 +- 影响范围:AGC 做方案入口、Rust/Tauri planning session/Provider adapter、GDD/审批 V2、前端 planning lane、阶段任务与 BDD 验收;做游戏/做素材 DirectProject 不变。 +- 验证方式:按 `docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md` 的 P0~P5 阶段验收执行;至少覆盖第 8 个问题、上限后 question 抑制、Provider 失败、非法输出、批准/修改/退回、重启恢复、旧会话切换强制失败、迟到 Provider 结果丢弃和当前空能力快照。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。 + +## 2026-09-03 PlanningSessionRuntime V2 统一 Agent 推断语义 + +- 背景:旧 V1 使用 `default_pending` / `answerSource=default` 表示未提问时由 Agent 按默认建议补齐的字段;该语义会让 V2 的 Agent 推断看起来像产品默认值,也会造成原型与生产字段不一致。 +- 决策:V2 只使用 `confirmed`、`assumption_pending`、`prototype_pending` 三种决定状态;`assumption_pending` 的来源统一为 `agent_inferred`。`answerSource` 仍不是独立阻断项,缺失或不一致时按状态归一为 `user_freeform`、`user_option` 或 `agent_inferred`。V1 的 `default_pending` / `default` 校验和历史数据保持不动,不作为 V2 合同的一部分。 +- 问询策略:V2 出稿前必须确认玩家核心行为、单局目标/核心循环、MVP 制作边界;其中任一仅由 Agent 推断时继续问一个关键问题。`questionLimit` 是 Runtime 对已展示问题数的硬上限,提示词中的“默认最多三轮”只是策略偏好,不要求与硬上限数值一致。 +- 影响范围:V2 GDD 输入/产物、Provider system prompt、前端 V2 类型与决定状态展示;旧 Supervisor/V1 存储、校验和历史产物不变。 +- 验证方式:V2 解析 `assumption_pending` 不报错并落盘为 `assumption_pending/agent_inferred`;`default_pending` 不作为 V2 合法状态;核心三项未确认时提示词要求继续问询;相关 Rust/TS 定向测试、类型和编码检查通过。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_policy_v2.rs`。 + +## 2026-09-04 PlanningSessionRuntime V2 将既有输出阻断原因回灌给 Provider + +- 背景:原型已将会导致输出拒收的字段、类型、数量和长度契约写入提示词,并在校验失败重试时回灌具体原因;生产 V2 仍只有简要 GDD 形状提示,模型可能重复犯同一结构错误。 +- 决策:生产 V2 只同步当前已经存在的 question/GDD 校验契约到 Provider system prompt,并在现有一次重试中明确列出本次阻断原因、要求逐项修复;不扩大校验范围、不新增门禁、不增加重试次数,也不把 `answerSource` 变成阻断条件。 +- 影响范围:`planning_session_v2.rs` 的 Provider prompt 与现有非法输出重试提示;`planning_policy_v2.rs` 校验逻辑、问询上限和持久化契约不变。 +- 验证方式:运行 Planning V2 定向 Rust 测试、`cargo fmt --check`、`git diff --check`,确认提示词构造和现有校验路径通过;不改变既有校验结果。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`。 + +## 2026-09-04 PlanningSessionRuntime V2 提示词只保留形状和策略 + +- 背景:把逐字段长度、数量、类型清单写入 system prompt 后,提示词与 JSON 骨架、Rust 校验器三重叠,模型也难以消化长清单;精确数字已由校验失败的一次重试回灌。 +- 决策:V2 system prompt 只保留问询/GDD 骨架、`game` 与 `decisions` / `prototypeValidationItems` 同级边界、状态枚举、首条 `initial-request` 约束,以及一行易错数量范围(options / keywords / pillars / coreLoop / mvpSystems / outOfScope / oneLiner)。不把逐字段长度、控制字符、label 去重等校验细则写入 prompt;校验范围、门禁和重试次数不变。 +- 影响范围:`planning_session_v2.rs` 的 Provider system prompt;`planning_policy_v2.rs` 校验逻辑与非法输出重试路径不变。 +- 验证方式:提示词含骨架与同级边界、不含逐字段长度清单;现有 Planning V2 定向测试、编码和 diff 检查通过。 +- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_session_v2.rs`。 + ## 2026-09-03 AGC 登录 route event 使用 handler 已验证主体归属 - 背景:登录请求进入时尚未拥有 `AuthenticatedAccessToken`,通用 tracking middleware 无法从响应 extensions 归属登录成功用户;将 AGC marker 直接写入按用户/业务日幂等的 `daily_login` 又会受到不同来源登录顺序影响。 @@ -7983,6 +8074,37 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 生成文件名保留可读清洗前缀,并追加 asset ID 的 SHA-256 摘要前缀以避免不同 ID 碰撞;不迁移既有旧路径,调用方需在采用新命名后使用新返回路径。 - Radial90 的前端预览与 Rust 导出统一使用角点映射和顺时针起始角规则,顺时针填充从角点前一条边开始,避免两端渲染偏移。 +## 2026-09-03 策划会话 Runtime V2 P1 内核落地 + +- 新增 `PlanningSessionRuntime V2` 内核入口:`start_planning_session_v2`、`continue_planning_session_v2`、`hydrate_planning_session_v2`。P1 只负责单 Agent 会话生命周期、Provider 流式/普通调用、消息持久化、回合幂等、单项目单活跃回合、耗时和失败恢复,不接入 GDD 解析、审批或旧 Supervisor。 +- V2 会话快照落在 `.agent/planning-v2/session.json`,完整消息落在 `.agent/planning-v2/conversation.jsonl`。同一 `clientTurnId` 已有成功 assistant 记录时直接重放;若只有 error 记录则允许沿用同一用户意图重试,不重复追加用户消息。进程退出后 hydrate 发现 `planning` 会投影为 `provider_failed/RECOVERY_REQUIRED`,不伪造成功或自动重试。 +- Provider 调用复用现有 `platform-llm` 的 API-kind、流式解析、超时和重试配置;当前策略快照固定 `mode=gdd`、`questionLimit=8`、`tools=[]`、`skills=[]`。GDD 业务规则留给 P2,生产 UI 接入留给 P3。 + +## 2026-09-03 策划会话 Runtime V2 P2 产物闭环 + +- V2 新增 `GddPlanningPolicy`:Provider 输出只接受合法 question 或完整 GDD JSON;question 在 `questionCount < questionLimit` 时保存并展示,第 8 个问题仍可展示,第 8 个之后再次返回 question 时只允许一次内部强制出稿重试,额外 question 不落盘、不进入用户等待态。 +- V2 GDD 使用独立 `plan-gdd.v2`,只保留项目身份、版本、游戏内容、决定、原型验证项和指纹,不带旧 Supervisor/Run/delegation 字段。每个版本写入 `.agent/planning-v2/gdd.vN.json`,同步更新 V2 index 和 `game/fast_gdd.md`;旧版本不可覆盖。 +- 新增 `decide_planning_artifact_v2` 与 `plan-approval.v2`。批准、修改、退回均绑定当前 Session、artifact、version 和 fingerprint;修改/退回必须有意见,修改后 Session 回到 `revision_requested`,下一轮由同一 V2 Session 继续。`answerSource` 缺失或未知值回退,不成为单独阻断项。 + +## 2026-09-03 策划会话 Runtime V2 P3 入口与 UI 接入开始 + +- 正式 AGC“做方案”入口在 `planningStartMode` 下直接调用 `start_planning_session_v2`、`continue_planning_session_v2` 和 `decide_planning_artifact_v2`,不再为新策划回合创建 Supervisor root、child Run 或 delegation。 +- 前端以适配层复用现有聊天区、澄清输入卡、GDD 审批卡和阶段进度条;V2 hydrate 返回 `conversation` 消息,用于页面刷新和重启后恢复可见历史。 +- V2 会话使用独立 `planning-session-v2-stream` 事件,旧 Supervisor Runtime 轮询、专业 Agent 轮询和旧 Runtime 事件不会介入 V2 会话。 +- 没有 V2 authority 的项目仍按旧读取路径打开;旧链路封存、未完成旧会话强制失败和旧入口彻底关闭仍留在 P5。 + +## 2026-09-05 策划会话 Runtime V2 P4 收口 + +- Planning V2 的 P4 灰度与回归验收已通过人工校验:正常提问/回答/GDD/批准链路、GDD 修改后再次批准链路、Provider 失败恢复、非法输出失败边界、重启恢复、前端工作台展示以及编码/差异门禁均完成验证。 +- P4 收口不代表旧链路退役;旧 `project-supervisor-plan` / `project-planning` 源码和入口仍保留,旧活跃会话封存、迟到结果隔离、旧入口关闭和只读历史展示统一留在后续 P5。 +- P5 收缩为最小退役:旧 `project-supervisor-plan` caller 统一立即返回退役错误,不启动旧 Runtime/Provider;不扩展 V1 `PlanSessionV1` schema,不做旧数据迁移,旧文件继续保留只读,V2 只读取 `.agent/planning-v2`。 + +## 2026-09-05 修正 Planning V2 流式交互契约 + +- Planning V2 不要求把 Provider 的文本 stream delta 逐条投影为用户可见对话。V2 的用户交互是结构化工具调用结果:`plan_ask_question` 渲染澄清选项卡,`plan_submit_gdd` 渲染 GDD 输出和审批卡;中间纯文本不是用户对话内容。 +- `planning-session-v2-stream` 若继续存在,只能作为内部状态/兼容事件能力,不构成实时逐 delta 的功能契约;Provider 是否使用流式传输不影响 V2 的业务验收。 +- 文档措辞约束:凡出现“流式响应”“流式事件”或 `text_delta`,均须注明其属于 Provider adapter/Runtime 内部实现能力;不得将其描述为前端必须逐条接收的用户可见消息。V2 的唯一用户交互结果是 `plan_ask_question` 和 `plan_submit_gdd` 的结构化工具结果,Provider 完成前是否产生多个 delta 不参与验收。 + ## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复 ## 2026-08-29 AGC 官方 LLM 代理与 Windows 私有路径修复 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index 9d754cb5d..fcaa3b0ff 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -22,14 +22,15 @@ AI 游戏创作 / DirectProject / UI workflow: 1. `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` -2. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md` -3. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` -4. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md` -5. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` -6. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` -7. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` -8. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` -9. UI 编辑器、宿主壳和当前测试专题文档 +2. `docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md` +3. `docs/technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md` +4. `docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md` +5. `docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`(仅存量旧链路) +6. `docs/technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md` +7. `docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md` +8. `docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md` +9. `docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md` +10. UI 编辑器、宿主壳和当前测试专题文档 图片画布 / 媒体生成: diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 763d0eca0..2a7d9dd1b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,35 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-09-05 Planning V2 审批和续跑必须等过项目锁瞬时争用 + +- **现象**:策划 V2 在 GDD 审批提交修改意见后提示 `项目正在被其他写操作占用:...\\.agent\\project.lock`,聊天区再出现 `项目总控 Agent 执行失败,请稍后重试`。 +- **原因**:V1 `decide_plan_gdd_at` / hydrate 已按完整或短窗口等待项目锁。V2 的审批、回合启动、策略落盘和 hydrate 直接 `acquire_project_write_lock`,与 GUI 重灌、刚结束的审批写盘或后台扫描撞车就立刻失败。修订后续跑走 `continue_planning_session_v2`,失败被前端写进总控错误位。这不是锁没释放,也不是 UAC。 +- **处理**:一次性用户意图(审批、回合启动、策略落盘、失败投影、GDD 认领)走完整等待窗口;V2 hydrate 走短窗口。前端 V2 hydrate 对锁争用保持上一份状态,不把瞬时占用画进审批卡。 +- **排查顺序**:先看错误是否点名 `project.lock` 且发生在提交修改意见或立刻续跑;不要当成总控 Runtime 或 Provider 失败。锁文件在失败后通常已被 Drop 删掉,现场缺文件不否定争用。 +- **验证**:Rust 定向覆盖 V2 审批、修订续跑和 hydrate 等过短暂占用的项目锁。 + +## 2026-09-05 新建项目锁不要把继承 DACL 当成 UAC 事件 + +- **现象**:策划 V2 在 GDD 审批提交修改意见时弹出权限窗口,目标是 `Documents\Genarrative GameAgent\gameagent-*\.agent\project.lock`,随后 `AGC ACL 提权修复未成功(exit code Some(1))`。 +- **原因**:#211 把新建 sidecar 纳入私有 DACL 门禁。父目录已是当前用户独占且禁止继承时,刚 `create_new` 的锁文件仍会短暂带继承 ACE;生产路径把这类 DACL 不合格送进 `--repair-private-acl`。独占句柄还会妨碍本进程 `SetNamedSecurityInfoW`。提权再用 `Start-Process -ArgumentList` 数组,含空格路径被拆开,helper 参数个数不对并以 1 退出。这不是 V2 审批协议或 Provider 权限请求。 +- **处理**:本进程新建对象只在进程内收紧 DACL,不因继承 ACE 自动 UAC。项目锁先写入并释放独占句柄,再 harden,回读内容校验后返回;不再对这把新锁走 `prepare_for_read`。UAC 仍留给允许范围内的外人本对象;提权命令行改为一条已加引号的 ArgumentList。 +- **排查顺序**:先看错误是否点名 `project.lock` 且含 `禁止继承` / `exit code Some(1)`;不要当成策划 V2 或 Provider 鉴权问题。含空格的 `Genarrative GameAgent` 项目根是复现条件,不是业务失败。 +- **验证**:Windows 定向覆盖含空格项目根取锁、新锁已满足私有 DACL、Drop 删除,以及提权参数把带空格路径保留为一个 quoted token。 + +## 2026-09-04 Planning V2 不可变 GDD 创建后不能当没提交 + +- **现象**:`gdd.vN.json` 已 create-only 落盘,但 index / Markdown / conversation / session 任一步失败后,session 停在 `provider_failed` 且 `current_artifact_version` 仍指向旧版本。重试会用新 UUID/时间戳再写同一版本号,命中“已存在且内容不同”。 +- **处理**:把该文件当作提交点。恢复时只认领 session 指针的下一个连续版本并补投影,不要删文件,也不要重建 GDD 身份。hydrate 和同一回合重试都必须走这条认领路径。 +- **排查顺序**:先看 `.agent/planning-v2/gdd.vN.json` 是否已存在、再看 `session.json` 的 `currentArtifactVersion` 是否落后;不要为了重试去覆盖不可变文件。 +- **验证**:孤儿文件重试后仍是同一 `gddId`/vN,hydrate 能看到当前产物。 + +## 2026-09-04 DeepSeek thinking 不能与 tool_choice=required 同时使用 + +- **现象**:DeepSeek V4(默认 thinking)对 `tool_choice=required` 或指定函数返回 HTTP 400:`Thinking mode does not support this tool_choice`。 +- **处理**:策划 V2 协议工具固定 `tool_choice=auto`,由 Runtime 校验必须恰好调用 `plan_ask_question` 或 `plan_submit_gdd`。不要按模型名分支,也不要用 required 强行出稿。 +- **验证**:请求体含 `tools` 且 `tool_choice=auto`;无工具调用时走既有非法输出重试。 + ## 2026-09-02 Tauri 事件桥在浏览器预览中必须 fail-safe - **现象**:Vitest/jsdom 挂载 AGC 客户端时,错误报告通知调用 `@tauri-apps/api/event.listen`,因缺少 `window.__TAURI_INTERNALS__` 产生未处理拒绝;测试断言虽通过,CI 仍以 unhandled errors 失败。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3473bffd4..aeb73210c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -183,7 +183,7 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创 - Windows AppData 安全迁移:首次创建客户端 AppData 时必须以进程 `TokenUser` SID 显式设置 owner,并写入当前用户私有 DACL,不能把可能为 Administrators 的 `TokenOwner` 当作用户身份。发现历史目录 owner 不属于当前 `TokenUser` 时,不在原目录上放宽权限,而是拒绝 reparse point / junction / symlink 后,将旧目录原子重命名到同级唯一 `.owner-mismatch-backup-*` 备份,再新建并验证当前用户 owner 与私有 DACL;迁移或备份失败必须失败关闭,不覆盖旧配置。 - Windows 私有文件初始化:父目录已归当前 `TokenUser` 后,新建 `.agent/.manifest.json.lock`、`agent-runner.lock`、endpoint 临时文件、project-owner 诊断临时文件与 real-E2E 私有文件的 owner 仍可能采用 token 默认 owner `Administrators`。manifest 固定锁和 Runner 固定 stale lock 只有在 Windows 不共享独占句柄已取得、且句柄确认普通文件、非 reparse point、链接数为一时才允许初始化或修复为当前 `TokenUser`,随后必须再次复核句柄并按既有 owner/DACL 门禁验证;其它临时文件只允许在本进程 `create_new` 成功且仍持有同一独占句柄时初始化 `TokenUser` owner / DACL,再写入、原子安装并严格复核,初始化失败必须清理刚创建的文件。既有 durable endpoint / diagnostic 读取不得自动接管;活锁不得截断,只有 sharing / lock violation `32/33` 表示占用,access denied 等其它错误立即返回。父进程观察到 Runner 子进程退出后立即返回错误,不等待完整 30 秒 deadline。 -- Windows ACL 提权边界:自定义 `--config-dir` 的启动前置检查必须把 `managed / user-selected` scope 一并传入提权子进程,不能依赖父进程内存中的配置目录覆盖;native picker 返回的文件或项目目录在同一进程登记短时授权,后续导入 / 项目操作只对登记路径(目录可覆盖其后代)允许 `user-selected` 自动提权,直接伪造 IPC 绝对路径不得获得该能力。项目文件列表 / 索引递归逐项拒绝 symlink 与 Windows reparse point,并在 metadata / read 前先完成 ACL 准备。 +- Windows ACL 提权边界:自定义 `--config-dir` 的启动前置检查必须把 `managed / user-selected` scope 一并传入提权子进程,不能依赖父进程内存中的配置目录覆盖;native picker 返回的文件或项目目录在同一进程登记短时授权,后续导入 / 项目操作只对登记路径(目录可覆盖其后代)允许 `user-selected` 自动提权,直接伪造 IPC 绝对路径不得获得该能力。项目文件列表 / 索引递归逐项拒绝 symlink 与 Windows reparse point,并在 metadata / read 前先完成 ACL 准备。本进程刚创建的普通文件或目录只在当前进程收紧 owner / 私有 DACL,不因继承 ACE 自动 UAC;UAC 只修复允许范围内、owner 不属于当前用户的已有对象。提权 `Start-Process -ArgumentList` 必须是一条按 Windows 命令行规则加引号的字符串,不能把带空格路径拆成多个 argv。 - 启动恢复和续跑边界:本条取代上一条中“只有 accepted 才可恢复”的窄口径。若进程在 Supervisor 用户消息已持久、accepted 未持久之间崩溃,只读 preflight 可以把该 `preparing` 识别为可恢复,但不改写 task/conversation;真实 resume 持有 Agent 锁后必须先幂等补写 accepted,再提升为 `pending / queued`。用户消息或 accepted conversation 已落盘而辅助审计失败时,以 conversation 为公开真相继续入队,不留下“已接收但永不执行”的任务;根终态首次公开写入的瞬时失败必须在终态投影后用相同 message ID 重试。receipt / isolated-join 等带 parent 的 Supervisor continuation 不再另写 Session 终态,只保留单一后端公开事件;`runtime-task-*` 与 `runtime-public-status-*` 共享同 run 的不透明关联摘要,秒级时间戳下多个连续任务必须按实际 run 对应的 `user -> accepted -> terminal` 顺序交错展示。 - ready-task 启动活性:`background_task.queued`、`autonomous_ready_task.scheduled`、Runner heartbeat 或执行锁已移交都不等于 child 已启动。实际持有执行权的 Runner 必须在释放项目写锁后同步写入 child 的 running task、`turn.started` 与 started journal,再把已启动 state 和 per-Agent 执行锁交给已确认开始轮询的独立 execution worker;同步启动或 worker 接管失败时,要在仍持有执行锁期间依次把 child 和 manifest Graph 节点明确落为 failed,再释放锁并让 parent 收到调度错误。`autonomous_ready_task.scheduled` 只作诊断审计,其写入失败不能阻断 durable child 启动;external client 只 wake Runner,不在客户端抢占执行。Supervisor 进度卡通过 durable `startedAt`(旧 Run 从完整 task journal 恢复,最新 task-record fallback 保持 0)显示真实持续时间,并以父 Run 与当前关联专业 Agent 的最大事件时间计算运行态活跃度:运行超过 5 分钟无新事件时显示“运行中 · 疑似停滞”和静默时长;等待用户、等待确认、Provider retry、视觉资产、进程会话、pausing 与 paused 不误报。父 Run terminal 后,持续时间冻结在父 Run 自身最后活动,不随 child 晚到收口事件增长。消息时间统一校验为 JavaScript 可表示的 Date;越界值显示“时间未知”且不写无效 `datetime`。实时回复只显示 response stream 自己的 `updatedAt`,缺失时同样显示“时间未知”,不能借用其它 Runtime 活动时间或随前端时钟漂移。该提示只提供可观测性,不改变 Runtime/manifest 正式状态。 - ready-task 对账取消续跑:未知工具结果仍停在 `needs-reconciliation` 且禁止自动重放;人工核对后显式取消原 child,保留 cancel tombstone,旧 child 和旧父 Run 按真实终态收口。若随后创建同 Session、同 Supervisor source、同有效任务语义的 continuation,新完成合同只对同时具有历史 `failed / needs-reconciliation`、最终 `cancelled` 和 durable tombstone 的 ready-task,把当前 manifest 对应 failed 节点恢复为 pending,并由 scheduler 创建全新 child Run。manifest 的读取、failed 筛选、每任务一次的 child journal 索引、证据重验和写回必须位于同一项目写锁域;较新的无 child 根 Run 只有在 durable journal 精确表明为旧 failed Graph 在进入调度前即失败时才能跨过,scheduler 自身失败必须阻断借用更老 tombstone。普通失败、无 tombstone、不同 source/Session/任务语义或证据冲突均保持失败关闭;不得复活旧 pending action、补造 observation 或把取消任务标成 completed。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 7d599f4d2..0ada7c977 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -9,7 +9,7 @@ 当前普通完整构建会从简短需求直接进入 `autonomous-game-build`,用户在消耗完整构建成本前没有正式确认玩法方向、MVP 范围和原型验证项的环节。现有完整构建中的 `design-director` 是只读协调任务,`design-foundation` 又会自行补齐玩法定位;用户意图与后续实现之间缺少可版本化、可审批、可恢复的策划基线。 -本方案新增“立项策划”阶段:用户给出一句需求后,由 Project Supervisor 顶层 root run 通过 `agent.delegate` 发起一个独立 `agentId` 的静态委派子 Agent(`agentId=project-planning`;2026-08-13 起取代原“下游工作流节点由 manifest ready-task 调度器启动”的表述,见第 1.1 节「D11 新拓扑」与 D11),在最多 3 轮决策卡内形成 Fast GDD(该 3 轮上限依赖 WP1——静态委派澄清轮次与返工深度拆分——先落地,见第 1.1 节;落地前实际上限仍是 1 轮);Runtime 校验并提交不可变版本,用户通过审批卡批准、修改或退回。只有不可变 GDD 与对应 approve receipt 同时有效时,后续完整构建才能取得 `approvedGddRef`。 +本方案新增“立项策划”阶段:用户给出一句需求后,由 Project Supervisor 顶层 root run 通过 `agent.delegate` 发起一个独立 `agentId` 的静态委派子 Agent(`agentId=project-planning`;2026-08-13 起取代原“下游工作流节点由 manifest ready-task 调度器启动”的表述,见第 1.1 节「D11 新拓扑」与 D11),在最多 3 轮决策卡内形成 Fast GDD(该 3 轮上限依赖 WP1——静态委派澄清轮次与返工深度拆分——先落地,见第 1.1 节;落地前实际上限仍是 1 轮);Runtime 校验并提交不可变版本,用户通过审批卡批准、修改或退回。审批修改后的 continuation 沿用普通 Planning V2 回合协议,Provider 可以继续问询,也可以直接提交新的完整 GDD;既有问询计数和已确认问答继承,不因修改重置。只有不可变 GDD 与对应 approve receipt 同时有效时,后续完整构建才能取得 `approvedGddRef`。 目标: @@ -389,7 +389,7 @@ Runtime 注入并强校验以下精确结构: ### 5.1 对话循环 -- 最多 3 轮主动追问,每轮只问 1 个主要决定。 +- 最多 3 轮主动追问,每轮只问 1 个主要决定;审批修改后的 continuation 仍沿用同一问询预算,可以继续问询或直接提交新的完整 GDD,问询计数不重置。 - 建议顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。 - 满足任一条件即出稿:用户明确说“直接出稿”;已经完成第 3 轮;剩余问题不影响首个可玩闭环;Runtime 注入 240 秒 Agent 活跃时间软提示。 - `accumulatedAgentMillis` 只累计 Provider 活跃区间,不包含等待用户、等待审批、进程休眠或应用关闭时间。 @@ -790,7 +790,7 @@ input 是当前 GDD 的完整快照,不要求与 source session 的 `decisions | title | 1~80 scalar | | genre.primary / fusion | primary 1~40;fusion 为 `null` 或 1~40 | | artStyle | visualType 1~80;keywords 3~5 个且去重,每项 1~32;其余各 1~400 | -| oneLiner | 45~90 scalar | +| oneLiner | Runtime 实际接受 10~160 scalar;模型 schema 提示 25~90 scalar(有意不对称,不是缺陷或 bug) | | pillars | 2~4 条;name 1~40,其余文本各 1~240;name 唯一 | | coreLoop | 4~8 步,每步 1~120 | | targetUsers | 三个主文本各 1~240;referenceGames 0~5 项,每项 1~80 | diff --git a/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md new file mode 100644 index 000000000..24d9ce051 --- /dev/null +++ b/docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md @@ -0,0 +1,957 @@ +# 策划会话 Runtime V2 接入与旧链路退役方案 + +- 日期:2026-09-03 +- 状态:P0 合同冻结、P1 内核、P2 产物闭环、P3 入口/UI 接入和 P4 灰度回归验收已完成;P5 待实施;本文是新生产实现的目标方案与阶段验收合同 +- 适用范围:AGC 桌面 App 的“做方案”入口、策划会话、GDD 产物与审批 + +> 本文规定新策划 Agent 的生产接入和旧链路退役方式。P3 开始修改正式 AGC 入口与工作台,但旧 `project-supervisor-plan` / `project-planning` 源码仍保留,直到 P5 完成退役;V2 切换时旧链路直接封存,所有未完成旧会话强制失败,旧 Fast GDD 文档之后只作为历史记录依据。 + +## 1. 决策摘要 + +本次不在旧 Supervisor Runtime 上逐条删除门禁,而是新增一个单 Agent 的 `PlanningSessionRuntime`(下称 Runtime V2): + +```text +做方案 + → PlanningSessionRuntime + → 一个策划 Agent Session + → Provider(流式) + → question 或 GDD + → 用户回答 / 审批 + → 同一 Session 继续 +``` + +V2 复用底层能力,但不复用旧策划编排身份: + +- 复用 Provider 连接、Provider 流式传输能力、超时/瞬态重试、会话消息持久化、项目路径边界、单项目并发控制和原子文件写入。这里的“Provider 流式传输能力”只描述底层请求实现,不承诺把每个文本 delta 投影给前端或用户。 +- 不经过 Project Supervisor,不创建 `project-planning` 子 Run,不使用 `agent.delegate`、delivery、continuation、Acceptance Graph 或 acceptance evidence。 +- 当前只启用 `mode=gdd`、最多展示 8 个有效问题、GDD 审批和用户修改。 +- 问询和出稿通过两个协议 function tools(`plan_ask_question` / `plan_submit_gdd`)输出,`tool_choice=required`;Runtime 解析工具参数后归一为 Question/Artifact,不执行工具、不把 `tool_call` 写入会话消息。 +- 当前不启用 MCP、Skill、第三方工具或无限问询;`capabilities.tools/skills` 仍为空,预留未来能力快照。 +- 新旧会话分开持久化,不自动转换旧会话;切换时所有未完成旧会话强制失败;同一项目同一时间只允许一条策划权威会话推进。 + +### 1.1 本次必须达到的结果 + +1. 新的“做方案”入口不再创建 `project-supervisor-plan` 根 Run。 +2. 单个策划 Agent 能在同一会话中完成提问、回答、GDD 生成、审批、修改和退回。 +3. `PLAN_MAX_TURNS=8` 表示最多向用户展示 8 个有效问题;第 8 个问题允许展示,达到 8 后再次调用 `plan_ask_question` 不得展示,内部最多重试一次要求调用 `plan_submit_gdd`。 +4. GDD、非法输出、Provider 请求失败和用户修改不增加有效问题数。 +5. Provider 失败、进程重启或页面重新打开后,不重复已完成的 Provider 副作用,不丢失已经持久化的用户消息和 GDD 版本。 +6. V2 切换时旧链路直接退役;所有未完成旧会话进入明确的 `legacy_retired` 失败状态,旧产物仍可读取。 + +### 1.2 明确不做 + +- 本次不实现无限多轮产品能力;只保证会话计数和上下文接口不把未来轮次锁死。 +- 本次不接入 MCP、Skill、第三方工具、工具审批或工具恢复。 +- 本次不实现上下文自动摘要、向量检索或无限历史存储;只分离完整会话记录与 Provider 请求上下文。 +- 本次不绑定批准 GDD 与后续做游戏的 `approvedGddRef`。 +- 本次不改做游戏/做素材的 DirectProject 路由。 +- 本次不删除旧 Runtime 源码、旧测试或旧 `.agent/planning` 产物。 +- 本次不保证未完成旧会话继续运行、继续问询或继续审批;切换后它们只能查看历史记录。 +- 本次不把 V2 做成 Python 子进程;生产实现仍在 AGC 客户端 Rust/Tauri 侧。 + +### 1.3 问询策略与 Runtime 门禁的不对称设计 + +**模型提示最多 3 轮、Runtime `question_limit` 默认 8,是明确的分层设计,不是缺陷、bug、配置漂移或校验遗漏。** 两个数值约束不同层面的行为,不要求统一成单一配置来源。 + +- **模型侧策略为最多 3 轮**:提示词明确要求 `question_count >= 3` 时整理已有信息并调用 `plan_submit_gdd`;信息足够时允许 0~2 轮提前出稿。第 3 轮后的出稿指令是有意的策略约束,不是应当放宽为“必要时继续问到 8 轮”的软目标。 +- **Runtime 侧门禁为最多 8 个有效问题**:`question_limit=8` 表示运行时最多接受并展示多少个问题。模型偏离 3 轮策略、继续返回 question 时,Runtime 不因超过 3 而直接拒绝;仍按现有格式和业务门禁判断,达到 8 后才拒绝新的 question。这是对模型输出保留的容忍空间。 +- **Session/UI 中的 8 表示容量,不是配额或出稿前置条件**:不承诺正常路径会问满 8 轮,也不要求模型必须能够按当前提示一路问到第 8 轮。按策略在 3 轮或更早生成 GDD,是正常行为;不能仅凭未问满 8 轮推断 GDD 质量不足。 +- **评审与自动评测口径**:应分别核对模型是否收到 3 轮收敛指令、Runtime 是否按 `question_limit` 执行门禁。两者数值不同本身不构成问题;不得据此把提示词改成由 `session.question_limit` 决定何时出稿,也不得把 Runtime 门禁收紧到 3。`questionCount=3` 的提示要求出稿与 Runtime 仍可接受合法 question 可以同时成立;`questionCount=8` 时 Runtime 拒绝新 question。 + +以上说明解释现有实现,不修改提示词、条件分支、默认值、校验或测试逻辑。 + +## 2. 当前生产链路与迁移原因 + +当前“做方案”生产路径是旧 Supervisor 链路: + +```text +planningStartMode + → project-supervisor-plan 根 Run + → agent.delegate + → project-planning 子 Run + → planning_coordinator + → plan.submit_gdd + → acceptance evidence / claim + → GDD approval pending +``` + +现役入口和身份绑定主要分布在: + +- `apps/ai-game-creator-shell/src/App.tsx` +- `apps/ai-game-creator-shell/src/features/agent-runtime/model.ts` +- `apps/ai-game-creator-shell/src-tauri/src/commands.rs` +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs` +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs` +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs` +- `apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs` + +旧链路的复杂度不是单一校验,而是多组相互依赖的身份与编排事实: + +- Supervisor 根 Run、`project-planning` 子 Run、父子 Run Profile; +- `agent.delegate`、delivery、continuation lineage 和 claim journal; +- `plan.submit_gdd` 的 child 身份限制; +- GDD 审批前的 Acceptance Graph / acceptance evidence; +- `project-supervisor-plan` source 与 active root 判定; +- 前端运行态、子 Agent 状态和审批 pending 的联动。 + +如果在旧 Runtime 内逐个放宽这些限制,容易出现“提示词已经单 Agent、执行层仍要求 Supervisor/child 身份”的半迁移状态,典型结果是身份不匹配、错误恢复或 `needs-reconciliation`。因此 V2 以新会话运行内核接入;旧链路不再作为可推进的运行时,只在切换时被封存并保留历史文件。 + +## 3. V2 目标架构 + +### 3.1 分层 + +```text +┌──────────────────────────────────────────┐ +│ PlanningSessionRuntime │ +│ - 会话状态 / 回合生命周期 │ +│ - 单 Agent 调用 │ +│ - question / artifact 结果分流 │ +│ - 恢复、计时、失败投影 │ +└──────────────────────────────────────────┘ + │ +┌──────────────────────────────────────────┐ +│ PlanningPolicy │ +│ 当前:GddPlanningPolicy │ +│ - 有效问题计数 │ +│ - question 上限 │ +│ - GDD 结构与版本 │ +│ - 审批 / 修改 / 退回 │ +└──────────────────────────────────────────┘ + │ +┌──────────────────────────────────────────┐ +│ Shared Provider / Session substrate │ +│ - provider-neutral request/stream │ +│ - 会话消息落盘 │ +│ - 项目锁、原子写、超时、基础重试 │ +│ - 上下文构建接口 │ +└──────────────────────────────────────────┘ +``` + +Runtime V2 不负责解释 GDD 字段;`GddPlanningPolicy` 也不负责 Provider 连接、文件锁或未来 MCP 进程。 + +### 3.2 当前与未来能力的边界 + +当前配置快照: + +```json +{ + "mode": "gdd", + "questionLimit": 8, + "capabilities": { + "tools": [], + "skills": [] + } +} +``` + +未来可以在不改会话核心的情况下扩展为: + +```json +{ + "mode": "conversation", + "questionLimit": null, + "capabilities": { + "tools": ["mcp.example.search"], + "skills": ["planning-research.v1"] + } +} +``` + +`questionLimit`、`mode` 和 `capabilities` 是策略/能力快照,不是 Runtime 的硬编码身份。当前空能力集合不意味着未来消息格式只能承载普通文本。 + +### 3.3 一次回合的统一结果 + +Provider 回合在 Runtime 内统一归一为以下结果之一: + +```text +Question(question) +Artifact(artifact) +ToolCall(toolCall) # 仅保留消息/事件类型;当前不写入 conversation +AssistantText(text) # 当前策略视为非法输出;未来可由 conversation 模式使用 +``` + +Provider 请求携带 `plan_ask_question` 与 `plan_submit_gdd`,`tool_choice=required`。`GddPlanningPolicy` 只接受恰好一个已知工具,并将其参数归一为 `Question` 或 `Artifact(kind=gdd)`。正文 JSON、多个工具或未知工具不写成成功产物;按输出重试策略处理,超过重试上限后进入可恢复失败状态。 + +## 4. 会话与状态合同 + +### 4.1 V2 会话快照 + +P0 冻结为 `planning-session.v2`: + +```json +{ + "schemaVersion": "planning-session.v2", + "engine": "planning-session-v2", + "sessionId": "ps-...", + "projectId": "...", + "mode": "gdd", + "status": "awaiting_approval", + "turnIndex": 3, + "questionCount": 2, + "questionLimit": 8, + "revisionCount": 0, + "currentArtifactVersion": 1, + "currentQuestion": null, + "capabilities": { + "tools": [], + "skills": [] + }, + "processingSeconds": 123.45, + "createdAtUtc": "2026-09-03T00:00:00Z", + "updatedAtUtc": "2026-09-03T00:02:03Z", + "lastError": null +} +``` + +约束: + +- `turnIndex` 是会话回合序号,不限制为 8 或 3;未来无限对话仍可继续递增。 +- `questionCount` 只统计已经展示给用户的有效 question。 +- `questionLimit` 由策略读取;当前值为 8,未来无限模式可为 `null`。 +- `revisionCount` 统计用户对当前产物发起的修改次数,不并入 questionCount。 +- `capabilities` 记录本会话可用能力快照;当前 `tools` 和 `skills` 必须为空数组。 +- `sessionId`、`projectId`、`createdAtUtc` 和 `updatedAtUtc` 是必填身份/时间字段;`lastError` 只保存安全错误分类和短摘要,不保存 Provider 原文、凭据或本地绝对路径。 + +V2 会话 `status` 枚举冻结为: + +```text +idle | planning | awaiting_user | awaiting_approval | +revision_requested | approved | rejected | provider_failed +``` + +`legacy_retired` 只属于旧链路封存投影,不写入 V2 Session。 + +### 4.2.1 V2 消息记录 + +完整会话记录使用 `planning-message.v2`,与 Provider 请求上下文分离: + +```json +{ + "schemaVersion": "planning-message.v2", + "messageId": "msg-...", + "clientTurnId": "turn-...", + "turnIndex": 3, + "atUtc": "2026-09-03T00:02:03Z", + "role": "assistant", + "kind": "question", + "payload": {} +} +``` + +`role` 冻结为 `user | assistant | system | tool`;`kind` 冻结为 `text | question | artifact | tool_call | tool_result | skill_reference | error`。当前 GDD 策略只把成功结果写成 `question`、`artifact` 和失败 `error`;协议工具只存在于 Provider 请求/响应,不把 `tool_call`/`tool_result` 写入 `conversation.jsonl`。未来启用 MCP/Skill 时使用已有 kind,不把工具结果伪装成普通 assistant 文本。 + +### 4.2.2 回合结果与能力快照 + +Provider 适配层输出 `planning-turn-result.v2`: + +```json +{ + "schemaVersion": "planning-turn-result.v2", + "kind": "question", + "payload": {} +} +``` + +`kind` 冻结为 `question | artifact | assistant_text | tool_call | error`。协议工具解析成功后,`GddPlanningPolicy` 只落盘 `question` 或 `artifact(kind=gdd)`;正文 JSON 和其它结果按非法输出处理。 + +能力快照冻结为: + +```json +{ + "tools": [], + "skills": [] +} +``` + +数组元素为稳定能力标识,必须去重并保持稳定排序;当前不得由模型修改。未来启用能力时由客户端/宿主在新回合开始前注入并重新记录快照。 + +### 4.2.3 当前策略的 payload 形状 + +`planning-message.v2` 和 `planning-turn-result.v2` 中的 `question` payload 冻结为: + +```json +{ + "id": "core_loop", + "header": "当前要决定:核心回路", + "question": "玩家每一局主要反复做什么?", + "options": [ + { + "label": "工作台设计迭代", + "description": "先设计,再验证并回到编辑器修改。" + }, + { + "label": "直接战斗验证", + "description": "先进入战斗,再根据结果调整设计。" + } + ] +} +``` + +当前策略只要求 `options` 为 2~4 项、label/description 非空;不要求 A/B/“需要原型验证”固定顺序,也不把 `answerSource` 作为阻断条件。`question.id` 只作为当前问题标识,用户回答必须绑定当前 question 的 id。 + +V2 决定状态使用 `confirmed | assumption_pending | prototype_pending`:`assumption_pending` 专门表示 Agent 根据上下文补出的、尚未被用户明确决定的内容,来源使用 `answerSource=agent_inferred`;`prototype_pending` 表示需要通过原型验证的决定,通常来源为用户选项或用户修改。V1 的 `default_pending` / `answerSource=default` 不属于 V2 语义。 + +`artifact` payload 冻结为通用产物包: + +```json +{ + "artifactId": "artifact-...", + "kind": "gdd", + "version": 1, + "status": "ready_for_approval", + "fingerprint": "sha256-...", + "payload": { + "schemaVersion": "plan-gdd.v2", + "game": {}, + "decisions": [], + "prototypeValidationItems": [] + } +} +``` + +`kind` 当前只有 `gdd`;未来增加其它产物类型时沿用同一产物包,不把会话状态改造成某一种产物的字段集合。`version` 在同一 V2 Session 内严格递增,`status` 当前使用 `ready_for_approval | approved | revision_requested | rejected | superseded`。 + +### 4.2.4 V2 command DTO + +四个 command 的输入/输出边界冻结如下: + +| command | 必要输入 | 返回 | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `start_planning_session_v2` | `projectPath`、`clientTurnId`、`prompt`、可选 `mode` | V2 Session 及本次回合结果 | +| `continue_planning_session_v2` | `projectPath`、`sessionId`、`clientTurnId`、`input` | 更新后的 Session 及本次回合结果 | +| `decide_planning_artifact_v2` | `projectPath`、`sessionId`、`artifactId`、`version`、`fingerprint`、`decisionId`、`action`、可选 `comment` | 审批结果和最新 Session 状态 | +| `hydrate_planning_session_v2` | `projectPath`、可选 `sessionId` | 只读 V2 Session、当前 question、当前产物、错误摘要和完整 `conversation` 消息 | + +`input` 冻结为 `option`、`freeform`、`direct_draft`、`revision` 四种用户意图: + +```json +{ + "kind": "option", + "questionId": "core_loop", + "optionIndex": 1, + "optionLabel": "工作台设计迭代", + "text": "按 A 做" +} +``` + +Runtime 可以把 `A/B/C/D`、`1/2/3/4`、完整 label、“按 A 做”和“按第一个选项做”归一为 `optionIndex + optionLabel`;无法确定时才保留 `freeform`,不由旧 Supervisor 规则阻断。 + +`action` 冻结为 `approve | revise | reject`。所有 command 都只接受项目路径、V2 Session/turn/artifact 身份和用户输入,不接受 Supervisor root、child Run、delegation、claim 或 acceptance evidence 字段。 + +### 4.3 状态 + +```text +idle + → planning + → awaiting_user + → planning + → awaiting_approval + → approved + +awaiting_approval + → revision_requested + → planning + +awaiting_approval + → rejected + +planning + → provider_failed +``` + +`provider_failed` 是可恢复的失败投影,不代表 GDD 被拒绝;重试时必须沿用当前 Session 和未完成的用户意图。`needs-reconciliation` 不作为 V2 的正常业务状态;只有发生不可判断的持久化冲突时,才进入单独的恢复错误并阻止自动覆盖。 + +### 4.4 问题上限语义 + +`PLAN_MAX_TURNS=8` 的准确语义:最多向用户展示 8 个有效问题,而不是最多调用 Provider 8 次。 + +```text +questionCount=7,本次返回 question +→ 保存并展示 +→ questionCount=8 + +questionCount=8,本次返回 GDD +→ 正常接受,不增加 questionCount + +questionCount=8,本次返回 question +→ 不保存、不展示、不进入 awaiting_user +→ 追加内部提示“不能再提问,直接根据已有信息出 GDD” +→ 最多重试 1 次 +``` + +其它规则: + +- 非法工具输出/GDD 不增加 `questionCount`。 +- Provider 请求失败不增加 `questionCount`,也不创建 GDD 版本。 +- 用户修改不受 `questionLimit` 限制,但修改回合仍不能再次向用户展示 question;若 Provider 返回 question,按一次内部出稿重试处理。 +- 达到内部输出重试上限后,保留当前会话和错误摘要,允许用户再次提交或恢复,不伪造 GDD。 +- 瞬态 Provider 故障不直接判死:timeout、connectivity、transport、空响应、反序列化失败、流式中途断连和上游 408/429/5xx 统一按主 Agent Runtime 同款分类判定为瞬态,会话层沿用该 Agent 的 `maxRetries` / `retryBackoffMs` 预算做指数退避自动重试本回合;重试不增加 `questionCount`、不产生 GDD 版本,debug `attempt-N` 随每次物理尝试递增。耗尽后才投影 `provider_failed`,错误摘要附带已重试次数;上游 4xx 等硬错误不进入该重试,直接 `provider_failed`。 +- `tool_choice=required` 依赖端点支持:部分模型或端点可能不接受该取值(例如思考模式下的 DeepSeek 会以 400 “Thinking mode does not support this tool_choice” 拒绝);这类 4xx 硬错误不进入瞬态重试,会话直接 `provider_failed`。接入或切换模型时必须先在目标端点验证 `required` 可用,再接入策划会话。 + +## 5. Provider、上下文与未来 MCP/Skill 兼容性 + +### 5.1 Provider 适配边界 + +`GddPlanningPolicy` 不直接构造 OpenAI/Anthropic 请求体。Runtime 只调用 provider-neutral 接口: + +```text +build_request(context, capability_snapshot, policy_hint) +start_stream(request) +collect_stream_events() +normalize_turn_result() +``` + +当前实际 Provider 配置、Responses 流式格式、超时、瞬态重试和凭据边界沿用 AGC 现有 Provider substrate;V2 不改变当前 Provider 路由,也不引入新的第三方模型协议。 + +P0 冻结适配器的四个边界对象: + +| 对象 | Runtime 可见字段 | 约束 | +| ------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| `PlanningProviderRequestV2` | `sessionId`、`turnIndex`、`mode`、`policyHint`、`messages`、`capabilities` | 不携带 Supervisor/child/delegation 身份;具体 API kind、URL 和凭据由 Provider substrate 持有 | +| `PlanningProviderStreamEventV2` | `type=text_delta\|completed\|failed`、可选 `text`/`result`/`error` | 仅为 adapter/Runtime 内部的 Provider-neutral 事件;Provider 原始协议在 adapter 内归一,Runtime 不解析 OpenAI/Anthropic 私有字段,也不把 `text_delta` 视为前端业务事件 | +| `ContextBuildResultV2` | `messages`、`estimatedTokens`、`overflow` | 完整会话记录不等于请求上下文;`overflow=true` 时显式失败,不静默丢历史 | +| `CapabilitySnapshotV2` | `tools`、`skills` | 稳定排序、去重;当前必须为空,由宿主注入,模型不能修改 | + +Provider adapter 只负责“请求、内部流式事件归一、稳定错误、用量/耗时”;是否接受 question/GDD、是否计入问题数和是否生成审批由 `PlanningPolicy` 决定。Adapter 的 `text_delta` 可以被 Runtime 收集、丢弃或作为诊断/兼容状态使用,不构成前端逐 delta 推送义务。 + +### 5.2 完整会话与请求上下文分离 + +```text +conversation.jsonl(完整事实记录) + │ + ▼ +ContextBuilder(本次请求上下文) + │ + ├─ 当前策略提示 + ├─ 关键决定/当前产物 + ├─ 最近消息窗口 + └─ 未来摘要/工具结果/Skill 引用 +``` + +当前实现可以先按有界完整历史构建请求,但必须通过 `ContextBuilder` 接口进入 Provider,不能把磁盘 JSONL 直接当作永久请求格式。当前不实现摘要;如果配置的上下文预算不足,应返回明确的上下文超限错误,不静默丢弃历史。未来再增加摘要或分页时,不改变持久化消息事实。 + +### 5.3 MCP / Skill 预留而不提前执行 + +当前只做数据边界: + +- Session 保存 `capabilities.tools` 与 `capabilities.skills` 的快照,当前为空。 +- 消息模型预留 `tool_call`、`tool_result`、`skill_reference` 类型,当前不产生。 +- Provider 请求接受能力快照参数,当前不向模型广告工具。 +- 能力启用由客户端/宿主决定,模型不能自行开启 MCP 或加载 Skill。 + +当前不做 MCP 进程管理、Skill 安装、工具权限、工具审批、外部副作用账本或工具恢复。 + +## 6. 持久化与新旧并存 + +### 6.1 V2 目录 + +V2 使用独立目录,避免被旧 `planning_storage.rs` 的 Supervisor 身份校验读取: + +```text +.agent/planning-v2/ +├─ session.json +├─ conversation.jsonl +├─ index.json +├─ gdd.v1.json +├─ gdd.v2.json +├─ debug/ +│ ├─ /requests/attempt-*.json +│ ├─ /responses/attempt-*.json +│ └─ /events.jsonl +└─ approvals/ + ├─ v1.json + └─ v2.json +``` + +`debug//` 只保存每次 Provider 尝试的请求、原始响应和解析分类,供失败诊断使用;它不进入会话上下文,不参与恢复、重试或 GDD 业务判断。诊断产物写入失败不改变主流程结果。 + +继续生成同一用户可见路径: + +```text +game/fast_gdd.md +``` + +该路径是当前 UI 和后续“做成游戏”入口的稳定交付面;V2 写入时必须使用项目写锁、临时文件和原子替换。 + +`gdd.v{N}.json` 的 create-only 写入是提交点。index、`game/fast_gdd.md`、conversation 和 session 指针都是投影:任一投影失败不得回滚已创建的 GDD,也不得用新的 UUID/时间戳重写同一版本。hydrate 与同一回合重试必须认领 session 指针的下一个连续版本并补投影;只有磁盘上还不存在该版本文件时,才根据本轮入参新建。 + +### 6.2 V2 GDD 与审批 + +P0 冻结 V2 GDD 使用 `plan-gdd.v2`,只保存业务内容和 V2 自身身份: + +```json +{ + "schemaVersion": "plan-gdd.v2", + "version": 1, + "createdAtUtc": "...", + "game": {}, + "decisions": [], + "prototypeValidationItems": [], + "fingerprint": "..." +} +``` + +不写入旧链路字段: + +```text +rootAgentId +rootRunId +delegationId +runProfile +runProfileBindingFingerprint +sourceSessionRevision +createdByRunId +``` + +P0 冻结审批记录绑定 `version + fingerprint`,并使用 `plan-approval.v2`: + +```json +{ + "schemaVersion": "plan-approval.v2", + "version": 1, + "gddFingerprint": "...", + "action": "approve", + "comment": "", + "atUtc": "..." +} +``` + +### 6.3 新旧会话路由 + +同一项目只允许一个活跃策划权威。V2 切换包含一次项目级“旧链路封存”操作,路由规则如下: + +| 项目状态 | 新请求路由 | +| ----------------------------------------- | ----------------------------------------------------------------------------- | +| 已有活跃 V2 会话 | 继续 V2;同一会话只允许一个在途 Provider 回合 | +| 只有旧 `project-supervisor-plan` 活跃会话 | 切换时将旧会话投影为 `legacy_retired` 失败;用户需重新创建 V2,不再继续旧链路 | +| 没有活跃策划会话 | 创建 V2 | +| 旧会话已终态、用户明确重新做方案 | 创建 V2,不改写旧目录 | +| 同时发现旧/V2 活跃会话 | 旧会话优先封存为 `legacy_retired`,只保留 V2 推进 | + +不做旧 session → V2 的自动转换。原因是两套身份、计数、审批和 GDD schema 不同,自动转换会把旧 pending 或旧 approval receipt 混入 V2。 + +### 6.4 旧链路一次性封存 + +切换由客户端在项目写锁内执行一次: + +1. 读取旧 `.agent/planning/session.json` 的当前阶段;`collecting`、`awaiting_user_input`、`awaiting_gdd_approval`、`revision_requested` 和 `recovery_required` 均视为未完成。 +2. 不改写旧 `plan-session.v1` 的字段形状;在 `.agent/planning-v2/legacy-cutover.json` 写入旧 session 指纹、原阶段、切换时间和 `state=legacy_retired`。 +3. V2/前端 hydrate 看到该标记后,把旧会话显示为“旧策划链路已退役(失败)”,禁止继续问询、审批、恢复或创建旧 continuation。 +4. 已经在途的旧 Provider 结果只允许落诊断,不得写入新的旧 GDD、approval receipt 或 `game/fast_gdd.md`;切换后的新写入只由 V2 负责。 +5. 旧 `gdd.v*.json`、旧 `index.json`、旧 approval receipt 和旧 conversation 仍保留只读,不删除、不改写、不迁移。 + +旧 session 缺失或损坏时,也不尝试修复后继续;写入 `legacy_retired` 封存标记并阻止旧入口,避免把不可判断的旧状态带入 V2。 + +## 7. 前端与命令接入 + +### 7.1 入口分流 + +目标分流: + +```text +做方案 → PlanningSessionRuntime V2 +做游戏 → DirectProject +做素材 → DirectProject +``` + +`planningStartMode` 仍可作为首页到工作台的入口标记,但它不再映射到 `project-supervisor-plan`。首轮提交、后续回答、审批意见和恢复都调用 V2 命令。 + +### 7.2 V2 命令边界 + +P0 冻结新增以下独立命令: + +```text +start_planning_session_v2 +continue_planning_session_v2 +decide_planning_artifact_v2 +hydrate_planning_session_v2 +``` + +命令职责冻结为: + +- `start_planning_session_v2`:创建或幂等启动 V2 Session,并提交首条用户需求。 +- `continue_planning_session_v2`:提交用户对 question 的回答,或提交审批修改意见后的修订指令;重启恢复不单独创建 `resume` 命令。 +- `decide_planning_artifact_v2`:提交当前最新产物的批准、修改或退回决定。 +- `hydrate_planning_session_v2`:返回 V2 Session、当前产物和当前等待态;若发现已提交但未投影的连续 GDD 版本,在项目写锁内认领并补投影。 + +命令只接收项目路径、Session 标识、稳定 client turn、用户文本/选项和 V2 产物身份;不接收或生成 Supervisor 根 Run、delegation、acceptance evidence 等字段。 + +### 7.3 UI 复用边界 + +第一版可复用现有: + +- `ProjectSupervisorView` 的聊天区域和工作台布局; +- `GddApprovalCard` 的产物展示与审批交互; +- 现有耗时展示和会话历史加载。 + +但数据来源必须改为 V2 状态,不再把“页面组件叫 Supervisor”当作运行时身份。后续再把组件重命名为 `PlanningSessionView`,不作为本次切换前置。 + +## 8. 最小安全与业务校验 + +### 8.1 保留 + +- 项目路径必须属于当前用户打开的项目。 +- 单项目单活跃策划回合;重复提交同一 client turn 必须幂等。 +- Provider 超时、瞬态失败和稳定错误摘要。 +- 输出 JSON 可解析;当前 GDD 必填业务字段、长度和基本类型合法。 +- GDD 版本严格递增,文件写入原子化。 +- 审批必须绑定当前最新 GDD 的版本和 fingerprint。 +- 旧审批不能覆盖新 GDD;Provider 失败不能伪造成成功。 +- 重启后可以恢复当前 Session,不重复已经提交成功的消息/产物。 + +### 8.2 不迁移 + +- Supervisor 根/子 Run 身份和 parent binding fingerprint。 +- `agent.delegate`、delivery、continuation、claim journal。 +- Acceptance Graph、acceptance evidence、Supervisor claim gate。 +- `project-supervisor-plan` source。 +- 固定 A/B/“需要原型验证”三选一硬协议。 +- `answerSource` 作为阻断条件。 +- 3 轮硬限制。 + +未来 MCP/Skill 的工具权限校验属于能力执行层,不重新引入上述 Supervisor 身份模型。 + +## 9. 阶段任务拆分 + +阶段按“先冻结合同,再做内核,再切入口,最后退役”执行。每个阶段完成后才进入下一阶段;阶段之间不要求一次性重写旧链路。 + +### P0:V2 合同冻结(文档与接口设计) + +目标:把 V2 与旧链路的边界写成开发可执行合同。 + +状态:已完成(2026-09-03)。P1 可以按本节冻结内容开始编码。 + +任务: + +| ID | 任务 | 产出 | +| ---- | -------------------------------------------- | ----------------------------------- | +| P0-1 | 冻结 Session、消息、回合结果、产物和审批 DTO | V2 schema、字段枚举和版本策略 | +| P0-2 | 冻结状态机、`questionLimit` 语义和重试规则 | 状态转移表、错误边界 | +| P0-3 | 冻结新旧并存与同项目单权威规则 | 路由/恢复决策表 | +| P0-4 | 冻结 Provider/ContextBuilder/Capability 插槽 | provider-neutral adapter 和模块边界 | + +阶段验收: + +- 产品、前端、Runtime 对“第 8 个问题”和“第 9 次 question”能按同一例子解释。 +- 文档中不再出现“V2 先复用旧 Supervisor 再逐项放宽”的实现路径。 +- 能明确区分完整会话记录、Provider 请求上下文、GDD 产物和审批记录。 +- 明确旧会话如何封存、何时创建 V2,以及如何拒绝旧/V2 双活。 +- 已冻结 `planning-session.v2`、`planning-message.v2`、`planning-turn-result.v2`、`plan-gdd.v2` 和 `plan-approval.v2` 的版本名及核心字段。 +- 已冻结 `start/continue/decide/hydrate` 四个 V2 command 的职责;恢复不另建 resume command。 +- 已冻结 `questionLimit=8`、内部 question 重试上限为 1、Provider 失败/非法输出不占 questionCount 的规则。 +- 已冻结 `.agent/planning-v2/legacy-cutover.json` 的旧链路封存边界和 `legacy_retired` 投影语义。 +- 已冻结当前 `capabilities.tools/skills=[]`,未来能力通过能力快照和消息 kind 扩展,不修改 Session 根结构。 + +依赖:无。完成后才能开始 P1。 + +### P1:PlanningSessionRuntime 内核 + +状态:实现已落在 AGC Tauri Runtime(2026-09-03)。当前仅提供内核闭环,尚未接入 GDD 解析、审批或生产 UI。 + +已落地的 P1 入口:`start_planning_session_v2`、`continue_planning_session_v2`、`hydrate_planning_session_v2`。会话快照和完整消息分别持久化到 `.agent/planning-v2/session.json` 与 `.agent/planning-v2/conversation.jsonl`;Provider 请求沿用现有 `platform-llm`,按配置执行流式或普通调用。流式传输只是 Provider 调用实现细节,不属于策划用户交互契约;用户可见结果以 `plan_ask_question` / `plan_submit_gdd` 工具调用及其结构化结果为准。 + +当前 P1 的恢复语义是轻量且显式的:进程退出时若快照仍为 `planning`,hydrate 将其投影为 `provider_failed/RECOVERY_REQUIRED`,要求用户重新提交当前意图;不会伪造成功或自动制造 GDD。`clientTurnId` 命中已有成功 assistant 记录时直接等值重放;若只有 error 记录,则沿用原用户意图重试且不重复追加用户消息。 + +目标:在不包含 GDD 业务规则的情况下,跑通单 Agent 会话、Provider 调用(兼容流式或普通模式)、持久化和恢复。用户可见的完成标准是结构化工具结果,不是中间文本 delta 的到达频率。 + +任务: + +| ID | 任务 | 产出 | +| ---- | ------------------------------------------ | --------------------------------------- | +| P1-1 | 新建 V2 Session 生命周期与单项目并发控制 | `planning_session_v2` Rust 模块 | +| P1-2 | 接入现有 Provider substrate 和内部流式事件归一 | provider-neutral request/stream adapter;不产生前端逐 delta 契约 | +| P1-3 | 实现消息 JSONL、回合身份和幂等写入 | `conversation.jsonl` 及 turn identity | +| P1-4 | 实现 ContextBuilder 初版 | 有界历史构建;超限显式失败 | +| P1-5 | 实现 Provider 失败/中断/重启恢复 | Session 不丢消息、不伪造成功 | +| P1-6 | 记录 `turnIndex`、处理耗时和安全错误摘要 | Session 快照、诊断字段 | + +阶段验收: + +- 真实 Provider 可以完成一轮工具调用,前端收到结构化 question 或 GDD 结果并在终态落盘。 +- 同一 `clientTurnId` 重试不会重复追加用户/助手消息。 +- 同一项目第二个在途回合被拒绝,原回合不受影响。 +- Provider 失败后 Session 保留,恢复不会自动制造新问题或 GDD。 +- 重启后能读取完整会话记录;请求上下文不依赖前端临时内存。 +- P1 不包含 `agent.delegate`、Supervisor root、GDD 校验或审批逻辑。 + +依赖:P0。 + +### P2:GddPlanningPolicy 与 V2 产物闭环 + +状态:核心实现已落地(2026-09-03)。当前已支持 question/GDD 解析、最多 8 个有效问题、达到上限后的单次强制出稿、`plan-gdd.v2` 版本文件、`game/fast_gdd.md` 投影和 `plan-approval.v2` 审批记录;审批修改后的下一轮仍由同一 V2 Session 继续。策略校验完成前的流式内容只在回合成功后对外转发,达到上限而被丢弃的 question、非法输出和重试内容不会泄露给调用方。等待审批时不能直接提交新的策划输入。P3 仍需把正式“做方案”入口和现有 UI 切到这些 command。 + +已落地入口:`decide_planning_artifact_v2`。`answerSource` 缺失或未知值按当前状态回退为 `user_freeform` / `user_option` / `agent_inferred`,不作为单独阻断项;GDD 结构、必填业务字段、版本、指纹和当前项目身份仍必须合法。V2 不接受或生成 V1 的 `default_pending` / `default` 语义。 + +目标:把新版原型的策划行为落到生产 V2,不把旧 Supervisor 协议带回来。 + +任务: + +| ID | 任务 | 产出 | +| ---- | ------------------------------------------ | ---------------------------------------- | +| P2-1 | 实现 question / GDD 结果解析 | 只接受当前策略需要的结果 | +| P2-2 | 实现最多 8 个有效问题的策略计数 | `questionCount` 与 `turnIndex` 分离 | +| P2-3 | 实现达到上限后的单次强制出稿重试 | 不保存/展示额外 question | +| P2-4 | 实现 V2 GDD schema、版本链和 `fast_gdd.md` | `.agent/planning-v2/**` 与 Markdown | +| P2-5 | 实现审批、修改、退回 | `plan-approval.v2` 与新版本生成 | +| P2-6 | 实现轻量输入归一化 | A/B/编号/完整 label/“按第一个选项做”映射 | + +阶段验收: + +- 0 轮直出 GDD 可保存并进入审批。 +- 第 8 个有效问题可展示;第 8 个问题后模型再次返回 question 时,用户看不到该问题,内部最多重试一次并要求出 GDD。 +- 非法 JSON/GDD、Provider 失败不增加 `questionCount`。 +- 审批“批准”产生 approved 状态;“修改”产生新 GDD 版本且旧版本只读;“退回”不伪造批准。 +- `answerSource` 即使缺失或使用等价值,也不会成为唯一阻断原因;结构和业务字段仍需合法。 +- V2 GDD 不包含旧 Supervisor 身份字段。 +- 不出现固定三选一或 `project-planning` child 合同。 + +依赖:P1。 + +### P3:生产入口与 UI 接入(实施中) + +目标:让用户从正式“做方案”入口使用 V2,同时保持现有页面可用。 + +任务: + +| ID | 任务 | 产出 | +| ---- | -------------------------------------------- | ------------------------------ | +| P3-1 | 新增 V2 Tauri command 注册和前端 invoke 封装 | 命令可启动/恢复/审批 | +| P3-2 | 将 `planningStartMode` 路由到 V2 | 新项目不创建旧 Supervisor root | +| P3-3 | 复用审批卡并切换到 V2 hydrate 状态 | GDD 展示、版本和审批按钮正常 | +| P3-4 | 加入当前运行态、流式回复和耗时展示 | 页面可见状态与 Session 一致 | +| P3-5 | 识别旧项目并准备封存投影 | 存量旧会话不被误路由到 V2 | + +阶段验收: + +- 新项目点击“做方案”后,持久化目录是 `.agent/planning-v2/`,不产生新的 `project-supervisor-plan` 或 `project-planning` Run。 +- 前端能展示 question、接收用户答案、展示 GDD 并完成审批。 +- 刷新页面/重启 App 后可以恢复 V2 当前等待态。 +- 做游戏、做素材入口行为与改造前一致。 +- 旧活跃策划项目不会与 V2 双活;切换封存后显示明确失败并要求重新创建 V2。 + +依赖:P2。 + +当前实现进度(2026-09-03): + +- 正式 `planningStartMode` 首轮提交已改走 `start_planning_session_v2`,后续回答改走 `continue_planning_session_v2`,审批改走 `decide_planning_artifact_v2`;不再为新策划回合创建 Supervisor Run。 +- 前端通过 V2 适配层复用现有聊天区、澄清卡、GDD 审批卡和阶段进度条;V2 Session 的 hydrate 结果额外携带 `conversation`,用于刷新/重启恢复历史消息。 +- V2 只将结构化工具调用结果投影到澄清选项卡和 GDD 审批卡;旧 Runtime 轮询、专业 Agent 轮询和旧 Runtime 事件在 V2 策划会话中关闭。Provider 中间文本不作为策划用户对话展示。 +- 对已存在 V2 Session 的项目,打开项目时先 hydrate V2;没有 V2 authority 的旧项目继续走旧读取路径,避免误把旧项目数据当成 V2。 +- P3 已完成;P4 的真实 Provider、前端工作台、失败恢复和安全门禁验收已通过。旧会话 `legacy_retired` 封存与旧入口彻底关闭仍属于 P5。 + +P3 之后的协议修正:V2 不再用正文 JSON 输出问询/GDD;Provider 请求挂 `plan_ask_question` / `plan_submit_gdd`,`tool_choice=required`,形状由工具 schema 承担。system prompt 只保留三项核心闭环等策略和当前问询进度;数量和字数由既有校验器在失败时回灌。`plan_submit_gdd` 的工具参数只包含决定和原型验证内容,不包含任何 Runtime 分配的 ID;Runtime 在落盘前为决定分配首项 `initial-request`、后续 `decision-{序号}`,并按 `prototype_pending` 顺序绑定原型验证项。入参不必回声 `schemaVersion`,落盘 GDD 仍写 `plan-gdd.v2`。失败结果不重复渲染,严格解析和失败不落盘成功产物的规则保持不变。历史 V2 数据不做兼容转换,按现有恢复/失败策略处理。 + +### P4:灰度、真实 Provider 与回归验收(已完成) + +目标:证明 V2 的正常路径和关键失败路径可用,再关闭旧入口新建能力。 + +任务: + +| ID | 任务 | 产出 | +| ---- | --------------------- | ---------------------------------------------------------------- | +| P4-1 | 离线状态/结构定向测试 | Session、策略、schema、审批测试 | +| P4-2 | 真实 Provider 测试 | 流式、问题、GDD、失败恢复 | +| P4-3 | 前端组件/工作台测试 | 路由、审批卡、恢复显示 | +| P4-4 | 旧链路切换封存测试 | 未完成旧 session 强制失败、V2 不误读旧目录 | +| P4-5 | 安全与编码门禁 | `npm run check:encoding`、`git diff --check` 及相关 Rust/TS 检查 | + +阶段验收: + +- 真实 Provider 至少完成“提问 → 回答 → GDD → 批准”和“GDD → 修改 → 新版本 → 批准”两条链路。 +- 至少覆盖一次 Provider 请求失败、一次非法输出和一次重启恢复。 +- 证实旧目录中的 delivery/approval 不会被 V2 hydrate 或审批读取。 +- 证实同一项目不存在两个活跃策划权威;旧活跃会话在切换后不可继续。 +- 所有失败均保留可操作状态,不以成功文案掩盖 Provider/持久化错误。 + +阶段结果:已完成人工灰度验收,包含正常提问/回答/GDD/批准链路、GDD 修改与再次批准链路、Provider 失败恢复、非法输出失败边界、重启恢复、前端展示和编码/差异门禁。P4 不包含旧链路退役;旧链路封存和入口关闭在 P5 执行。 + +依赖:P3。 + +### P5:旧链路退役(最简方案) + +目标:停止新业务进入旧 Supervisor,并将所有未完成旧会话一次性封存为失败。 + +任务: + +| ID | 任务 | 产出 | +| ---- | ---------------------------------------------------- | -------------------------------- | +| P5-1 | 关闭旧 `project-supervisor-plan` 新建/继续入口 | 入口统一返回退役错误 | +| P5-2 | 旧活跃会话自动失败 | 入口拒绝后不启动旧 Runtime/Provider | +| P5-3 | 保留旧产物只读 | 不迁移、不修复、不删除旧文件 | +| P5-4 | 更新生产文档和最小回归测试 | 旧入口拒绝、V2 目录隔离 | + +阶段验收: + +- 代码搜索和运行时审计均证明新“做方案”不再调用 `start_game_creator_supervisor_runtime_task`。 +- 新项目不会创建旧 Supervisor root、child Run、delivery 或 acceptance evidence。 +- 旧入口或旧 continuation 若被直接调用,返回稳定的“旧链路已退役”错误,不启动旧 Runtime 或 Provider。 +- 旧文件和已终态旧产物保持原样,继续只读查看;不引入旧 Session schema 迁移。 +- V2 hydrate 和审批只读取 `.agent/planning-v2`,不读取旧目录。 +- 做游戏 DirectProject 和其它现役 Agent Runtime 不受影响。 + +依赖:P4 通过;确认切换窗口并完成一次性封存。 + +## 10. BDD 验收场景 + +### 功能:新项目走单 Agent 策划会话 + +为了去掉不必要的 Supervisor 编排,作为创作者,我希望“做方案”直接进入一个策划会话。 + +```gherkin +场景: 新项目首次进入做方案 + 假如项目没有活跃的旧策划会话,也没有 V2 会话 + 当用户从首页进入“做方案”并提交初始需求 + 那么系统应创建一个 planning-session-v2 会话 + 而且该会话只有一个策划 Agent + 而且不应创建 project-supervisor-plan 根 Run、project-planning 子 Run 或 agent.delegate delivery +``` + +### 功能:问询与问题上限 + +```gherkin +场景: 第 8 个问题仍然可以展示 + 假如 V2 会话已经展示 7 个有效问题 + 当 Provider 返回第 8 个合法 question + 那么系统应保存并展示该问题 + 而且 questionCount 应为 8 + 而且 turnIndex 应按实际 Provider 回合递增 + +场景: 达到问题上限后不再向用户展示问题 + 假如 V2 会话的 questionCount 已为 8 + 当 Provider 返回合法 question + 那么系统不应保存或展示该 question + 而且不应进入 awaiting_user + 而且系统应追加内部出稿提示并最多重试一次 + 而且重试得到合法 GDD 时应进入 awaiting_approval + +场景: 达到问题上限时直接返回 GDD + 假如 V2 会话的 questionCount 已为 8 + 当 Provider 直接返回合法 GDD + 那么系统应正常保存 GDD + 而且不应追加额外 question +``` + +### 功能:Provider 与非法输出失败边界 + +```gherkin +场景: Provider 请求失败后恢复 + 假如 V2 会话正在 planning 且尚未得到本次结果 + 当 Provider 请求超时或返回瞬态失败 + 那么系统应保留当前 Session 和已落盘消息 + 而且不应增加 questionCount + 而且不应创建新的 GDD 版本 + 而且用户可以重试或恢复同一会话 + +场景: 非法 GDD 不被伪装成成功 + 假如 Provider 返回无法解析或缺少必填字段的 GDD + 当输出校验完成 + 那么系统应按当前重试上限请求修正 + 而且重试耗尽后应显示可操作失败 + 而且不得写入 approved GDD +``` + +### 功能:审批、修改与退回 + +```gherkin +场景: 用户批准当前 GDD + 假如当前存在 V2 最新 GDD 且审批卡引用的 fingerprint 与文件一致 + 当用户选择批准 + 那么系统应写入 V2 approval receipt + 而且会话状态应为 approved + 而且旧 GDD 文件保持可读 + +场景: 用户修改当前 GDD + 假如当前 GDD 正在等待审批 + 当用户提交修改意见 + 那么系统应以当前 GDD 为基线启动同一 V2 Session 的修订回合 + 而且用户修改不应消耗 questionLimit + 而且成功后应生成递增版本的新 GDD + 而且旧版本不应被覆盖 + +场景: 过期审批不能覆盖新版本 + 假如审批卡引用 v1,但当前最新 GDD 已经是 v2 + 当用户提交 v1 的批准或修改 + 那么系统应拒绝该决定 + 而且 v2 内容和状态不得改变 +``` + +### 功能:恢复与新旧并存 + +```gherkin +场景: App 重启后恢复等待用户回答 + 假如 V2 会话已持久化一个合法 question 且状态为 awaiting_user + 当 App 重启并重新打开项目 + 那么系统应恢复同一 question + 而且不应再次调用 Provider 生成新 question + +场景: 旧活跃会话在切换时强制失败 + 假如项目已有活跃的 project-supervisor-plan 会话 + 当系统切换到 PlanningSessionRuntime V2 + 那么旧会话应被投影为 legacy_retired 失败 + 而且不得再接受旧问询、审批、恢复或 continuation + 而且用户重新做方案时只能创建 V2 会话 + +场景: 旧 Provider 迟到结果不能复活旧链路 + 假如旧会话在切换时已有一个 Provider 请求在途 + 当该请求在切换后返回 GDD 或 question + 那么系统不得写入旧 GDD、approval receipt 或 game/fast_gdd.md + 而且旧会话仍保持 legacy_retired 失败 + +场景: V2 不读取旧 planning 目录 + 假如项目同时存在旧 .agent/planning 和 V2 .agent/planning-v2 目录 + 当系统 hydrate V2 会话 + 那么系统只能读取 V2 schema 和产物 + 而且旧 delivery、旧 approval receipt 和旧 Run 身份不得改变 V2 状态 +``` + +### 功能:未来能力插槽的当前行为 + +```gherkin +场景: 当前 V2 会话不启用 MCP 或 Skill + 假如用户创建新的 V2 策划会话 + 当 Runtime 构建 Provider 请求 + 那么能力快照中的 tools 和 skills 应为空数组 + 而且 Provider 请求不应广告 MCP/Skill 工具 + 而且会话 schema 应能保存该空能力快照 +``` + +## 11. 测试映射 + +| 场景/规则 | 测试层级 | 计划目标 | +| ------------------------------------------ | --------------------- | ----------------------------------------------- | +| Session 状态、questionCount/turnIndex 分离 | Rust unit | `planning_session_v2` | +| Provider 失败、幂等回合、恢复 | Rust integration | V2 runtime/provider adapter tests | +| GDD schema、版本链、fingerprint | Rust unit/integration | V2 artifact/approval tests | +| 第 8 个问题与上限后重试 | Rust unit | `GddPlanningPolicy` tests | +| 审批批准/修改/退回/过期审批 | Rust integration | V2 approval tests | +| 输入“按 A 做/按第一个选项做” | Rust/TS unit | input normalization tests | +| 做方案入口路由 | frontend integration | `App`/planning lane tests | +| 问题卡、GDD 卡和恢复态展示 | component | `ProjectSupervisorView`/`GddApprovalCard` tests | +| 真实 Provider 流式链路 | real provider smoke | P4 独立脚本或现有 real-e2e harness | +| 旧会话切换强制失败、旧目录不被 V2 读取 | Rust integration | legacy cutover/recovery tests | +| 中文编码和文档 diff | repository gate | `npm run check:encoding`、`git diff --check` | + +未接入 Cucumber/Playwright runner 前,以上 BDD 先作为 Markdown 验收合同;不为本方案新增独立 BDD 测试框架。 + +## 12. 风险与处理原则 + +| 风险 | 处理 | +| -------------------------------------------------------- | ----------------------------------------------------------------- | +| 继续复用旧 `planning_submit.rs` 导致 Supervisor 身份回流 | V2 使用独立 artifact/approval 模块;只复用通用文件/锁能力 | +| 新旧都写 `game/fast_gdd.md` | 同一项目单活跃策划权威;V2/旧路径均使用项目锁和原子写 | +| 审批修改后立刻续跑与 hydrate 抢同一把项目锁 | 一次性用户意图走完整等待窗口,V2 hydrate 走短窗口,对齐 V1;不引入可重入锁 | +| 无限会话导致上下文无限膨胀 | 当前先分离完整记录和 ContextBuilder;超预算显式失败,后续再加摘要 | +| 未来 MCP/Skill 侵入 GDD 策略 | 能力快照和消息类型在 Runtime 层预留,当前策略不广告、不执行 | +| 强制失败导致旧 pending/receipt 不再可继续 | 这是本次明确的退役语义;旧文件只读保留,不迁移、不删除 | +| 前端组件名继续叫 Supervisor 造成误解 | 第一阶段只切数据源;后续独立重命名,不把命名重构当接入前置 | + +## 13. 完成定义 + +本方案对应的工程工作只有在以下条件全部满足后才可宣布完成: + +- P0~P4 阶段验收通过,真实 Provider 至少跑通一条审批链和一条修改链。 +- 新“做方案”入口的运行时审计中不再出现新的 Supervisor root/child/delivery。 +- V2 Session、GDD 和审批记录可在重启后恢复,且旧目录不会污染 V2。 +- P5 关闭旧新建/继续入口;所有未完成旧会话均为 `legacy_retired` 失败,历史产物仍可只读查看。 +- 做游戏/做素材 DirectProject 路径无回归。 +- 相关 Rust/TS 定向验证、`npm run check:encoding` 和 `git diff --check` 通过。