diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index cde826b7c..9b2d2370d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -1110,7 +1110,7 @@ fn static_delegate_original_is_awaiting_clarification( /// 防环 / 防越界:反向重放阶段一旦遇到重复 id、缺失的上游节点,或跳数超出 /// STATIC_DELEGATE_LINEAGE_MAX_HOPS,立即 fail closed,返回 (u32::MAX, u32::MAX), /// 使调用方的深度门 / 轮次门必然拒绝,而不是静默放行。 -fn static_delegate_lineage_counters( +pub(crate) fn static_delegate_lineage_counters( deliveries: &[StaticDelegateDeliveryRecord], delegation_id: &str, ) -> (u32, u32) { @@ -1669,7 +1669,7 @@ pub(crate) fn read_static_delegate_delivery_at( Ok(Some(record)) } -fn list_static_delegate_deliveries_at( +pub(crate) fn list_static_delegate_deliveries_at( root: &Path, ) -> Result, String> { let dir = resolve_local_project_path(root, STATIC_DELEGATE_DELIVERY_DIR)?; @@ -1766,7 +1766,7 @@ fn list_static_delegate_claims_at(root: &Path) -> Result Result<(), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index f83bf5b0a..cd180e072 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -1,5 +1,459 @@ use super::super::*; +// =========================================================================================== +// WP2 测试帮助函数:把「dispatch -> 标记终态 -> 认领 -> (可选)用户澄清问答 -> 真实 +// observe_agent_runtime_agent_delegate 派发」这套反复出现的仪式收敛成可组合的小函数, +// 供本文件下方的澄清轮次 / 返工深度链上推断用例复用。所有函数都不绕过生产校验,只是把 +// 已经在本文件其他用例里出现过的调用顺序打包起来。 +// =========================================================================================== + +/// 创建一条处于 Dispatched 状态的静态委派 delivery(链根或某一跳的占位),不做任何终态标记。 +#[allow(clippy::too_many_arguments)] +fn create_dispatched_static_delegate_delivery( + root: &Path, + parent_agent_id: &str, + parent_session_id: &str, + parent_run_id: &str, + parent_action_id: &str, + delegation_id: &str, + target_agent_id: &str, + target_session_id: &str, + target_run_id: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + repair_of_delegation_id: Option<&str>, +) { + let delivery = new_static_delegate_delivery_with_contract( + parent_agent_id, + parent_session_id, + parent_run_id, + parent_action_id, + delegation_id, + target_agent_id, + target_session_id, + target_run_id, + acceptance_criteria, + expected_artifacts, + repair_of_delegation_id, + ); + create_or_read_static_delegate_delivery_at(root, &delivery).expect("create dispatched delivery"); +} + +/// 把一条已 dispatched 的 delivery 标记为「存在质量缺口」的 needs-repair 终态并认领—— +/// 依赖调用方传入的 expected_artifacts 里至少有一个从未真正写入项目的路径, +/// 确保 build_static_delegate_structured_result_at 必然推导出 NeedsRepair。 +#[allow(clippy::too_many_arguments)] +fn mark_and_claim_static_delegate_needs_repair( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + target_session_id: &str, + target_run_id: &str, + delegation_id: &str, + expected_artifacts: &[String], + claim_action_id: &str, +) { + let result = build_static_delegate_structured_result_at( + root, + "completed", + expected_artifacts, + false, + None, + None, + None, + None, + ) + .expect("build needs-repair result"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsRepair, + "帮助函数要求 expected_artifacts 至少一项缺失,产出必须是 needs-repair" + ); + mark_static_delegate_delivery_ready_with_result_at( + root, + target_agent_id, + target_session_id, + target_run_id, + delegation_id, + "completed", + "存在质量缺口", + result, + ) + .expect("mark needs-repair ready"); + claim_ready_static_delegate_receipts_at(root, parent_agent_id, parent_run_id, claim_action_id) + .expect("claim needs-repair receipt"); +} + +/// 把一条已 dispatched 的 delivery 标记为 needs-user-input 终态并认领。 +/// 注意:即便这一跳是纯粹的用户澄清、不产出文件,structuredResult 也必须完整覆盖 +/// delivery 记录自身声明的 expected_artifacts(校验在 write_static_delegate_delivery_at +/// 落盘时统一执行),所以这里必须把调用方真实的 expected_artifacts 传给 +/// build_static_delegate_structured_result_at——needs-user-input 分类优先级高于 +/// missing-artifacts,即便产物仍缺失也不影响 contract_status 推导。 +#[allow(clippy::too_many_arguments)] +fn mark_and_claim_static_delegate_needs_user_input( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + target_session_id: &str, + target_run_id: &str, + delegation_id: &str, + expected_artifacts: &[String], + questions_body: &str, + claim_action_id: &str, +) { + let result = build_static_delegate_structured_result_at( + root, + "completed", + expected_artifacts, + false, + None, + None, + None, + Some(&format!("AGC_NEEDS_USER_INPUT_V1\n{questions_body}")), + ) + .expect("build needs-user-input result"); + assert_eq!( + result.contract_status, + StaticDelegateContractStatus::NeedsUserInput + ); + mark_static_delegate_delivery_ready_with_result_at( + root, + target_agent_id, + target_session_id, + target_run_id, + delegation_id, + "completed", + "需要用户澄清", + result, + ) + .expect("mark needs-user-input ready"); + claim_ready_static_delegate_receipts_at(root, parent_agent_id, parent_run_id, claim_action_id) + .expect("claim needs-user-input receipt"); +} + +/// 认领已 ready 的 needs-user-input delivery 之后,走真实的 +/// ensure_static_delegate_user_input_wait_at -> prepare -> answer 链路取得该 delivery 的 +/// (questionsSha256, answersSha256),并清理已回答的 pending 动作文件。 +fn answer_static_delegate_clarification_wait( + root: &Path, + state: &mut AgentRuntimeState, + parent_agent_id: &str, + parent_run_id: &str, + delegation_id: &str, + answers: BTreeMap, + response_id: &str, +) -> (String, String) { + let deliveries = claimed_static_delegate_deliveries_at(root, parent_agent_id, parent_run_id) + .expect("read claimed deliveries"); + assert!( + ensure_static_delegate_user_input_wait_at(root, state, &deliveries) + .expect("create supervisor wait"), + "认领到的 needs-user-input delivery 必须触发一次 Supervisor 等待" + ); + let pending = + read_game_creator_agent_runtime_pending_tool_action(root, parent_agent_id, parent_run_id) + .expect("read pending action"); + assert!( + pending.task.contains(delegation_id), + "pending action 应引用 {delegation_id}:{}", + pending.task + ); + let request = match prepare_game_creator_agent_user_input_request_at(root, &pending) + .expect("prepare clarification request") + { + AgentRuntimeUserInputRecovery::Waiting(request) => request, + other => panic!("unexpected clarification recovery: {other:?}"), + }; + let (_, observation) = answer_game_creator_agent_user_input_request_for_pending_at( + root, + &pending, + &request.request_id, + response_id, + answers, + ) + .expect("answer clarification request"); + let detail = serde_json::from_str::( + observation.detail.as_deref().expect("answer detail"), + ) + .expect("parse answer detail"); + let questions_sha256 = detail["questionsSha256"] + .as_str() + .expect("questions sha") + .to_string(); + let answers_sha256 = detail["answersSha256"] + .as_str() + .expect("answers sha") + .to_string(); + let pending_path = + game_creator_agent_runtime_pending_tool_action_path(root, parent_agent_id, parent_run_id); + fs::remove_file(&pending_path).expect("clear answered pending"); + (questions_sha256, answers_sha256) +} + +/// 走真实 agent.delegate 路径派发一次澄清 continuation(携带 continuationOfDelegationId / +/// questionsSha256 / answersSha256),不对结果做任何断言。 +#[allow(clippy::too_many_arguments)] +fn dispatch_static_delegate_clarification_continuation( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + task_text: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + original_delegation_id: &str, + questions_sha256: &str, + answers_sha256: &str, + action_id: &str, +) -> AgentRuntimeToolObservation { + let delegate_input = serde_json::json!({ + "agentId": target_agent_id, + "task": task_text, + "acceptanceCriteria": acceptance_criteria, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": original_delegation_id, + "runId": null, + "continuationOfDelegationId": original_delegation_id, + "questionsSha256": questions_sha256, + "answersSha256": answers_sha256, + }); + observe_agent_runtime_agent_delegate( + root, + parent_agent_id, + parent_run_id, + Some(action_id), + &delegate_input, + ) +} + +/// 只读地推导一次澄清 continuation 会得到的 delegationId——用于在真正派发前/后核对落盘身份, +/// 不产生任何副作用(validate_static_delegate_clarification_continuation_at 本身是只读校验)。 +fn clarification_continuation_delegation_id( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + original_delegation_id: &str, + questions_sha256: &str, + answers_sha256: &str, +) -> String { + let input = serde_json::json!({ + "continuationOfDelegationId": original_delegation_id, + "questionsSha256": questions_sha256, + "answersSha256": answers_sha256, + }); + let identity = validate_static_delegate_clarification_continuation_at( + root, + parent_agent_id, + parent_run_id, + &input, + Some(original_delegation_id), + ) + .expect("validate continuation binding") + .expect("derived continuation identity"); + agent_runtime_delegation_id(parent_agent_id, parent_run_id, target_agent_id, &identity) +} + +/// 派发一次澄清 continuation 并断言必须成功、必须真正落盘,返回新 delivery 的 +/// (delegationId, targetSessionId, targetRunId),供下一跳复用。 +#[allow(clippy::too_many_arguments)] +fn clarification_round_must_succeed( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + task_text: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + original_delegation_id: &str, + questions_sha256: &str, + answers_sha256: &str, + action_id: &str, +) -> (String, String, String) { + let new_delegation_id = clarification_continuation_delegation_id( + root, + parent_agent_id, + parent_run_id, + target_agent_id, + original_delegation_id, + questions_sha256, + answers_sha256, + ); + let observation = dispatch_static_delegate_clarification_continuation( + root, + parent_agent_id, + parent_run_id, + target_agent_id, + task_text, + acceptance_criteria, + expected_artifacts, + original_delegation_id, + questions_sha256, + answers_sha256, + action_id, + ); + assert_eq!(observation.status, "ok", "{observation:?}"); + let new_delivery = read_static_delegate_delivery_at(root, &new_delegation_id) + .expect("read new delivery") + .expect("continuation 成功后必须真正落盘"); + ( + new_delegation_id, + new_delivery.target_session_id, + new_delivery.target_run_id, + ) +} + +/// 走完整「标记 needs-user-input -> 认领 -> 用户作答 -> 真实 continuation 派发」链路, +/// 断言这一轮必须成功,返回新 delivery 的 (delegationId, targetSessionId, targetRunId)。 +#[allow(clippy::too_many_arguments)] +fn drive_static_delegate_clarification_round( + root: &Path, + state: &mut AgentRuntimeState, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + target_session_id: &str, + target_run_id: &str, + task_text: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + original_delegation_id: &str, + questions_body: &str, + answer_key: &str, + answer_value: &str, + mark_claim_action_id: &str, + answer_response_id: &str, + delegate_action_id: &str, +) -> (String, String, String) { + mark_and_claim_static_delegate_needs_user_input( + root, + parent_agent_id, + parent_run_id, + target_agent_id, + target_session_id, + target_run_id, + original_delegation_id, + expected_artifacts, + questions_body, + mark_claim_action_id, + ); + let (questions_sha256, answers_sha256) = answer_static_delegate_clarification_wait( + root, + state, + parent_agent_id, + parent_run_id, + original_delegation_id, + BTreeMap::from([(answer_key.to_string(), answer_value.to_string())]), + answer_response_id, + ); + clarification_round_must_succeed( + root, + parent_agent_id, + parent_run_id, + target_agent_id, + task_text, + acceptance_criteria, + expected_artifacts, + original_delegation_id, + &questions_sha256, + &answers_sha256, + delegate_action_id, + ) +} + +/// 走到「标记 needs-user-input -> 认领 -> 用户作答」为止,然后尝试真实 continuation 派发, +/// 但断言这一跳必须失败,返回失败的 observation 供调用方核对错误文案。 +#[allow(clippy::too_many_arguments)] +fn drive_static_delegate_clarification_round_expect_rejection( + root: &Path, + state: &mut AgentRuntimeState, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + target_session_id: &str, + target_run_id: &str, + task_text: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + original_delegation_id: &str, + questions_body: &str, + answer_key: &str, + answer_value: &str, + mark_claim_action_id: &str, + answer_response_id: &str, + delegate_action_id: &str, +) -> AgentRuntimeToolObservation { + mark_and_claim_static_delegate_needs_user_input( + root, + parent_agent_id, + parent_run_id, + target_agent_id, + target_session_id, + target_run_id, + original_delegation_id, + expected_artifacts, + questions_body, + mark_claim_action_id, + ); + let (questions_sha256, answers_sha256) = answer_static_delegate_clarification_wait( + root, + state, + parent_agent_id, + parent_run_id, + original_delegation_id, + BTreeMap::from([(answer_key.to_string(), answer_value.to_string())]), + answer_response_id, + ); + let observation = dispatch_static_delegate_clarification_continuation( + root, + parent_agent_id, + parent_run_id, + target_agent_id, + task_text, + acceptance_criteria, + expected_artifacts, + original_delegation_id, + &questions_sha256, + &answers_sha256, + delegate_action_id, + ); + assert_eq!(observation.status, "failed", "{observation:?}"); + observation +} + +/// 走真实 agent.delegate 路径派发一次不携带任何澄清字段的「质量返工」,不对结果做断言。 +#[allow(clippy::too_many_arguments)] +fn dispatch_static_delegate_plain_repair( + root: &Path, + parent_agent_id: &str, + parent_run_id: &str, + target_agent_id: &str, + task_text: &str, + acceptance_criteria: &[String], + expected_artifacts: &[String], + original_delegation_id: &str, + action_id: &str, +) -> AgentRuntimeToolObservation { + let delegate_input = serde_json::json!({ + "agentId": target_agent_id, + "task": task_text, + "acceptanceCriteria": acceptance_criteria, + "expectedArtifacts": expected_artifacts, + "repairOfDelegationId": original_delegation_id, + "runId": null, + }); + observe_agent_runtime_agent_delegate( + root, + parent_agent_id, + parent_run_id, + Some(action_id), + &delegate_input, + ) +} + #[test] fn supervisor_chat_window_carries_encoded_project_path() { assert_eq!( @@ -2943,28 +3397,23 @@ fn clarification_continuation_chain_supports_multiple_rounds() { &round2_identity, ); - // ---------- 第 3 轮尝试:对 D2 发起 continuation 以创建 D3,repair_of = D2 ---------- - // 关键钉子:直接调用 validate_static_delegate_repair_request_at(agent.delegate 在真实路径 - // 上无条件对所有携带 repairOfDelegationId 的请求都会跑这一步),用第 2 轮的参数验证返工 - // 深度门是否放行。 - let depth_gate_error = validate_static_delegate_repair_request_at( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - run_id, - &d3_delegation_id, - target_agent_id, - &acceptance_criteria, - &expected_artifacts, - Some(&d2_delegation_id), - ) - .expect_err("repair depth gate must reject the third round directly"); + // ---------- 第 3 轮:对 D2 发起 continuation 以创建 D3,repair_of = D2 ---------- + // 【有意的行为变更】本测试原先在这里断言第 3 轮必须被拒绝——旧实现里返工深度门对 + // “质量返工”和“澄清 continuation”一视同仁,静态委派全链路只放行一跳,第 3 轮永远不可达。 + // 本轮设计冻结把返工深度(上限 1)与澄清轮次(上限 3)拆成两个独立的链上推断维度: + // D2 -> D3 的分类判据是 D2.structured_result.contract_status == NeedsUserInput(真), + // 因此它是澄清 continuation,只受澄清轮次上限约束,不再触碰返工深度门。 + // 第 3 轮现在必须成功、D3 必须真正落盘——这是设计冻结明确要求的新行为,不是回归。 + let pre_round3_deliveries = + list_static_delegate_deliveries_at(&root).expect("list deliveries before round3"); + let (pre_round3_depth, pre_round3_round) = + static_delegate_lineage_counters(&pre_round3_deliveries, &d2_delegation_id); assert_eq!( - depth_gate_error, "静态委派返工深度最多为 1", - "实际返回错误:{depth_gate_error}" + (pre_round3_depth, pre_round3_round), + (0, 1), + "D2 应为返工深度 0、澄清轮次 1(D1 是链根,D1 -> D2 是第 1 轮澄清)" ); - // 再走一遍真实的 agent.delegate 全路径,确认生产代码路径上同一道门同样会拦下第 3 轮, - // 而不只是被孤立调用的校验函数拦下。 let round3_delegate_input = serde_json::json!({ "agentId": target_agent_id, "task": "根据用户确认的卡通美术风格继续完成原方案", @@ -2984,21 +3433,1030 @@ fn clarification_continuation_chain_supports_multiple_rounds() { &round3_delegate_input, ); assert_eq!( - round3_observation.status, "failed", - "第 3 轮 continuation 在真实 agent.delegate 路径上必须被拒绝:{round3_observation:?}" - ); - assert!( - round3_observation.summary.contains("深度最多为 1"), - "第 3 轮失败摘要应包含返工深度门的原文,实际为:{round3_observation:?}" + round3_observation.status, "ok", + "第 3 轮 continuation 在真实 agent.delegate 路径上必须成功:{round3_observation:?}" ); - // D3 从未真正落盘:第 3 轮在整条链路上永远不可达(unreachable as a consequence, - // 不是被某条独立规则在“第 3 轮”专门拦下)。 - assert!( - read_static_delegate_delivery_at(&root, &d3_delegation_id) - .expect("read D3 lookup") - .is_none(), - "第 3 轮被拒绝后不应留下任何 D3 delivery 记录" + let d3_delivery = read_static_delegate_delivery_at(&root, &d3_delegation_id) + .expect("read D3 lookup") + .expect("第 3 轮成功后 D3 必须真正落盘"); + assert_eq!( + d3_delivery.repair_of_delegation_id.as_deref(), + Some(d2_delegation_id.as_str()), + "D3 必须记录自己续接自 D2" + ); + let post_round3_deliveries = + list_static_delegate_deliveries_at(&root).expect("list deliveries after round3"); + let (round3_depth, round3_round) = + static_delegate_lineage_counters(&post_round3_deliveries, &d3_delegation_id); + assert_eq!( + (round3_depth, round3_round), + (0, 2), + "D3 应继承返工深度 0(R1 澄清跳不重置、也不新增返工深度),澄清轮次推导为 2" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +const CLARIFICATION_QUESTION_BODY: &str = concat!( + "{\"questions\":[{\"id\":\"confirm\",\"header\":\"确认\",", + "\"question\":\"请确认继续?\",", + "\"options\":[", + "{\"label\":\"确认\",\"description\":\"继续下一步。\"},", + "{\"label\":\"暂缓\",\"description\":\"先不要继续。\"}", + "]}]}" +); + +#[test] +fn clarification_round_limit_rejects_fourth_round() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-clarification-round-limit", "澄清轮次上限边界测试") + .expect("project init"); + let run_id = "project-supervisor-clarification-round-limit-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "协调专业 Agent 完成玩法方案", + run_id, + "agent-chat", + "协调专业 Agent", + vec!["取得用户多轮澄清后继续专业委派".to_string()], + ) + .expect("start supervisor run"); + + let acceptance_criteria = vec!["明确美术资源清单".to_string()]; + let expected_artifacts: Vec = vec![]; + let target_agent_id = "design-director"; + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire round-limit target lane") + .expect("round-limit target lane available"); + + let d0_id = "round-limit-d0"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "round-limit-d0-action", + d0_id, + target_agent_id, + "round-limit-d0-child-session", + "round-limit-d0-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + + // 第 1、2、3 轮澄清续接必须依次成功(round 分别推导为 1、2、3,均未越过默认上限 3)。 + let (d1_id, d1_session, d1_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "round-limit-d0-child-session", + "round-limit-d0-child-run", + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + d0_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-limit-claim-1", + "round-limit-response-1", + "round-limit-delegate-1", + ); + let (d2_id, d2_session, d2_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d1_session, + &d1_run, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d1_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-limit-claim-2", + "round-limit-response-2", + "round-limit-delegate-2", + ); + let (d3_id, d3_session, d3_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d2_session, + &d2_run, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d2_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-limit-claim-3", + "round-limit-response-3", + "round-limit-delegate-3", + ); + let all_deliveries = list_static_delegate_deliveries_at(&root).expect("list deliveries"); + assert_eq!( + static_delegate_lineage_counters(&all_deliveries, &d3_id), + (0, 3), + "D3 应为返工深度 0、澄清轮次 3——已经用满默认上限" + ); + + // 第 4 轮:D3 -> D4 必须被拒绝,且错误文案必须是澄清轮次上限专用文案, + // 不能是返工深度门的文案——两个维度互相独立,不能串扰。 + let rejection = drive_static_delegate_clarification_round_expect_rejection( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d3_session, + &d3_run, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d3_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-limit-claim-4", + "round-limit-response-4", + "round-limit-delegate-4", + ); + assert!( + rejection.summary.contains("澄清轮次已达上限"), + "第 4 轮失败摘要应包含澄清轮次上限文案,实际为:{rejection:?}" + ); + assert!( + !rejection.summary.contains("深度最多为 1"), + "第 4 轮的拒绝理由不能是返工深度门的文案,实际为:{rejection:?}" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn alternating_repair_and_clarification_hop_blocks_second_repair_at_depth_two() { + // 最脆弱的一处钉子测试:交替链 D1(根) -> 质量返工 D2(depth=1) -> D2 转 NeedsUserInput + // -> 澄清 D3(depth 必须仍为 1、round=1) -> D3 转 NeedsRepair -> 对 D3 发起返工 D4 + // 必须被拒绝(因为 depth 会变成 2,超过上限 1)。 + // 这条同时证伪两种可能的失误实现: + // - “R1 误把 depth 清零”:如果 D3 的 depth 被错误算成 0,D4 就会被误放行。 + // - “R2 漏加 1”:如果 D2 的 depth 从未真正变成 1(例如实现里“有 parent 就继承 depth” + // 这种自然默认,从不区分澄清跳与返工跳),D4 同样会被误放行。 + // 现有的“纯返工链”和“纯澄清链”测试都无法同时抓住这两种失误,只有交替链能做到。 + let root = unique_project_path(); + init_local_game_project_at(&root, "project-alternating-chain", "交替返工澄清链钉子测试") + .expect("project init"); + let run_id = "project-supervisor-alternating-chain-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验收专业 Agent 并按需返工/澄清", + run_id, + "agent-chat", + "等待专业回执", + vec!["完成合同验收".to_string()], + ) + .expect("start alternating chain run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/alternating-missing.md".to_string()]; + let target_agent_id = "design-director"; + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire alternating chain target lane") + .expect("alternating chain target lane available"); + + // ---------- D1:链根,标记为质量问题(needs-repair) ---------- + let d1_id = "alternating-chain-d1"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "alternating-chain-d1-action", + d1_id, + target_agent_id, + "alternating-chain-d1-child-session", + "alternating-chain-d1-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "alternating-chain-d1-child-session", + "alternating-chain-d1-child-run", + d1_id, + &expected_artifacts, + "alternating-chain-d1-claim", + ); + + // ---------- D1 -> D2:质量返工(depth 必须变成 1) ---------- + let d2_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "alternating-chain-d2-repair-action", + ); + let d2_observation = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + d1_id, + "alternating-chain-d2-repair-action", + ); + assert_eq!(d2_observation.status, "ok", "{d2_observation:?}"); + let d2_delivery = read_static_delegate_delivery_at(&root, &d2_id) + .expect("read D2") + .expect("D2 必须真正落盘"); + let deliveries_after_d2 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D2"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d2, &d2_id), + (1, 0), + "D2 是质量返工跳,depth 必须变成 1,round 保持 0" + ); + + // ---------- D2 转 needs-user-input ---------- + mark_and_claim_static_delegate_needs_user_input( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d2_delivery.target_session_id, + &d2_delivery.target_run_id, + &d2_id, + &expected_artifacts, + CLARIFICATION_QUESTION_BODY, + "alternating-chain-d2-claim", + ); + + // ---------- D2 -> D3:澄清续接(depth 必须仍为 1,round 必须变成 1) ---------- + let (questions_sha256, answers_sha256) = answer_static_delegate_clarification_wait( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + &d2_id, + BTreeMap::from([("confirm".to_string(), "确认".to_string())]), + "alternating-chain-d3-response", + ); + let (d3_id, d3_session, d3_run) = clarification_round_must_succeed( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "根据澄清结果继续补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + &d2_id, + &questions_sha256, + &answers_sha256, + "alternating-chain-d3-continuation-action", + ); + let deliveries_after_d3 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D3"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d3, &d3_id), + (1, 1), + "D3 是澄清跳:depth 必须原样继承自 D2(仍为 1,不清零),round 必须变成 1" + ); + + // ---------- D3 转 needs-repair ---------- + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d3_session, + &d3_run, + &d3_id, + &expected_artifacts, + "alternating-chain-d3-claim", + ); + + // ---------- 对 D3 发起返工 D4:必须被拒绝(depth 会变成 2,超过上限 1) ---------- + let d4_observation = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "再次补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + &d3_id, + "alternating-chain-d4-repair-action", + ); + assert_eq!( + d4_observation.status, "failed", + "对 D3 的返工必须被拒绝:depth 会变成 2,超过上限 1:{d4_observation:?}" + ); + assert!( + d4_observation.summary.contains("深度最多为 1"), + "拒绝理由必须是返工深度门文案,实际为:{d4_observation:?}" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn quality_rework_resets_clarification_round_budget() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-round-reset", "返工重置澄清轮次预算测试") + .expect("project init"); + let run_id = "project-supervisor-round-reset-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验收专业 Agent 并按需返工/澄清", + run_id, + "agent-chat", + "等待专业回执", + vec!["完成合同验收".to_string()], + ) + .expect("start round-reset run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/round-reset-missing.md".to_string()]; + let target_agent_id = "design-director"; + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire round-reset target lane") + .expect("round-reset target lane available"); + + // ---------- D0 -> 两轮澄清 D1、D2 ---------- + let d0_id = "round-reset-d0"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "round-reset-d0-action", + d0_id, + target_agent_id, + "round-reset-d0-child-session", + "round-reset-d0-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + let (d1_id, d1_session, d1_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "round-reset-d0-child-session", + "round-reset-d0-child-run", + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + d0_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-reset-claim-1", + "round-reset-response-1", + "round-reset-delegate-1", + ); + let (d2_id, d2_session, d2_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d1_session, + &d1_run, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d1_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-reset-claim-2", + "round-reset-response-2", + "round-reset-delegate-2", + ); + let deliveries_after_d2 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D2"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d2, &d2_id), + (0, 2), + "D2 已经历 2 轮澄清" + ); + + // ---------- D2 转 needs-repair,对其发起一次真实质量返工 D3 ---------- + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d2_session, + &d2_run, + &d2_id, + &expected_artifacts, + "round-reset-d2-claim", + ); + let d3_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "round-reset-d3-repair-action", + ); + let d3_observation = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + &d2_id, + "round-reset-d3-repair-action", + ); + assert_eq!(d3_observation.status, "ok", "{d3_observation:?}"); + let d3_delivery = read_static_delegate_delivery_at(&root, &d3_id) + .expect("read D3") + .expect("D3 必须真正落盘"); + let deliveries_after_d3 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D3"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d3, &d3_id), + (1, 0), + "D3 是质量返工跳:depth 变成 1,澄清轮次必须重置为 0——不能因为返工吃掉预设的澄清轮次预算" + ); + + // ---------- 对 D3 连续发起 3 轮澄清,全部必须放行 ---------- + let (d4_id, d4_session, d4_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d3_delivery.target_session_id, + &d3_delivery.target_run_id, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d3_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-reset-claim-4", + "round-reset-response-4", + "round-reset-delegate-4", + ); + let (d5_id, d5_session, d5_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d4_session, + &d4_run, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d4_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-reset-claim-5", + "round-reset-response-5", + "round-reset-delegate-5", + ); + let (d6_id, _d6_session, _d6_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d5_session, + &d5_run, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d5_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "round-reset-claim-6", + "round-reset-response-6", + "round-reset-delegate-6", + ); + let deliveries_after_d6 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D6"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d6, &d6_id), + (1, 3), + "D3 返工重置澄清轮次预算后,D4/D5/D6 三轮澄清必须全部放行,round 推导到 3" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn clarification_hop_does_not_block_next_node_quality_rework() { + // 场景标题「澄清不占同级返工门配额」的可达实现:D0 完成后进入 needs-user-input, + // 对 D0 的唯一合法后续动作是澄清 continuation(生产代码里 + // validate_static_delegate_clarification_continuation_at 强制要求:一旦原 delivery + // 处于 needs-user-input,任何携带 repairOfDelegationId=D0 的请求都必须同时携带匹配的 + // continuationOfDelegationId/questionsSha256/answersSha256,否则在到达同级返工排它门之前 + // 就会先被“必须绑定原 delegationId”拒绝)——因此不存在“对同一个 D0 既发澄清续接、 + // 又发一次不带澄清字段的质量返工”这种可达状态。 + // 本用例改为验证这条设计真正保证的东西:D0 的一次澄清续接消费掉的是 D0 自己的“同级 + // 返工/续接排它配额”,而不会连带影响它产生的新节点 C1 自身的配额——C1 完成后如果是 + // 质量问题,仍然可以正常发起一次真实返工并被放行。 + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-sibling-quota", + "澄清跳不占用下一节点返工配额测试", + ) + .expect("project init"); + let run_id = "project-supervisor-sibling-quota-run"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验收专业 Agent 并按需返工/澄清", + run_id, + "agent-chat", + "等待专业回执", + vec!["完成合同验收".to_string()], + ) + .expect("start sibling-quota run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/sibling-quota-missing.md".to_string()]; + let target_agent_id = "design-director"; + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire sibling-quota target lane") + .expect("sibling-quota target lane available"); + + let d0_id = "sibling-quota-d0"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "sibling-quota-d0-action", + d0_id, + target_agent_id, + "sibling-quota-d0-child-session", + "sibling-quota-d0-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + // 先做一次澄清续接生成 C1(repair_of = D0),消费掉 D0 的同级续接配额。 + let (c1_id, c1_session, c1_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "sibling-quota-d0-child-session", + "sibling-quota-d0-child-run", + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + d0_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "sibling-quota-claim-1", + "sibling-quota-response-1", + "sibling-quota-delegate-1", + ); + + // 直接对 D0 再发一次不带澄清字段的“真实质量返工”:必须被拒绝,且拒绝理由必须是 + // 澄清 continuation 校验器的“必须绑定原 delegationId”,而不是同级返工排它门的 + // “最多允许一轮返工”——证明挡住它的是分类规则本身,而不是 C1 消费掉了配额。 + let plain_repair_against_d0 = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "跳过澄清直接返工", + &acceptance_criteria, + &expected_artifacts, + d0_id, + "sibling-quota-plain-repair-against-d0-action", + ); + assert_eq!( + plain_repair_against_d0.status, "failed", + "{plain_repair_against_d0:?}" + ); + assert!( + plain_repair_against_d0 + .summary + .contains("必须绑定原 delegationId"), + "对处于 needs-user-input 的 D0 发起不带澄清字段的请求,必须被澄清校验器拒绝,\ + 而不是被同级返工排它门拒绝,实际为:{plain_repair_against_d0:?}" + ); + + // C1 完成后本身是质量问题:对 C1 发起一次真实返工,必须被放行——证明 D0 的续接配额 + // 与 C1 自己的返工配额相互独立,澄清跳不会连带占用后续节点的配额。 + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &c1_session, + &c1_run, + &c1_id, + &expected_artifacts, + "sibling-quota-c1-claim", + ); + let repair_against_c1 = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + &c1_id, + "sibling-quota-repair-against-c1-action", + ); + assert_eq!( + repair_against_c1.status, "ok", + "C1 自己的返工配额不应被 D0 的澄清续接连带消耗:{repair_against_c1:?}" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn legacy_pre_clarification_delivery_repair_counts_toward_depth() { + // 手工构造一份 #165(引入 NeedsUserInput)之前风格的旧 delivery:contract_status + // 只可能是 EvidenceReady/NeedsRepair,没有任何澄清相关字段被写入过。验证链上推断函数 + // 把它正确分类为“非澄清”,据此发起的返工正确计入返工深度,且第二次返工仍被深度门拦下。 + let root = unique_project_path(); + init_local_game_project_at(&root, "project-legacy-delivery", "历史记录分类回归测试") + .expect("project init"); + let run_id = "project-supervisor-legacy-delivery-run"; + let parent_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验收专业 Agent 并按需返工", + run_id, + "agent-chat", + "等待专业回执", + vec!["完成合同验收".to_string()], + ) + .expect("start legacy-delivery run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/legacy-missing.md".to_string()]; + let target_agent_id = "design-director"; + + let legacy_id = "legacy-delivery-original"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &parent_state.session_id, + run_id, + "legacy-delivery-action", + legacy_id, + target_agent_id, + "legacy-delivery-child-session", + "legacy-delivery-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + // 手工写入一条 #165 之前风格的终态结果:没有 user_input_questions_sha256, + // 没有 clarification_request_id/clarification_answers_sha256。 + let mut legacy_delivery = read_static_delegate_delivery_at(&root, legacy_id) + .expect("read legacy delivery") + .expect("legacy delivery exists"); + legacy_delivery.status = StaticDelegateDeliveryStatus::ClaimedByParent; + legacy_delivery.terminal_status = Some("completed".to_string()); + legacy_delivery.result_summary = Some("旧版本质量缺口".to_string()); + legacy_delivery.structured_result = Some(StaticDelegateStructuredResult { + contract_status: StaticDelegateContractStatus::NeedsRepair, + artifacts: Vec::new(), + missing_expected_artifacts: expected_artifacts.clone(), + verification_required: false, + verified_revision: None, + evidence: Vec::new(), + error: Some("缺少 1 个预期产物".to_string()), + user_input_questions: Vec::new(), + user_input_questions_sha256: None, + }); + legacy_delivery.claimed_by_action_id = Some("legacy-delivery-claim-action".to_string()); + write_static_delegate_delivery_at(&root, &legacy_delivery).expect("persist legacy delivery"); + assert!(legacy_delivery.clarification_request_id.is_none()); + assert!(legacy_delivery.clarification_answers_sha256.is_none()); + + let deliveries_before_repair = + list_static_delegate_deliveries_at(&root).expect("list deliveries before repair"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_before_repair, legacy_id), + (0, 0), + "旧记录自身是链根:depth=0,round=0" + ); + + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire legacy-delivery target lane") + .expect("legacy-delivery target lane available"); + let repair_id = agent_runtime_delegation_id( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "legacy-delivery-repair-action", + ); + let repair_observation = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + legacy_id, + "legacy-delivery-repair-action", + ); + assert_eq!( + repair_observation.status, "ok", + "旧记录必须被分类为非澄清,正常放行返工:{repair_observation:?}" + ); + let deliveries_after_repair = + list_static_delegate_deliveries_at(&root).expect("list deliveries after repair"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_repair, &repair_id), + (1, 0), + "返工必须正确计入深度:depth 变成 1" + ); + + // 返工的返工必须被深度门拦下:先让返工本身走到 ClaimedByParent(否则会先被 + // “只能引用已由父 Agent 认领的原回执”拦下,而不是深度门),再尝试对它发起第二次返工。 + let repair_delivery = read_static_delegate_delivery_at(&root, &repair_id) + .expect("read repair delivery") + .expect("repair delivery exists"); + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &repair_delivery.target_session_id, + &repair_delivery.target_run_id, + &repair_id, + &expected_artifacts, + "legacy-delivery-repair-claim", + ); + + let nested_repair_error = validate_static_delegate_repair_request_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + "legacy-delivery-nested-repair", + target_agent_id, + &acceptance_criteria, + &expected_artifacts, + Some(&repair_id), + ) + .expect_err("返工的返工必须被深度门拒绝"); + assert!(nested_repair_error.contains("深度最多为 1")); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn static_delegate_repair_request_rejects_cross_run_reference() { + // 跨 run 隔离:同一 Supervisor agent_id 下,换一个 parent_run_id 后引用另一个 run 里的 + // delivery 发起返工,必须被拒绝——静态委派的返工/续接关系不能跨 Supervisor 父 run。 + let root = unique_project_path(); + init_local_game_project_at(&root, "project-cross-run", "静态委派跨 run 隔离测试") + .expect("project init"); + let owning_run_id = "project-supervisor-cross-run-owner-run"; + let other_run_id = "project-supervisor-cross-run-other-run"; + let owner_state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "验收专业 Agent 并按需返工", + owning_run_id, + "agent-chat", + "等待专业回执", + vec!["完成合同验收".to_string()], + ) + .expect("start owning run"); + start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "另一个互不相关的 run", + other_run_id, + "agent-chat", + "等待专业回执", + vec!["完成另一份合同验收".to_string()], + ) + .expect("start other run"); + + let acceptance_criteria = vec!["必须交付缺失文件".to_string()]; + let expected_artifacts = vec!["game/cross-run-missing.md".to_string()]; + let target_agent_id = "design-director"; + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire cross-run target lane") + .expect("cross-run target lane available"); + + let original_id = "cross-run-original"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &owner_state.session_id, + owning_run_id, + "cross-run-original-action", + original_id, + target_agent_id, + "cross-run-original-child-session", + "cross-run-original-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + mark_and_claim_static_delegate_needs_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + owning_run_id, + target_agent_id, + "cross-run-original-child-session", + "cross-run-original-child-run", + original_id, + &expected_artifacts, + "cross-run-original-claim", + ); + + // 在属主 run 里发起返工必须放行——先证明这条 delivery 本身可以被正常返工。 + let same_run_repair = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + owning_run_id, + target_agent_id, + "补齐缺失文件", + &acceptance_criteria, + &expected_artifacts, + original_id, + "cross-run-same-run-repair-action", + ); + assert_eq!(same_run_repair.status, "ok", "{same_run_repair:?}"); + + // 换一个互不相关的 parent_run_id,引用同一个 original_id 发起返工:必须被拒绝。 + let cross_run_repair = dispatch_static_delegate_plain_repair( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + other_run_id, + target_agent_id, + "跨 run 冒用返工", + &acceptance_criteria, + &expected_artifacts, + original_id, + "cross-run-cross-run-repair-action", + ); + assert_eq!( + cross_run_repair.status, "failed", + "跨 run 引用旧链节点的返工请求必须被拒绝:{cross_run_repair:?}" + ); + assert!( + cross_run_repair.summary.contains("同一 Supervisor 父 run"), + "拒绝理由必须是跨 run 隔离文案,实际为:{cross_run_repair:?}" + ); + + drop(target_lock); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn game_chat_source_caps_clarification_round_at_one() { + // source 区分:game-chat source 下澄清上限为 1,第 2 轮就被拒绝; + // 非 game-chat source(本文件其余用例默认场景)下能走到 3——已由 + // clarification_round_limit_rejects_fourth_round 覆盖,这里只验证 game-chat 分支。 + let root = unique_project_path(); + init_local_game_project_at(&root, "project-game-chat-round-cap", "game-chat 澄清上限测试") + .expect("project init"); + let run_id = "project-supervisor-game-chat-round-cap-run"; + // 绑定 Run Profile 为 game-chat source;profile 保持默认 STANDARD(而非 + // AUTONOMOUS_GAME_BUILD),避免额外触发“game-chat 单主路径禁止 Supervisor 直接委派” + // 这条与本用例无关的美术委派专用门(那条门只在 profile=AUTONOMOUS_GAME_BUILD 时生效)。 + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + None, + None, + ) + .expect("bind game-chat run profile"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "game-chat 单主路径协调专业 Agent", + run_id, + AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, + "协调专业 Agent", + vec!["取得用户澄清后继续专业委派".to_string()], + ) + .expect("start game-chat run"); + + let acceptance_criteria = vec!["明确美术资源清单".to_string()]; + let expected_artifacts: Vec = vec![]; + let target_agent_id = "design-director"; + let target_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, target_agent_id) + .expect("acquire game-chat target lane") + .expect("game-chat target lane available"); + + let d0_id = "game-chat-round-cap-d0"; + create_dispatched_static_delegate_delivery( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &state.session_id, + run_id, + "game-chat-round-cap-d0-action", + d0_id, + target_agent_id, + "game-chat-round-cap-d0-child-session", + "game-chat-round-cap-d0-child-run", + &acceptance_criteria, + &expected_artifacts, + None, + ); + + // 第 1 轮必须放行(round 推导为 1,未达 game-chat 上限 1?—— 判据是 + // original_clarification_round >= limit:D0 自身 round=0,0 >= 1 为假,放行)。 + let (d1_id, d1_session, d1_run) = drive_static_delegate_clarification_round( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + "game-chat-round-cap-d0-child-session", + "game-chat-round-cap-d0-child-run", + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + d0_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "game-chat-round-cap-claim-1", + "game-chat-round-cap-response-1", + "game-chat-round-cap-delegate-1", + ); + let deliveries_after_d1 = + list_static_delegate_deliveries_at(&root).expect("list deliveries after D1"); + assert_eq!( + static_delegate_lineage_counters(&deliveries_after_d1, &d1_id), + (0, 1), + "game-chat 下第 1 轮澄清必须放行,round 推导为 1" + ); + + // 第 2 轮:D1 自身 round=1,1 >= game-chat 上限 1 为真,必须被拒绝。 + let rejection = drive_static_delegate_clarification_round_expect_rejection( + &root, + &mut state, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + target_agent_id, + &d1_session, + &d1_run, + "继续推进玩法方案", + &acceptance_criteria, + &expected_artifacts, + &d1_id, + CLARIFICATION_QUESTION_BODY, + "confirm", + "确认", + "game-chat-round-cap-claim-2", + "game-chat-round-cap-response-2", + "game-chat-round-cap-delegate-2", + ); + assert!( + rejection.summary.contains("澄清轮次已达上限"), + "game-chat 下第 2 轮必须被澄清轮次上限拒绝:{rejection:?}" ); drop(target_lock);