把审批卡的修改/退回意见原文送进 Supervisor 会话
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Native shell tests (pull_request) Successful in 15m12s
Project CI / Frontend tests (pull_request) Successful in 3m39s

用户在审批卡上写的意见此前只落到 `.agent/planning/approvals/v{n}.json` 就断了。
Supervisor 唯一的信息源是 `agent.run_status` 返回的 claimedDelegateContract,
那里面只有 contractStatus=user-revision-requested,没有承载原文的字段。于是它
按 playbook 第 6 条「把用户原话完整附在 task 里」写了句占位「请按审批卡上用户
提交的修改意见…」,子 Agent 收到的是空指令,只能自由发挥——实测用户写「把游戏
名称改成日本语」,产出的 v2 把标题从《裂隙脉冲》改成《裂潮航印》,仍是中文。

通道本来就有:决策卡的答案早就是这么送的——`append_user_input_answer_message`
把用户选择渲成一句 role=user 消息追加到 Supervisor 会话,下一轮 prompt 由
`prompt_history_sources` 现读现取。审批决定接上同一条通道即可。

因此不动 delivery schema,也不动 playbook:
- schema 加字段要连带改 validator、run_status 投影,还得让 e43133875 的
  `static_delegate_structured_result_follows_claim_snapshot` 跟着 rebase 新字段,
  否则 claim 重放冲突原样复发——多一处必须手工同步的地方。
- playbook 第 6 条的规则本来就在,缺的是原文本身,不是规则。

落点用 Supervisor 的**当前活动会话**(sessionId 传 None),不用 delivery 上的
parentSessionId——那是委派发出时的快照,不保证仍是可写的活动会话。
messageId 用 responseId 派生:`project_receipt_locked` 会被
`reconcile_plan_gdd_approval_projections_locked` 在每次 hydrate 重跑,不幂等就
每刷新一次多一条。

守门一条,两个断言都验过非空转:去掉追加,第一条断言红;换成非幂等 append,
重放后消息数 2 vs 1,第二条断言红。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 08:42:42 +00:00
parent 9e2648893b
commit b27ffce9b4
2 changed files with 85 additions and 0 deletions
@@ -74,6 +74,42 @@ fn approval_observation(receipt: &PlanGddApprovalV1) -> AgentRuntimeToolObservat
}
}
/// 审批卡上的「修改/退回」是用户说的话,落点和决策卡的答案一样:Supervisor 自己
/// 的会话文件。`append_user_input_answer_message` 已经为澄清答案建立了这条通道,
/// 审批决定沿用它。没有这一步 Supervisor 只能从 delivery 的
/// `contractStatus=user-revision-requested` 知道「用户要改」,读不到要改什么——
/// playbook 第 6 条的「把用户原话完整附在 task 里」就没有原话可附,返工委派只能
/// 写一句占位,子 Agent 于是自由发挥。
fn append_plan_gdd_revision_message(
root: &Path,
receipt: &PlanGddApprovalV1,
) -> Result<(), String> {
let label = if receipt.action == "reject" {
"退回"
} else {
"修改"
};
append_local_conversation_message_for_session_idempotent_at(
root,
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
// 落在 Supervisor 当前活动会话。delivery 上的 parentSessionId 是委派发出时
// 的快照,不保证仍是可写的活动会话。
None,
LocalConversationMessage {
role: "user".to_string(),
content: format!(
"我对 Fast GDD v{} 的审批:{}。意见原文:\n{}",
receipt.version,
label,
receipt.comment.as_deref().unwrap_or_default()
),
agent_id: None,
},
&format!("plan-gdd-decision-{}", receipt.response_id),
)
.map(|_| ())
}
fn receipt_decision_input(
gdd: &PlanGddV1,
input: &DecidePlanGddInputV1,
@@ -1149,6 +1185,10 @@ fn project_receipt_locked(
note_plan_gdd_projection_gap(root, receipt, "delivery-revision-mark", &error);
recovery_pending = true;
}
if let Err(error) = append_plan_gdd_revision_message(root, receipt) {
note_plan_gdd_projection_gap(root, receipt, "delivery-revision-message", &error);
recovery_pending = true;
}
}
// A replay may target an older receipt after a newer GDD has already been
// submitted. The receipt still repairs its own audit/observation, but it
@@ -4192,6 +4192,51 @@ mod tests {
cleanup_fixture(root);
}
/// 审批卡上的「修改/退回」意见必须原文落进 Supervisor 会话——那是 playbook
/// 第 6 条「把用户原话完整附在 task 里」唯一的原话来源。delivery 的
/// `contractStatus=user-revision-requested` 只说明「用户要改」,不带内容,
/// Supervisor 拿不到原文就只能在返工委派里写一句占位。
///
/// 追加必须幂等:`reconcile_plan_gdd_approval_projections_locked` 每次 hydrate
/// 都会为全部回执重跑 `project_receipt_locked`。
#[test]
fn a_revision_comment_reaches_the_supervisor_conversation_once() {
let (root, gdd, _root_runtime) = acceptance_gate_fixture(true);
create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending");
decide_plan_gdd_at(
&root,
&approval_input(
&gdd,
"revise",
"gdd-response-00000000-0000-4000-8000-000000000050",
Some("把游戏名称改成日本语".to_string()),
),
)
.expect("commit revise receipt");
let supervisor_messages = || {
read_local_conversation_for_session_at(
&root,
Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID),
None,
)
.expect("read supervisor conversation")
.messages
};
let after_decision = supervisor_messages();
assert!(
after_decision.iter().any(|message| message.role == "user"
&& message.content.contains("把游戏名称改成日本语")),
"用户修改意见必须原文进入 Supervisor 会话"
);
reconcile_plan_gdd_approval_projections_locked(&root).expect("replay receipt projections");
assert_eq!(
supervisor_messages().len(),
after_decision.len(),
"投影重放不得重复追加同一条审批意见"
);
cleanup_fixture(root);
}
/// reject 之后能不能在同一 lineage 重做,**不由提交门的 phase 判据决定**。
///
/// 提交门要求 session 的 activeRunId 等于当前策划子 run,而 schema 不变量禁止