diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md index 286f3ca60..98f59670a 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md @@ -11,7 +11,7 @@ - 转达子 Agent 的问题时 `questions` 原样透传:不改写题干、不增删选项、不替用户预选。 - 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] {header} → 用户答:{原文}`。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字,绝不压缩用户答案。 -- A、B、“需要原型验证”三项必须原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻已确认决定时,在被推翻的决定后注明“已被第 N 轮回答推翻,以后者为准”,并在新决定 topic 中写明推翻关系。 +- A、B、“需要原型验证”三项必须原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。 【委派合同的边界】 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md index bb22f4704..ccb206cea 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md @@ -16,8 +16,9 @@ ## 低幻觉与 GDD 约束 - 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。 -- A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`;只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项。 -- 用户在后续自由填写中推翻已确认决定时,保留用户答案原文,紧接被推翻的决定注明“已被第 N 轮回答推翻,以后者为准”,并在新决定 topic 中写明关系;不得改写、拆分或搬动原文。 +- 决定台账里,**事实归 Runtime、判断归你**。`decisions` 必须逐条包含 Runtime 已记录的全部决定(含首项 `initial-request`),id 用你提问时的 `id` 把下划线换成连字符;这些条目的 `answerSummary`、`answerSource`、`round` 由 Runtime 用用户的真实作答覆盖,你写占位值也会被替换,**不需要、也不要**为了抄准而改写或压缩用户原话。你真正决定的是 `topic` 和 `state`。 +- A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。 +- 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。 - 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。 - GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。 - 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs index e978b8ea1..4e6bf1374 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs @@ -448,9 +448,13 @@ pub(crate) fn ensure_plan_session_for_planning_child_task_at_locked( .ok_or_else(|| { plan_coordinator_error("PLAN_SESSION_RECOVERY_REQUIRED", "plan root task 缺失") })?; - let initial_request = - normalize_plan_text(&root_task.task, "plan session initial request", 1, 400) - .map_err(|error| error.to_string())?; + let initial_request = normalize_plan_text( + &root_task.task, + "plan session initial request", + 1, + PLAN_INITIAL_REQUEST_MAX_CHARS, + ) + .map_err(|error| error.to_string())?; let mut session = PlanSessionV1 { schema_version: PLAN_SESSION_SCHEMA_VERSION.to_string(), project_id, 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 b3372a586..166666990 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 @@ -116,6 +116,29 @@ pub(crate) const PLAN_SESSION_PATH: &str = ".agent/planning/session.json"; pub(crate) const PLAN_SESSION_PREVIOUS_PATH: &str = ".agent/planning/.session.json.previous"; 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; + +/// `initial-request` 那条决定的 answerSummary 上限,也就是立项策划入口原始需求的上限。 +/// +/// 上游用 `sanitize_agent_runtime_text(task, AGENT_RUNTIME_TASK_MAX_CHARS)` 归一根 +/// task:4000 个 Unicode scalar 封顶,超长时再补一个省略号,真实上界因此是 4001。 +/// 这里早期写死 400,于是 401~4001 字的开场需求会让根 run、Goal Contract 与首跳 +/// 委派全部正常建立,直到策划子 Agent 的 task-start 才在 session 投影上硬失败 +/// (`phase=planning-session-projection-failed`);此时 session 从未创建,用同一根 +/// task 重试必然复现,用户只能重开一条链。直接绑定到上游常量,两边不会再漂开。 +pub(crate) const PLAN_INITIAL_REQUEST_MAX_CHARS: usize = + crate::agent::runtime_driver::AGENT_RUNTIME_TASK_MAX_CHARS + 1; + +/// `initial-request` 承载的是用户原话,长度上限与其余决定不同。 +fn decision_answer_summary_max_chars(decision_id: &str) -> usize { + if decision_id == "initial-request" { + PLAN_INITIAL_REQUEST_MAX_CHARS + } else { + PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS + } +} pub(crate) const PLAN_GDD_APPROVAL_SCHEMA_VERSION: &str = "plan-gdd-approval.v1"; pub(crate) const PLAN_GDD_APPROVAL_PENDING_SCHEMA_VERSION: &str = "plan-gdd-approval-pending.v1"; pub(crate) const PLAN_GDD_APPROVAL_DIR: &str = ".agent/planning/approvals"; @@ -869,7 +892,7 @@ fn validate_decisions( &decision.answer_summary, &format!("decisions[{index}].answerSummary"), 1, - 400, + decision_answer_summary_max_chars(&decision.id), )?; if decision.basis.is_some() { return Err(invalid("v1 的 decision.basis 必须为 null")); @@ -1935,7 +1958,7 @@ fn validate_plan_session_shape(value: &PlanSessionV1) -> Result<(), PlanningStor &decision.answer_summary, &format!("session.decisionsSummary[{index}].answerSummary"), 1, - 400, + decision_answer_summary_max_chars(&decision.id), )?; } if value.decisions_summary[0].id != "initial-request" { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index d04cdc49f..2a0accee8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -1205,28 +1205,106 @@ fn gdd_submit_identity_matches(gdd: &PlanGddV1, context: &PlanSubmitGddRuntimeCo .is_none_or(|approval_request_id| gdd.approval_request_id == approval_request_id) } -fn session_decisions_match_input(session: &PlanSessionV1, input: &PlanSubmitGddInputV1) -> bool { - session.decisions_summary.len() <= input.decisions.len() - && session +/// 决定台账的权威归属:Runtime 拥有**事实**(用户在第几轮、对着哪道题、原样说了 +/// 什么),策划子 Agent 拥有**判断**(这句话是不是构成对该题的取舍、该记成什么 +/// topic)。 +/// +/// 早期实现要求 submit input 的前缀与 `session.decisionsSummary` 六个字段逐项相等。 +/// 那六个字段没有一个是子 Agent 生产的,它只能从 Supervisor 转述的委派 task 里回抄; +/// 而权威台账从不下发给它,拒绝理由也不含差异。于是「回抄」这件零信息量的动作成了 +/// 唯一的提交前提,用户只要自由填写过一次,逐字复现就依赖一条没有机制保证的 LLM +/// 转述链,抄歪即在 5 次盲重试后硬失败。同一条相等约束还顺带禁掉了子 Agent 纠正 +/// 错误绑定的能力——答非所问被 Runtime 投影成 confirmed 之后,改一个字都过不了。 +/// +/// 现在只守真正要守的那一条:**不能声称用户确认过他没确认的东西**。 +fn submit_decisions_respect_session_authority( + session: &PlanSessionV1, + input: &PlanSubmitGddInputV1, +) -> bool { + // 1. 不得凭空造出用户拍板:任何 confirmed 且非默认来源的决定,都必须命中一条 + // 同 id 的 confirmed session 决定。 + let no_forged_confirmation = input.decisions.iter().all(|decision| { + if decision.state != "confirmed" || decision.answer_source == "default" { + return true; + } + session .decisions_summary .iter() - .zip(&input.decisions) - .all(|(session, input)| { - session.id == input.id - && session.topic == input.topic - && session.state == input.state - && session.answer_source == input.answer_source - && session.round == input.round - && session.answer_summary == input.answer_summary - }) - && input.decisions[session.decisions_summary.len()..] + .any(|recorded| recorded.id == decision.id && recorded.state == "confirmed") + }); + // 2. 不得丢弃用户已作出的决定,也不得篡改用户亲自选择的「需要原型验证」。 + // confirmed 允许降级为 default_pending(子 Agent 判定该轮回答并未回答所问 + // 时的唯一出口),但不能凭空消失。 + let no_dropped_authority = session.decisions_summary.iter().all(|recorded| { + let Some(decision) = input + .decisions .iter() - .all(|decision| { - decision.state == "default_pending" - && decision.answer_source == "default" - && decision.round == 0 - }) - && session.prototype_validation_items == input.prototype_validation_items + .find(|decision| decision.id == recorded.id) + else { + return false; + }; + match recorded.state.as_str() { + "prototype_pending" => decision.state == "prototype_pending", + "confirmed" => matches!(decision.state.as_str(), "confirmed" | "default_pending"), + _ => true, + } + }); + // 3. Runtime 生成的原型验证项必须都在,内容由 `apply_plan_session_authority_to_ + // submit_input` 覆盖,不比较;子 Agent 可以另加自己的项,由 `validate_decisions` + // 的「逐项对应全部 prototype_pending 决定」双射约束兜底。 + let no_dropped_prototype_items = session.prototype_validation_items.iter().all(|recorded| { + input + .prototype_validation_items + .iter() + .any(|item| item.id == recorded.id) + }); + no_forged_confirmation && no_dropped_authority && no_dropped_prototype_items +} + +/// 把 Runtime 拥有的字段直接覆盖进 submit input,而不是要求子 Agent 回抄。 +/// +/// 覆盖对象只有「仍然挂着用户权威」的条目:保持 confirmed 的、以及用户亲选的 +/// prototype_pending。子 Agent 判定为未答而降级成 default_pending 的条目,其 +/// answerSummary 描述的是它自己填的默认值,归它所有,不覆盖。`topic` 任何情况下 +/// 都不覆盖——按答案真实内容重新命名决定,正是子 Agent 纠正错误绑定的手段。 +/// +/// 覆盖必须发生在 durable action identity 重放比对之前,且只依赖 session 里 +/// 跨 submit 不变的 `decisionsSummary` / `prototypeValidationItems` +/// (`build_submit_session_successor` 原样克隆这两项),这样同一个 actionId 重放 +/// 时归一化结果稳定,重放比对不会因为覆盖而错判成 payload 不一致。 +fn apply_plan_session_authority_to_submit_input( + session: &PlanSessionV1, + input: &mut PlanSubmitGddInputV1, +) { + for decision in &mut input.decisions { + let Some(recorded) = session + .decisions_summary + .iter() + .find(|recorded| recorded.id == decision.id) + else { + continue; + }; + let carries_user_authority = match recorded.state.as_str() { + "confirmed" => decision.state == "confirmed", + "prototype_pending" => decision.state == "prototype_pending", + _ => false, + }; + if !carries_user_authority { + continue; + } + decision.answer_source = recorded.answer_source.clone(); + decision.round = recorded.round; + decision.answer_summary = recorded.answer_summary.clone(); + } + for item in &mut input.prototype_validation_items { + if let Some(recorded) = session + .prototype_validation_items + .iter() + .find(|recorded| recorded.id == item.id) + { + *item = recorded.clone(); + } + } } fn session_identity_matches_context( @@ -1318,10 +1396,10 @@ fn validate_current_session_cas( // 这正是第 12 节划归「本次 Provider input」的那一类,应该走 rejected // observation 回灌让它改,受既有 5 次预算约束,而不是硬阻断等人。 // 不变量本身一个字没放松:不匹配照样拒,只是改了拒绝的后果。 - if !session_decisions_match_input(session, input) { + if !submit_decisions_respect_session_authority(session, input) { return Err(submit_error( "PLAN_SESSION_DECISIONS_MISMATCH", - "submit input 未逐项匹配当前 planning session 决策摘要", + "submit input 的决定台账越过了 planning session 的用户权威", )); } if session.latest_delegation_id != context.delegation_id { @@ -1816,6 +1894,23 @@ pub(crate) fn execute_plan_submit_gdd( let session_read = read_plan_session_with_recovery_locked(root); let current_session = session_read.as_ref().ok().and_then(Option::as_ref); + // Runtime 拥有的决定字段在这里一次性覆盖进 input,之后的重放比对、CAS 与 GDD + // 构建全部使用归一化后的值。放在重放分支之前是必需的:`submit_payload_matches_gdd` + // 拿 input 和已落库 GDD 反推出的 input 比对,只有两侧都归一化过才等价。归一化 + // 只读 `decisionsSummary` / `prototypeValidationItems`,二者跨 submit successor + // 原样保留,所以同一 actionId 重放的结果稳定。session 读不出来时保持原样,把 + // session 错误留给下面既有的分支处置。 + let normalized_input; + let input = match current_session { + Some(session) => { + let mut owned = input.clone(); + apply_plan_session_authority_to_submit_input(session, &mut owned); + normalized_input = owned; + &normalized_input + } + None => input, + }; + // First resolve the durable action identity. This branch intentionally // runs before pending/version checks: replay must be idempotent even when a // previous attempt already advanced the session or projections. @@ -2164,6 +2259,15 @@ mod tests { } fn submit_fixture() -> (PathBuf, PlanSubmitGddRuntimeContext, PlanSubmitGddInputV1) { + submit_fixture_from(valid_input()) + } + + /// 与 `submit_fixture` 同构,但由调用方提供 input:durable session 的 + /// `decisionsSummary` / `prototypeValidationItems` 直接镜像它,于是可以构造出 + /// 「用户已在第 N 轮拍板」「用户亲选了需要原型验证」这类前置台账。 + fn submit_fixture_from( + input: PlanSubmitGddInputV1, + ) -> (PathBuf, PlanSubmitGddRuntimeContext, PlanSubmitGddInputV1) { let root = std::env::temp_dir().join(format!( "genarrative-planning-submit-{}", Uuid::new_v4().simple() @@ -2206,7 +2310,6 @@ mod tests { let mut context = context(); context.project_id = "project-test-001".to_string(); context.run_profile_binding_fingerprint = child_binding.binding_fingerprint; - let input = valid_input(); let decisions = input .decisions .iter() @@ -3445,17 +3548,18 @@ mod tests { cleanup_fixture(root); } - /// 台账逐项比对失败必须与真 CAS 区分开:前者是本次 Provider input 写错, - /// 后者是 durable 权威已变。两者都拒,但只有前者值得回灌重试。 + /// 用户答案原文归 Runtime 所有:子 Agent 抄歪了直接被覆盖回去,而不是把整条 + /// 提交拒掉。真 CAS(durable 权威已变)仍然是另一回事,必须区分开。 #[test] - fn session_ledger_mismatch_is_reported_apart_from_a_real_session_cas_conflict() { - // 抄错既有决定的正文(权威没变,错的是 input)。 + fn a_rewritten_answer_summary_is_overwritten_while_a_stale_session_is_still_a_cas_conflict() { + // 抄错既有决定的正文(权威没变,错的是 input):落库的是权威原文。 let (root, context, mut input) = submit_fixture(); + let authoritative = input.decisions[0].answer_summary.clone(); input.decisions[0].answer_summary.push_str("(被改写)"); - let mismatch = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("a rewritten session decision must fail"); - assert_eq!(mismatch.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); - assert!(!root.join(".agent/planning/gdd.v1.json").exists()); + execute_plan_submit_gdd(&root, &context, &input) + .expect("a rewritten answer summary is overwritten, not rejected"); + let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); + assert_eq!(chain[0].decisions[0].answer_summary, authoritative); cleanup_fixture(root); // 同一份合法 input,只把 session revision 弄陈旧(权威已被推进)。 @@ -3468,6 +3572,128 @@ mod tests { cleanup_fixture(root); } + /// 一轮澄清落进 durable 台账之后的 fixture:confirmed 一条 + 用户亲选的 + /// prototype_pending 一条(后者必须配同 id 的原型验证项,双射由 + /// `validate_decisions` 兜)。 + fn clarified_input() -> PlanSubmitGddInputV1 { + let mut input = valid_input(); + input.decisions.push(PlanSubmitDecision { + id: "core-loop-shape".to_string(), + topic: "核心防守方式".to_string(), + state: "confirmed".to_string(), + answer_source: "user_freeform".to_string(), + round: 1, + answer_summary: "不要那两个,我要玩家只能移动光源给守卫开路".to_string(), + }); + input.decisions.push(PlanSubmitDecision { + id: "touch-readability".to_string(), + topic: "触控可读性".to_string(), + state: "prototype_pending".to_string(), + answer_source: "user_option".to_string(), + round: 2, + answer_summary: "需要原型验证".to_string(), + }); + input + .prototype_validation_items + .push(PlanPrototypeValidationItem { + id: "touch-readability".to_string(), + question: "验证触控可读性是否成立".to_string(), + micro_prototype: "用 30~90 分钟制作只覆盖触控可读性的最小可交互原型".to_string(), + observation: "记录玩家在无额外提示时的行为".to_string(), + pass_criterion: "至少 3 次独立试玩中有 2 次出现预期行为".to_string(), + }); + input + } + + /// B3 的自救出口:用户的自由填写答非所问时,子 Agent 必须能按答案的**真实内容** + /// 重新命名这条决定。topic 归它所有,Runtime 不覆盖也不比对。 + #[test] + fn planning_child_may_rename_a_decision_topic_to_match_what_the_user_actually_said() { + let (root, context, mut input) = submit_fixture_from(clarified_input()); + input.decisions[1].topic = "重玩动力(用户实际回答的是这个)".to_string(); + execute_plan_submit_gdd(&root, &context, &input).expect("a renamed topic is the child's"); + let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); + assert_eq!( + chain[0].decisions[1].topic, + "重玩动力(用户实际回答的是这个)" + ); + // 但答案原文仍然是 Runtime 的权威值。 + assert_eq!( + chain[0].decisions[1].answer_summary, + "不要那两个,我要玩家只能移动光源给守卫开路" + ); + cleanup_fixture(root); + } + + /// 降级(confirmed → default_pending)是允许的安全方向;整条丢掉不行——那会让 + /// 用户已经作出的决定从 GDD 里凭空消失。 + #[test] + fn a_confirmed_decision_may_be_downgraded_but_never_dropped() { + let (root, context, mut input) = submit_fixture_from(clarified_input()); + input.decisions[1].state = "default_pending".to_string(); + input.decisions[1].answer_source = "default".to_string(); + input.decisions[1].answer_summary = "按默认建议填写,等待用户确认".to_string(); + execute_plan_submit_gdd(&root, &context, &input).expect("downgrade is the safe direction"); + let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); + // 降级之后这条不再声称用户拍过板,正文归子 Agent 所有,不被覆盖。 + assert_eq!(chain[0].decisions[1].state, "default_pending"); + assert_eq!( + chain[0].decisions[1].answer_summary, + "按默认建议填写,等待用户确认" + ); + cleanup_fixture(root); + + let (root, context, mut input) = submit_fixture_from(clarified_input()); + input.decisions.remove(1); + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("dropping a user decision must fail"); + assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + cleanup_fixture(root); + } + + /// 用户亲手选的「需要原型验证」不是子 Agent 可以改判的东西。 + #[test] + fn a_user_picked_prototype_validation_cannot_be_rewritten_by_the_planning_child() { + let (root, context, mut input) = submit_fixture_from(clarified_input()); + input.decisions[2].state = "confirmed".to_string(); + input.prototype_validation_items.clear(); + let error = execute_plan_submit_gdd(&root, &context, &input) + .expect_err("a user-picked prototype validation must survive"); + assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + cleanup_fixture(root); + } + + /// B1:`initial-request` 承载用户原话,上限必须跟上游根 task 的真实上界一致 + /// (`AGENT_RUNTIME_TASK_MAX_CHARS` 再加一个截断省略号),其余决定仍是 400。 + #[test] + fn initial_request_answer_summary_matches_the_upstream_root_task_bound() { + assert_eq!( + PLAN_INITIAL_REQUEST_MAX_CHARS, + crate::agent::runtime_driver::AGENT_RUNTIME_TASK_MAX_CHARS + 1 + ); + + let mut input = valid_input(); + input.decisions[0].answer_summary = "需".repeat(PLAN_INITIAL_REQUEST_MAX_CHARS); + validate_plan_submit_gdd_input(&input) + .expect("a full-length initial request is a legal decision summary"); + + input.decisions[0].answer_summary = "需".repeat(PLAN_INITIAL_REQUEST_MAX_CHARS + 1); + validate_plan_submit_gdd_input(&input) + .expect_err("one scalar past the upstream bound must still fail"); + + let mut input = valid_input(); + input.decisions.push(PlanSubmitDecision { + id: "other-decision".to_string(), + topic: "其它决定".to_string(), + state: "default_pending".to_string(), + answer_source: "default".to_string(), + round: 0, + answer_summary: "默".repeat(PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS + 1), + }); + validate_plan_submit_gdd_input(&input) + .expect_err("non-initial decisions keep the 400 scalar bound"); + } + #[test] fn submit_replay_reports_missing_session_without_guessing_or_allocating_version() { let (root, context, input) = submit_fixture();