立项策划决定台账改为 Runtime 覆盖,不再要求子 Agent 逐字回抄

plan.submit_gdd 此前要求提交的 decisions[] 前缀与 session.decisionsSummary
六个字段逐项相等,prototypeValidationItems 整体相等。那六个字段没有一个由策划子
Agent 生产:id、topic、state、answerSource、round、answerSummary 全部是 Runtime
在用户答题时从问题与答案投影出来的。子 Agent 只能从 Supervisor 转述的委派 task 里
回抄,而权威台账从不注入它的上下文,拒绝 observation 也只有一句"未逐项匹配"、不含
差异。于是这个零信息量的回抄成了唯一的提交前提:用户只要自由填写过一次,逐字复现
就依赖一条没有机制保证的 LLM 转述链,抄歪即在 5 次盲重试后 run failed。

同一条相等约束还顺带禁掉了纠错。答非所问的自由填写被投影成 confirmed 之后
(planning_coordinator.rs 的 else 分支对任何非选项答案一律断言 confirmed),
子 Agent 即便看出绑定错了也不能改——改一个字就过不了相等校验。

现在只守真正要守的那一条:不能声称用户确认过他没确认的东西。

- submit_decisions_respect_session_authority 取代逐项相等,只校验三点:
  不得凭空造出没有 session 支撑的 confirmed;不得丢弃用户已作出的决定;
  用户亲选的 prototype_pending 不得被改判。
- apply_plan_session_authority_to_submit_input 把 answerSummary、answerSource、
  round 直接覆盖进 input,覆盖发生在 durable action identity 重放比对之前,
  且只读跨 submit successor 原样保留的 decisionsSummary/prototypeValidationItems,
  所以同一 actionId 重放结果稳定。
- topic 与 state 归子 Agent:可按答案真实内容重命名决定,可把 confirmed 降级为
  default_pending。这是纠正错误绑定的唯一出口,方向单向。

顺带修掉开场需求长度的三方不一致:入口不设限,AGENT_RUNTIME_TASK_MAX_CHARS
截断到 4000(超长再补一个省略号),而 plan session 的 initial request 硬拒 400。
401~4001 字的需求会让根 run、Goal Contract 与首跳委派全部正常建立,直到策划子
Agent 的 task-start 才炸在 planning-session-projection-failed,且 session 从未
创建、同一根 task 重试必然复现。PLAN_INITIAL_REQUEST_MAX_CHARS 直接绑定上游常量。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 07:09:58 +00:00
parent 98abcf4f0d
commit 853a0147f2
5 changed files with 292 additions and 38 deletions
@@ -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` 判断,不要替它裁定哪条作废
【委派合同的边界】
@@ -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 预览;不得修改、删减或向用户询问。
@@ -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,
@@ -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)` 归一根
/// task4000 个 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" {
@@ -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` 同构,但由调用方提供 inputdurable 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 抄歪了直接被覆盖回去,而不是把整条
/// 提交拒掉。真 CASdurable 权威已变)仍然是另一回事,必须区分开
#[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 台账之后的 fixtureconfirmed 一条 + 用户亲选的
/// 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();