立项策划决定台账改为 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:
@@ -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 预览;不得修改、删减或向用户询问。
|
||||
|
||||
+7
-3
@@ -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,
|
||||
|
||||
+25
-2
@@ -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" {
|
||||
|
||||
+256
-30
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user