Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20b1fd63de | |||
| 105591bac5 | |||
| da44d66dc8 | |||
| 0a85c4f87e | |||
| efce7b102f | |||
| db5edc948c | |||
| 45c3780bf5 | |||
| 6fcf42e4ac | |||
| e0f9f811b7 | |||
| 03b5c1c9f4 | |||
| 58992330aa | |||
| 2c687b01a4 | |||
| c0e377f479 | |||
| 6001b87215 | |||
| b08ab6ee66 | |||
| 7f80012d7f | |||
| fbe95591d5 | |||
| d222aad2ec | |||
| 13b28ebbc7 | |||
| 30648e6b93 | |||
| 9dd1052374 | |||
| aec9568c39 | |||
| 0a1f0e0b26 | |||
| 5c31ae91ca | |||
| 3ba6168c6a | |||
| 2628b83d4b | |||
| a7b2b0e23b | |||
| ef982fdb78 | |||
| 91b65f94ca | |||
| 70513cf049 | |||
| 87aed0b765 | |||
| 733a7015af |
@@ -957,6 +957,9 @@ async function runInteractiveCargo(cliArguments, setActiveChild) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 立项策划跑 standard 档,`agent.delegate` 这类动作按项目权限策略必须逐个确认,
|
||||
// 而确认和问询都只从 CLI 的 stdin 读。自主构建档没有这一步,所以只有 --plan 需要
|
||||
// 一个把「人坐在终端前敲 approve」自动化掉的应答器;判据本身仍然走后端确认命令。
|
||||
const swarmConfirmationPromptPattern = /输入 approve 或 reject:$/u;
|
||||
const swarmUserInputPromptPattern = /请选择 1-\d+,或直接输入其他答案:$/u;
|
||||
|
||||
|
||||
@@ -1553,11 +1553,13 @@ if (!viteConfigSource.includes('allow: [repoRoot]')) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!(
|
||||
tauriConfig.build?.beforeDevCommand?.includes(
|
||||
'run ai-game-creator-shell:dev-server',
|
||||
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
|
||||
)) {
|
||||
if (
|
||||
!(
|
||||
tauriConfig.build?.beforeDevCommand?.includes(
|
||||
'run ai-game-creator-shell:dev-server',
|
||||
) || tauriConfig.build?.beforeDevCommand?.includes('run agc:serve')
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator shell beforeDevCommand must start the selected Vite dev server',
|
||||
);
|
||||
|
||||
@@ -1192,7 +1192,7 @@ async function main() {
|
||||
function isDirectModuleExecution() {
|
||||
return Boolean(
|
||||
process.argv[1] &&
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
resolve(process.argv[1]) === fileURLToPath(import.meta.url),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,4 +2,8 @@
|
||||
|
||||
需要等待专业 Agent 时不得调用 respond_to_user;Runtime 会通过 delegate/all-join 完成屏障保持同一父 run,取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。contractStatus=needs-user-input 时,Runtime 会按原 delivery 逐一发起 user.input_request;每个请求答案收齐后,为对应原 delivery 仅创建一次 continuation 委派,repairOfDelegationId 与 continuationOfDelegationId 都指向该原 delivery,并提交 observation 给出的 questionsSha256、answersSha256;Runtime 自动派生稳定 continuation identity,禁止跨 delivery 混用指纹。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。
|
||||
|
||||
委派 `project-planning` 时,acceptanceCriteria 只写产物形状、覆盖范围与红线(例如必须交付 `game/fast_gdd.md`、必须原创、必须只定义一个 MVP 闭环),**不得替用户预先裁定产品取舍**。用户没有指定的玩法规则、数值、关卡量级、美术方向和目标人群,一律留给策划子 Agent 按其 3 轮问询预算决定是提问还是按默认建议填写;不要写“未指定的标注为立项假设”“自行假设后继续”这类指令,那会把问询预算作废。平台事实(自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览)由 Runtime 固定注入,属于已定事实,不得要求标为待定、建议或开放项。
|
||||
|
||||
对 `project-planning` 的澄清 continuation,必须按 A/B/“需要原型验证”三项合同原样转述;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。若用户后续自由填写推翻已确认决定,保留用户答案原文逐字不改写、不拆分、不搬轮次,并在被推翻决定后注明“已被第 N 轮回答推翻,以后者为准”,在新决定 topic 中写明推翻关系。
|
||||
|
||||
只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。
|
||||
|
||||
@@ -1069,10 +1069,15 @@ pub(crate) async fn continue_design_agent_at(
|
||||
.ok_or("策划 Agent 当前正在工作")?;
|
||||
let mut session = match read_design_session(root)? {
|
||||
Some(session) => session,
|
||||
None => new_design_session(
|
||||
&project_id,
|
||||
&load_game_creator_app_config()?.selected_model_id,
|
||||
),
|
||||
None => {
|
||||
if read_planning_session_v2(root)?.is_some() {
|
||||
return Err("此项目包含旧策划会话,请查看原有记录或在新项目开始五阶段策划".into());
|
||||
}
|
||||
new_design_session(
|
||||
&project_id,
|
||||
&load_game_creator_app_config()?.selected_model_id,
|
||||
)
|
||||
}
|
||||
};
|
||||
if session.project_id != project_id {
|
||||
return Err("策划会话与当前项目不匹配".into());
|
||||
|
||||
@@ -2566,11 +2566,18 @@ fn direct_taonier_art_asset_identity(
|
||||
.to_string(),
|
||||
reference_resource_ids: asset.source.reference_resource_ids.clone(),
|
||||
};
|
||||
// 参考集合先按该 kind 的请求合同收口:根素材(规范图)允许用户参考(icon-spec 没有
|
||||
// 规范前置,参考只是风格输入),派生素材仍必须按合同携带规范前置。
|
||||
let references_match_contract =
|
||||
crate::agent::platform_art_runtime_references_match_request_contract(
|
||||
&identity.reference_resource_ids,
|
||||
expected_kind,
|
||||
);
|
||||
let lineage_matches = match expected_reference_source {
|
||||
Some(source) => direct_taonier_reference_matches_local_source(root, source, &identity),
|
||||
None => identity.reference_resource_ids.is_empty(),
|
||||
None => true,
|
||||
};
|
||||
lineage_matches.then_some(identity)
|
||||
(references_match_contract && lineage_matches).then_some(identity)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2579,7 +2586,9 @@ fn direct_taonier_reference_matches_local_source(
|
||||
source: &DirectTaonierArtAssetIdentity,
|
||||
derived: &DirectTaonierArtAssetIdentity,
|
||||
) -> bool {
|
||||
let [remote_reference_id] = derived.reference_resource_ids.as_slice() else {
|
||||
// 派生素材的规范身份只由参考序列首项承担:用户参考按顺序追加在规范图之后,
|
||||
// 不能让它们顶替或淹没规范引用,也不能因为多出用户参考就判定派生关系不成立。
|
||||
let Some(remote_reference_id) = derived.reference_resource_ids.first() else {
|
||||
return false;
|
||||
};
|
||||
if derived.canvas_project_id == source.canvas_project_id
|
||||
@@ -3390,6 +3399,8 @@ async fn generate_direct_taonier_art_asset_at(
|
||||
slice_mode: (asset_kind == "art-spritesheet").then(|| "connected-components".to_string()),
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
reference_asset_ids: Vec::new(),
|
||||
target_category: None,
|
||||
};
|
||||
let runtime_context =
|
||||
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
|
||||
@@ -9802,6 +9813,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_taonier_art_package_accepts_manifest_user_references() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
init_local_game_project_at(root.path(), "direct-art-references", "直连美术参考")
|
||||
.expect("init project");
|
||||
register_direct_taonier_art_package_fixture(root.path());
|
||||
assert!(direct_taonier_art_package_is_valid(root.path()));
|
||||
|
||||
// 规范图与背景图带用户参考:参考只是风格输入,规范身份仍由参考序列首项承担。
|
||||
mutate_manifest_at(root.path(), |manifest| {
|
||||
let art_spec = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == DIRECT_CODEX_ART_SPEC_ASSET_PATH)
|
||||
.expect("art spec asset");
|
||||
art_spec.source.reference_resource_ids = vec![
|
||||
"user-reference-1".to_string(),
|
||||
"user-reference-2".to_string(),
|
||||
];
|
||||
let background = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == DIRECT_CODEX_BACKGROUND_ASSET_PATH)
|
||||
.expect("background asset");
|
||||
background
|
||||
.source
|
||||
.reference_resource_ids
|
||||
.push("user-reference-1".to_string());
|
||||
Ok(())
|
||||
})
|
||||
.expect("apply user references to the art base");
|
||||
assert!(
|
||||
direct_taonier_art_package_is_valid(root.path()),
|
||||
"user references must not invalidate the art package"
|
||||
);
|
||||
|
||||
// 图集仍只接受唯一规范引用:多一项用户参考必须失败关闭。
|
||||
mutate_manifest_at(root.path(), |manifest| {
|
||||
let spritesheet = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH)
|
||||
.expect("spritesheet asset");
|
||||
spritesheet
|
||||
.source
|
||||
.reference_resource_ids
|
||||
.push("user-reference-1".to_string());
|
||||
Ok(())
|
||||
})
|
||||
.expect("add an extra spritesheet reference");
|
||||
assert!(
|
||||
!direct_taonier_art_package_is_valid(root.path()),
|
||||
"art spritesheet must reject extra user references"
|
||||
);
|
||||
|
||||
// 用户参考不能顶替图集的规范前置。
|
||||
mutate_manifest_at(root.path(), |manifest| {
|
||||
let spritesheet = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
.find(|asset| asset.local_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH)
|
||||
.expect("spritesheet asset");
|
||||
spritesheet.source.reference_resource_ids = vec!["user-reference-1".to_string()];
|
||||
Ok(())
|
||||
})
|
||||
.expect("replace the spritesheet canonical reference");
|
||||
assert!(
|
||||
!direct_taonier_art_package_is_valid(root.path()),
|
||||
"a user reference must not replace the art spritesheet canonical spec"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
|
||||
@@ -2341,6 +2341,8 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
|
||||
slice_mode,
|
||||
grid_x,
|
||||
grid_y,
|
||||
reference_asset_ids: Vec::new(),
|
||||
target_category: None,
|
||||
};
|
||||
let _generation_guard = state.image_generation_gate.lock().await;
|
||||
let generated = with_direct_editor_api_credentials(
|
||||
|
||||
@@ -67,10 +67,11 @@ pub(crate) use canvas_generation::{
|
||||
generate_platform_art_asset_with_options_at,
|
||||
generate_platform_art_asset_with_required_slices_at, maybe_generate_platform_art_asset_step,
|
||||
needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind,
|
||||
normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category,
|
||||
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
|
||||
prepare_platform_art_asset_output_path, project_canvas_asset_media_types,
|
||||
role_has_canvas_assets, suggested_canvas_tool_call, PlatformArtAssetGenerationOptions,
|
||||
PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
|
||||
project_canvas_asset_media_types, role_has_canvas_assets, suggested_canvas_tool_call,
|
||||
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use draft_validation::{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -549,7 +549,7 @@ fn append_agent_runtime_file_read_evidence_ref(
|
||||
// 给的是**整个三元组**,不是一个碎片。`agent.acceptance_update` 的 evidence 引用
|
||||
// 要求 {agentId, runId, actionId} 三个字段,回执查找也按三元组整体做 key。早期
|
||||
// 只给 actionId,另外两个靠模型回忆——实测它第一次就把其中一个记错,白吃一次
|
||||
// 拒绝。验收证据只接受当前根 run 的回执,
|
||||
// 拒绝。反正 `validate_fast_gdd_evidence_identity` 只接受当前根 run 的回执,
|
||||
// 合法取值唯一,本来就不该让它猜。
|
||||
observation.summary = format!(
|
||||
"{} · sourceAgentId={agent_id} · sourceRunId={run_id} · sourceActionId={action_id}",
|
||||
@@ -919,9 +919,9 @@ mod file_read_source_action_id_tests {
|
||||
AgentRuntimeToolObservation {
|
||||
tool: "file.read".to_string(),
|
||||
status: "ok".to_string(),
|
||||
summary: "已读取 design.md 第 1-134 行(共 134 行)".to_string(),
|
||||
summary: "已读取 game/fast_gdd.md 第 1-134 行(共 134 行)".to_string(),
|
||||
detail: Some(format!(
|
||||
"design.md · sha256={} · lines 1-134 of 134
|
||||
"game/fast_gdd.md · sha256={} · lines 1-134 of 134
|
||||
第一行内容",
|
||||
"a".repeat(64)
|
||||
)),
|
||||
@@ -954,7 +954,7 @@ mod file_read_source_action_id_tests {
|
||||
agent_runtime_action_receipt_public_safe_detail_for_test(&root, &observation)
|
||||
.expect("safe detail still parses");
|
||||
let value = serde_json::from_str::<serde_json::Value>(&safe_detail).expect("json");
|
||||
assert_eq!(value["path"], "design.md");
|
||||
assert_eq!(value["path"], "game/fast_gdd.md");
|
||||
assert_eq!(value["lines"], "1-134 of 134");
|
||||
assert_eq!(value["contentSha256"], "a".repeat(64));
|
||||
}
|
||||
|
||||
@@ -78,6 +78,12 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str)
|
||||
matches!(kind.as_str(), "empty-response" | "deserialize")
|
||||
}
|
||||
|
||||
/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在
|
||||
/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。
|
||||
fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool {
|
||||
matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT")
|
||||
}
|
||||
|
||||
const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4;
|
||||
|
||||
/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的
|
||||
|
||||
@@ -7,6 +7,9 @@ mod design_session;
|
||||
mod finalization;
|
||||
mod json_sidecar;
|
||||
mod models;
|
||||
mod planning_gdd_model;
|
||||
mod planning_policy_v2;
|
||||
mod planning_session_v2;
|
||||
mod provider_control;
|
||||
mod provider_retry;
|
||||
mod real_e2e_checkpoint;
|
||||
@@ -21,6 +24,9 @@ pub(crate) use design_session::*;
|
||||
pub(in crate::agent) use finalization::*;
|
||||
pub(in crate::agent) use json_sidecar::*;
|
||||
pub(in crate::agent) use models::*;
|
||||
pub(crate) use planning_gdd_model::*;
|
||||
pub(crate) use planning_policy_v2::*;
|
||||
pub(crate) use planning_session_v2::*;
|
||||
pub(in crate::agent) use provider_control::*;
|
||||
pub(in crate::agent) use provider_retry::*;
|
||||
pub(in crate::agent) use real_e2e_checkpoint::*;
|
||||
|
||||
+1082
File diff suppressed because it is too large
Load Diff
+2021
File diff suppressed because it is too large
Load Diff
+1756
File diff suppressed because it is too large
Load Diff
@@ -781,7 +781,7 @@ mod provider_reconciliation_diagnostic_tests {
|
||||
let directory = tempfile::tempdir().expect("diagnostic directory");
|
||||
let snapshot = AgentRuntimeProviderRequestSnapshot {
|
||||
project_id: "project-1".to_string(),
|
||||
agent_id: "project-supervisor".to_string(),
|
||||
agent_id: "project-planning".to_string(),
|
||||
task_id: "task-1".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
run_id: "run-1".to_string(),
|
||||
@@ -805,7 +805,7 @@ mod provider_reconciliation_diagnostic_tests {
|
||||
usage: None,
|
||||
tool_calls: vec![platform_llm::LlmToolCall {
|
||||
id: "call-1".to_string(),
|
||||
name: "runtime_tool_agent_message".to_string(),
|
||||
name: "runtime_tool_plan_submit_gdd".to_string(),
|
||||
arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(),
|
||||
}],
|
||||
responses_output: Vec::new(),
|
||||
|
||||
@@ -201,17 +201,39 @@ pub(crate) fn observe_agent_runtime_agent_message(
|
||||
}
|
||||
}
|
||||
|
||||
/// 委派 task 末尾那句「你在这条链路上的位置」。返工和普通委派的语义不同,单独保留。
|
||||
/// 委派 task 末尾那句「你在这条链路上的位置」。三种跳的语义互不相同,共用一句话
|
||||
/// 就会说谎,所以这里把它显式化。
|
||||
///
|
||||
/// - `Repair`:质量返工,`repair_depth` 上限 1,「唯一返工轮」是真的。而且这句话是
|
||||
/// 做游戏链路的**授权信号**——`design-foundation` / `art-director` /
|
||||
/// `art-asset-plan` 的角色提示词都把「任务正文明确标识这是带 repairOfDelegationId
|
||||
/// 的唯一返工轮」当作 `replaceExisting=true` 的唯一许可(见 agent/prompt.rs 的三处
|
||||
/// 角色 prompt)。这一支逐字不能动。
|
||||
/// - `PlanClarification`:澄清续跑不消耗 `repair_depth`,预算是
|
||||
/// `static_delegate_clarification_round_limit_at`(plan 链路 3 轮)。master 只有一道
|
||||
/// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后
|
||||
/// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后
|
||||
/// 全部直接出稿,没有任何一个 run 走到第 2 轮。
|
||||
/// - `UserRevision`:用户在审批卡上点「修改 / 退回」后的修订轮。它同样带
|
||||
/// `repairOfDelegationId`,但 `repair_depth` 防的是 runaway agent,而这一跳每一轮
|
||||
/// 都由人触发——人本身就是循环边界,所以 `static_delegate_lineage_counters` 早就
|
||||
/// 把 depth/round 原样继承了。缺的是这句话:走 `Repair` 分支时用户第一次点修改就
|
||||
/// 会被告知「这是唯一返工轮」,和澄清跳当初那个假天花板是同一个错误。原型对应的是
|
||||
/// `USER_REVISION_SOFT_LIMIT = 16`,且超过只提示、不拒绝。
|
||||
/// - `None`:普通委派,不加这一段。
|
||||
pub(in crate::agent) enum StaticDelegateHopNote<'a> {
|
||||
None,
|
||||
Repair { original_delegation_id: &'a str },
|
||||
Repair {
|
||||
original_delegation_id: &'a str,
|
||||
},
|
||||
UserRevision {
|
||||
original_delegation_id: &'a str,
|
||||
},
|
||||
PlanClarification {
|
||||
original_delegation_id: &'a str,
|
||||
rounds_used: u32,
|
||||
rounds_limit: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl StaticDelegateHopNote<'_> {
|
||||
@@ -225,6 +247,32 @@ impl StaticDelegateHopNote<'_> {
|
||||
StaticDelegateHopNote::Repair {
|
||||
original_delegation_id,
|
||||
} => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"),
|
||||
StaticDelegateHopNote::UserRevision {
|
||||
original_delegation_id,
|
||||
} => format!(
|
||||
"\n\n这是对已认领委派 {original_delegation_id} 的用户修订轮,由用户在审批卡上提出,不是质量返工,不消耗返工深度,也不重置澄清轮次。按任务正文里的用户意见原文修订同一份 GDD 谱系后重新提交;用户看过新稿还可以再次提出修改,这不是最后一轮,不要因此压缩改动或提前收尾。"
|
||||
),
|
||||
// 预算用尽:planning_coordinator 出卡时会用
|
||||
// `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问,
|
||||
// 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。
|
||||
StaticDelegateHopNote::PlanClarification {
|
||||
original_delegation_id,
|
||||
rounds_used,
|
||||
rounds_limit,
|
||||
} if rounds_used >= rounds_limit => format!(
|
||||
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮。已用澄清轮次 {rounds_used}/{rounds_limit},澄清预算已用尽:本轮不得再输出 AGC_NEEDS_USER_INPUT_V1 信封,剩余空白按默认建议补齐并标 default_pending,立即提交 GDD。"
|
||||
),
|
||||
// 轮号必须和 planning_coordinator 出卡时的期望一致:那边用
|
||||
// `validate_exact_plan_clarification_question(.., current_round + 1)`,
|
||||
// current_round 就是本 delivery 的谱系轮次,也就是这里的 rounds_used。
|
||||
StaticDelegateHopNote::PlanClarification {
|
||||
original_delegation_id,
|
||||
rounds_used,
|
||||
rounds_limit,
|
||||
} => format!(
|
||||
"\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 写成「第{next_round}轮·当前要决定:<主题>」,轮号必须是 {next_round},主题写这一轮真正要定的那件事。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。",
|
||||
next_round = rounds_used.saturating_add(1),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1475,3 +1523,122 @@ pub(crate) fn observe_agent_runtime_agent_spawn_isolated(
|
||||
.map(|value| redact_agent_runtime_project_paths(root, &value, 3_600)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 委派 task 末尾那句话是两条链路的合同,不能共用一份文案。
|
||||
///
|
||||
/// 上半条钉做游戏链路:`design-foundation` / `art-director` / `art-asset-plan`
|
||||
/// 的角色提示词把「任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮」
|
||||
/// 当作 `replaceExisting=true` 的唯一授权信号,改一个字就会让返工轮拿不到许可。
|
||||
///
|
||||
/// 下半条钉立项策划链路:澄清续跑不是返工轮,套用返工文案等于告诉策划子 Agent
|
||||
/// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。
|
||||
/// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header,
|
||||
/// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。
|
||||
/// 用户修订轮同样不能套返工文案。
|
||||
///
|
||||
/// 「唯一返工轮」防的是 runaway agent,而这一跳由用户在审批卡上亲手点出来——人本身
|
||||
/// 就是循环边界,`static_delegate_lineage_counters` 早就把 depth/round 原样继承了。
|
||||
/// 套用返工文案就是告诉策划子 Agent「用户只能改这一次」,和澄清跳当初那个假天花板
|
||||
/// 是同一个错误。原型对应的是软阈值 16 次、超过只提示不拒绝。
|
||||
#[test]
|
||||
fn user_revision_hop_note_is_not_the_repair_round_note() {
|
||||
let revision = render_static_delegate_task_contract(
|
||||
"任务",
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
"delegation-new",
|
||||
&["交付 game/fast_gdd.md".to_string()],
|
||||
&["game/fast_gdd.md".to_string()],
|
||||
StaticDelegateHopNote::UserRevision {
|
||||
original_delegation_id: "delegation-old",
|
||||
},
|
||||
)
|
||||
.expect("render user revision hop note");
|
||||
assert!(
|
||||
!revision.contains("唯一返工轮"),
|
||||
"用户修订轮不得复用返工文案,否则子 Agent 以为用户只能改这一次:{revision}"
|
||||
);
|
||||
assert!(
|
||||
revision.contains("不消耗返工深度"),
|
||||
"必须写明它不吃返工额度:{revision}"
|
||||
);
|
||||
assert!(
|
||||
revision.contains("不是最后一轮"),
|
||||
"必须写明用户还能再改,否则子 Agent 会把多条意见攒到一轮改完:{revision}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_clarification_hop_note_is_not_the_repair_round_note() {
|
||||
let repair = render_static_delegate_task_contract(
|
||||
"任务",
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
"delegation-new",
|
||||
&["交付产物".to_string()],
|
||||
&["assets/art-spec.png".to_string()],
|
||||
StaticDelegateHopNote::Repair {
|
||||
original_delegation_id: "delegation-old",
|
||||
},
|
||||
)
|
||||
.expect("render repair");
|
||||
assert!(
|
||||
repair.contains("这是对已认领委派 delegation-old 的唯一返工轮。"),
|
||||
"返工轮文案是做游戏链路 replaceExisting 的授权信号,必须逐字保留:{repair}"
|
||||
);
|
||||
|
||||
let clarification = render_static_delegate_task_contract(
|
||||
"任务",
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
"delegation-new",
|
||||
&["交付 game/fast_gdd.md".to_string()],
|
||||
&["game/fast_gdd.md".to_string()],
|
||||
StaticDelegateHopNote::PlanClarification {
|
||||
original_delegation_id: "delegation-old",
|
||||
rounds_used: 1,
|
||||
rounds_limit: 3,
|
||||
},
|
||||
)
|
||||
.expect("render clarification");
|
||||
assert!(
|
||||
!clarification.contains("唯一返工轮"),
|
||||
"澄清续跑不得复用返工文案,否则策划子 Agent 以为只剩这一轮:{clarification}"
|
||||
);
|
||||
assert!(
|
||||
clarification.contains("已用澄清轮次 1/3"),
|
||||
"澄清续跑必须写明已用轮次与上限:{clarification}"
|
||||
);
|
||||
assert!(
|
||||
clarification.contains("第2轮·当前要决定:"),
|
||||
"task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1:{clarification}"
|
||||
);
|
||||
|
||||
let exhausted = render_static_delegate_task_contract(
|
||||
"任务",
|
||||
"project-supervisor",
|
||||
"run-1",
|
||||
"delegation-new",
|
||||
&["交付 game/fast_gdd.md".to_string()],
|
||||
&["game/fast_gdd.md".to_string()],
|
||||
StaticDelegateHopNote::PlanClarification {
|
||||
original_delegation_id: "delegation-old",
|
||||
rounds_used: 3,
|
||||
rounds_limit: 3,
|
||||
},
|
||||
)
|
||||
.expect("render exhausted clarification");
|
||||
assert!(
|
||||
exhausted.contains("澄清预算已用尽"),
|
||||
"预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}"
|
||||
);
|
||||
assert!(
|
||||
!exhausted.contains("第4轮·当前要决定:"),
|
||||
"预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,18 @@ pub(in crate::agent) fn agent_role_project_path_mutation_block(
|
||||
if autonomous_relaxed_run_at(root, agent_id, run_id).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
if is_agent_planning_storage_path(path) || is_plan_fast_gdd_projection_path(path) {
|
||||
return Some(AgentRuntimeToolObservation {
|
||||
tool: tool.to_string(),
|
||||
status: "blocked".to_string(),
|
||||
summary: if is_agent_planning_storage_path(path) {
|
||||
"`.agent/planning/**` 只能由立项策划 Runtime 专用存储层写入".to_string()
|
||||
} else {
|
||||
"`game/fast_gdd.md` 只能由立项策划 Runtime renderer 写入".to_string()
|
||||
},
|
||||
detail: Some(format!("agentId={agent_id} · runId={run_id} · path={path}")),
|
||||
});
|
||||
}
|
||||
match autonomous_owner_artifact_validation_available_for_run_at(root, agent_id, run_id) {
|
||||
Ok(true) => {
|
||||
let allowed = autonomous_manifest_owner_artifact_paths(agent_id);
|
||||
|
||||
@@ -577,6 +577,8 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
||||
slice_mode: (!slice_mode.trim().is_empty()).then_some(slice_mode.clone()),
|
||||
grid_x,
|
||||
grid_y,
|
||||
reference_asset_ids: Vec::new(),
|
||||
target_category: None,
|
||||
};
|
||||
if let Some(pending) = pending_action {
|
||||
match recover_persisted_visual_generation_options(
|
||||
@@ -628,6 +630,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
|
||||
.or_else(|| (!slice_mode.trim().is_empty()).then_some(slice_mode)),
|
||||
grid_x,
|
||||
grid_y,
|
||||
reference_asset_ids: requested_options.reference_asset_ids,
|
||||
// Agent 运行时不会指定完成登记的目标栏目,保持调用方给的值(默认 `None`)。
|
||||
target_category: requested_options.target_category,
|
||||
}
|
||||
};
|
||||
options.replace_existing = replace_existing;
|
||||
|
||||
@@ -275,7 +275,8 @@ fn agent_runtime_native_capability_registry() -> Result<&'static CapabilityRegis
|
||||
///
|
||||
/// `"__all_agents__"` 是个不对应任何真实 Agent 的哨兵:走这条路径拿到的是
|
||||
/// 未按身份收窄的完整函数目录。生产代码必须调用 `_for_agent` 版本并传入真实
|
||||
/// `agentId`,否则按身份收窄的工具面会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」
|
||||
/// `agentId`,否则按身份收窄的工具面(如 `project-planning` 的 exact
|
||||
/// allowlist)会被静默绕开。这里用 `#[cfg(test)]` 把「忘记改用 `_for_agent`」
|
||||
/// 从运行时静默扩权变成编译期错误。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_agent_runtime_native_function_tools() -> Result<Vec<LlmFunctionTool>, String> {
|
||||
|
||||
@@ -390,6 +390,12 @@ pub(crate) async fn start_local_project_asset_generation(
|
||||
image_size: Option<String>,
|
||||
asset_name: Option<String>,
|
||||
output_path: Option<String>,
|
||||
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
|
||||
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
|
||||
reference_asset_ids: Option<Vec<String>>,
|
||||
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
|
||||
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
|
||||
target_category: Option<String>,
|
||||
) -> Result<AssetGenerationTaskRecord, String> {
|
||||
let task_id = asset_generation_task_id(&task_id)?;
|
||||
let request = prepare_local_project_asset_generation(
|
||||
@@ -400,6 +406,8 @@ pub(crate) async fn start_local_project_asset_generation(
|
||||
image_size.as_deref(),
|
||||
asset_name.as_deref(),
|
||||
output_path.as_deref(),
|
||||
reference_asset_ids.as_deref().unwrap_or_default(),
|
||||
target_category.as_deref(),
|
||||
)?;
|
||||
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&request.root, "asset.register")?;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
|
||||
use std::future::Future;
|
||||
|
||||
const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_PREFIX: &str = "external-editor-api-";
|
||||
@@ -662,6 +663,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String>
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
None,
|
||||
)?;
|
||||
changed |= asset_changed;
|
||||
}
|
||||
@@ -1876,8 +1878,56 @@ pub(crate) fn register_local_asset_entry(
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
|
||||
.map(|(result, _)| result)
|
||||
register_local_asset_entry_with_change(
|
||||
root, local_path, kind, media_type, id_prefix, source, None,
|
||||
)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
/// 带**显式目标分类**的登记入口:只给 GUI 生成完成路径用(前端 `targetCategory`)。
|
||||
///
|
||||
/// 入口栏目与生成 kind 不是同一套词汇(栏目 `character` / `scene` / `ui-interaction`,
|
||||
/// 生成 kind 的派生分类会把图片落到 `unclassified`、规范图落到 `document`),所以要落回
|
||||
/// 入口栏目只能由调用方把目标分类显式交进来。取值必须先过
|
||||
/// [`shared_contracts::game_creation_app::game_creation_app_asset_category_from_str`],
|
||||
/// 非法值失败关闭,绝不回退到 kind 派生;其它调用方继续走
|
||||
/// [`register_local_asset_entry`],行为不变。
|
||||
pub(crate) fn register_local_asset_entry_with_category(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
target_category: Option<&str>,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
let target_category = normalize_asset_category_override(target_category)?;
|
||||
register_local_asset_entry_with_change(
|
||||
root,
|
||||
local_path,
|
||||
kind,
|
||||
media_type,
|
||||
id_prefix,
|
||||
source,
|
||||
target_category,
|
||||
)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
/// 归一显式目标分类:只接受合法枚举值,返回落盘字符串。
|
||||
fn normalize_asset_category_override(
|
||||
target_category: Option<&str>,
|
||||
) -> Result<Option<GameCreationAppAssetCategory>, String> {
|
||||
let Some(target_category) = target_category else {
|
||||
return Ok(None);
|
||||
};
|
||||
let target_category = target_category.trim();
|
||||
if target_category.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
game_creation_app_asset_category_from_str(target_category)
|
||||
.map(Some)
|
||||
.ok_or_else(|| format!("非法资源分类:{target_category}"))
|
||||
}
|
||||
|
||||
fn register_local_asset_entry_with_change(
|
||||
@@ -1887,6 +1937,7 @@ fn register_local_asset_entry_with_change(
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
target_category: Option<GameCreationAppAssetCategory>,
|
||||
) -> Result<(UploadLocalAssetResult, bool), String> {
|
||||
let normalized_path = normalize_relative_path(local_path)?;
|
||||
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
||||
@@ -1912,11 +1963,17 @@ fn register_local_asset_entry_with_change(
|
||||
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
|
||||
let changed = existing.kind != kind
|
||||
|| existing.media_type != media_type
|
||||
|| existing.source != source;
|
||||
|| existing.source != source
|
||||
|| target_category.is_some_and(|category| existing.category != category);
|
||||
if existing.kind != kind {
|
||||
existing.kind = kind.to_string();
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind);
|
||||
}
|
||||
// 调用方显式给出目标分类时它就是权威值:GUI 完成登记必须能落回入口栏目,
|
||||
// 这也是同路径重新生成时把资产从旧栏目(或 unclassified)原位接管过来的唯一入口。
|
||||
if let Some(category) = target_category {
|
||||
existing.category = category;
|
||||
}
|
||||
existing.media_type = media_type.to_string();
|
||||
existing.source = source;
|
||||
Ok((existing.id.clone(), "asset.update", changed))
|
||||
@@ -1933,7 +1990,8 @@ fn register_local_asset_entry_with_change(
|
||||
local_path: normalized_path.clone(),
|
||||
image_sequence_frames: None,
|
||||
image_sequence_duration_ms: None,
|
||||
category: game_creation_app_asset_category_for_kind(kind),
|
||||
category: target_category
|
||||
.unwrap_or_else(|| game_creation_app_asset_category_for_kind(kind)),
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
@@ -2153,6 +2211,7 @@ pub(crate) fn delete_manifest_asset_at(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared_contracts::game_creation_app::GameCreationAppAssetCategory;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
@@ -2176,6 +2235,121 @@ mod tests {
|
||||
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
|
||||
}
|
||||
|
||||
/// GUI 完成登记可以显式指定目标栏目:新建条目与已登记条目都按显式值落盘。
|
||||
///
|
||||
/// 入口栏目(character / scene / ui-interaction)与生成 kind 不是同一套词汇,按 kind 派生
|
||||
/// 会把图片落到 unclassified,占位拿不回原位;非法值必须失败关闭,不传时保持 kind 派生。
|
||||
#[test]
|
||||
fn explicit_target_category_overrides_the_kind_derived_category() {
|
||||
fn canvas_source() -> GameCreationAppAssetSource {
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: None,
|
||||
resource_id: None,
|
||||
asset_object_id: None,
|
||||
task_id: None,
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: None,
|
||||
reference_resource_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
fn category_of(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory {
|
||||
read_existing_manifest_for_project(root)
|
||||
.expect("read manifest")
|
||||
.assets
|
||||
.into_iter()
|
||||
.find(|asset| asset.id == asset_id)
|
||||
.expect("registered asset is present")
|
||||
.category
|
||||
}
|
||||
|
||||
let temporary = tempfile::tempdir().expect("tempdir");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "target-category-test", "目标栏目登记")
|
||||
.expect("init project");
|
||||
fs::create_dir_all(root.join("assets")).expect("create assets dir");
|
||||
fs::write(root.join("assets/hero.png"), b"png-bytes").expect("write asset");
|
||||
|
||||
let registered = register_local_asset_entry_with_category(
|
||||
root,
|
||||
"assets/hero.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
Some("character"),
|
||||
)
|
||||
.expect("register with a target category");
|
||||
assert_eq!(
|
||||
category_of(root, ®istered.id),
|
||||
GameCreationAppAssetCategory::Character
|
||||
);
|
||||
|
||||
// 同 kind 重新生成时显式目标分类仍是权威值:资产要能换栏目原位接管。
|
||||
register_local_asset_entry_with_category(
|
||||
root,
|
||||
"assets/hero.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
Some("ui-interaction"),
|
||||
)
|
||||
.expect("re-register with another target category");
|
||||
assert_eq!(
|
||||
category_of(root, ®istered.id),
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
);
|
||||
|
||||
// 非法值失败关闭,且不动已落盘的分类。
|
||||
assert!(register_local_asset_entry_with_category(
|
||||
root,
|
||||
"assets/hero.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
Some("version"),
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
category_of(root, ®istered.id),
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
);
|
||||
|
||||
// 不传目标分类时保持原有行为:新条目按 kind 派生(image → unclassified)。
|
||||
fs::write(root.join("assets/plain.png"), b"png-bytes").expect("write plain asset");
|
||||
let plain = register_local_asset_entry(
|
||||
root,
|
||||
"assets/plain.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
)
|
||||
.expect("register without a target category");
|
||||
assert_eq!(
|
||||
category_of(root, &plain.id),
|
||||
GameCreationAppAssetCategory::Unclassified
|
||||
);
|
||||
// 已落盘的显式分类在 kind 未变时仍然是权威值:同 kind 重登记不得把它抹掉。
|
||||
register_local_asset_entry(
|
||||
root,
|
||||
"assets/hero.png",
|
||||
"image",
|
||||
"image/png",
|
||||
"platform-art",
|
||||
canvas_source(),
|
||||
)
|
||||
.expect("re-register without a target category");
|
||||
assert_eq!(
|
||||
category_of(root, ®istered.id),
|
||||
GameCreationAppAssetCategory::UiInteraction
|
||||
);
|
||||
}
|
||||
|
||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||
///
|
||||
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
||||
|
||||
@@ -879,6 +879,104 @@ pub(crate) fn validated_local_project_directory_path(
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
/// Open the approved Fast GDD Markdown in whatever application the OS has
|
||||
/// registered for it.
|
||||
///
|
||||
/// The GDD is the one product artifact the 立项策划 lane hands back, and it is
|
||||
/// already on disk — `plan.submit_gdd` renders `game/fast_gdd.md` and the
|
||||
/// approval receipt re-renders it with the approved header. This command only
|
||||
/// hands that existing path to the shell; it never creates or rewrites it.
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_local_project_plan_gdd_markdown(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
) -> Result<(), String> {
|
||||
let path = validated_local_project_plan_gdd_markdown_path(project_path.trim())?;
|
||||
app.opener()
|
||||
.open_path(path.to_string_lossy().into_owned(), None::<&str>)
|
||||
.map_err(|error| format!("打开 Fast GDD 文件失败:{error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn validated_local_project_plan_gdd_markdown_path(
|
||||
project_path: &str,
|
||||
) -> Result<PathBuf, String> {
|
||||
let root = validated_local_project_directory_path(project_path)?;
|
||||
// `resolve_local_project_path` 是项目内路径的唯一安全入口:它做根校验、相对路径
|
||||
// 归一化,并逐段拒绝符号链接。这里的相对路径是常量,但仍然走它——GDD 的渲染侧
|
||||
// (`planning_storage`)用的也是同一个解析器,两边对「项目内的这个文件」必须是
|
||||
// 同一个判定,不能一边解析一边拼字符串。
|
||||
let path = resolve_local_project_path(&root, PLAN_FAST_GDD_PATH)?;
|
||||
match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) if metadata.file_type().is_file() => Ok(path),
|
||||
Ok(_) => Err("Fast GDD 产物不是普通文件".to_string()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
Err("Fast GDD 产物尚未生成,请先完成立项策划审批".to_string())
|
||||
}
|
||||
Err(error) => Err(format!("读取 Fast GDD 产物失败:{error}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plan_gdd_markdown_path_tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> tempfile::TempDir {
|
||||
let temporary = tempfile::tempdir().expect("create GDD path fixture");
|
||||
crate::project::init_local_game_project_at(
|
||||
&temporary.path().join("project"),
|
||||
"gdd-open",
|
||||
"打开 GDD 产物",
|
||||
)
|
||||
.expect("initialize GDD path fixture");
|
||||
temporary
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_the_rendered_markdown_under_the_project_root() {
|
||||
let temporary = fixture();
|
||||
let root = temporary.path().join("project");
|
||||
fs::create_dir_all(root.join("game")).expect("create game directory");
|
||||
fs::write(root.join(PLAN_FAST_GDD_PATH), "# Fast GDD").expect("render markdown");
|
||||
|
||||
let resolved =
|
||||
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
|
||||
.expect("resolve rendered markdown");
|
||||
|
||||
assert_eq!(resolved, root.join(PLAN_FAST_GDD_PATH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_to_open_a_markdown_that_has_not_been_rendered_yet() {
|
||||
// 恢复态下 `plan.submit_gdd` 的 Markdown 渲染可能还没落盘。这时按钮必须给出
|
||||
// 明确原因,而不是把一个不存在的路径丢给 shell 由系统弹一个无从解释的错误。
|
||||
let temporary = fixture();
|
||||
let root = temporary.path().join("project");
|
||||
|
||||
let error =
|
||||
validated_local_project_plan_gdd_markdown_path(&root.to_string_lossy().into_owned())
|
||||
.expect_err("missing markdown must fail closed");
|
||||
|
||||
assert!(error.contains("尚未生成"), "unexpected error: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_project_path_that_is_not_an_initialized_project() {
|
||||
let temporary = tempfile::tempdir().expect("create bare fixture");
|
||||
let error = validated_local_project_plan_gdd_markdown_path(
|
||||
&temporary.path().to_string_lossy().into_owned(),
|
||||
)
|
||||
.expect_err("a directory without .agent is not a project root");
|
||||
assert!(!error.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_relative_project_path() {
|
||||
let error = validated_local_project_plan_gdd_markdown_path("relative/project")
|
||||
.expect_err("relative project path must fail");
|
||||
assert!(error.contains("绝对路径"), "unexpected error: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_local_game_manifest(
|
||||
project_path: String,
|
||||
@@ -2222,6 +2320,27 @@ pub(crate) fn update_local_project_resource_classification(
|
||||
)
|
||||
}
|
||||
|
||||
/// 为一批已登记素材追加标签:整批一次校验、一次 manifest 写入、一次 revision 推进。
|
||||
///
|
||||
/// 权限位与单素材分类更新同口径取 `asset.register`(命令包装层只做权限门面,
|
||||
/// 身份 / 写锁 / CAS / 原子写与审计都在 `project/manifest.rs` 内完成)。
|
||||
/// 这里刻意**不**循环调用单素材命令:逐项调用会写出多份 manifest、推进多次 revision,
|
||||
/// 中途失败还会留下"前几个素材改了、后面的没改"的部分写入。
|
||||
#[tauri::command]
|
||||
pub(crate) fn add_local_project_resource_tags(
|
||||
input: AddLocalProjectResourceTagsInput,
|
||||
) -> Result<AddLocalProjectResourceTagsResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
add_manifest_asset_tags_at(
|
||||
root,
|
||||
&input.expected_project_id,
|
||||
input.expected_project_revision,
|
||||
input.asset_ids,
|
||||
input.tags,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn derive_local_project_resource(
|
||||
input: DeriveLocalProjectResourceInput,
|
||||
@@ -4773,6 +4892,8 @@ pub(crate) fn prepare_local_project_asset_generation(
|
||||
image_size: Option<&str>,
|
||||
asset_name: Option<&str>,
|
||||
output_path: Option<&str>,
|
||||
reference_asset_ids: &[String],
|
||||
target_category: Option<&str>,
|
||||
) -> Result<LocalProjectAssetGenerationRequest, String> {
|
||||
let project_path = project_path.trim();
|
||||
if project_path.is_empty() {
|
||||
@@ -4780,6 +4901,13 @@ pub(crate) fn prepare_local_project_asset_generation(
|
||||
}
|
||||
let asset_kind = normalize_platform_art_asset_generation_kind(kind)
|
||||
.ok_or_else(|| format!("素材类型不受支持:{}", kind.trim()))?;
|
||||
// 参考入参只接受当前项目 manifest 素材 id:路径、远端 resourceId 与超限在这里就被拒绝,
|
||||
// 不把校验推迟到远端(远端只该收到当前账号绑定下的 resource ID)。
|
||||
let reference_asset_ids =
|
||||
normalize_platform_art_reference_asset_ids(asset_kind, reference_asset_ids)?;
|
||||
// GUI 完成登记层参数:入口栏目与生成 kind 不是同一套词汇,只有调用方显式给出目标分类
|
||||
// 才能把产物原位落回入口栏目。非法值(含 `version` / `all` 这类栏目伪值)直接失败关闭。
|
||||
let target_category = normalize_platform_art_target_category(target_category)?;
|
||||
Ok(LocalProjectAssetGenerationRequest {
|
||||
root: PathBuf::from(project_path),
|
||||
prompt: local_project_asset_prompt(prompt)?,
|
||||
@@ -4816,6 +4944,8 @@ pub(crate) fn prepare_local_project_asset_generation(
|
||||
.then(|| "connected-components".to_string()),
|
||||
grid_x: None,
|
||||
grid_y: None,
|
||||
reference_asset_ids,
|
||||
target_category,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -4837,6 +4967,10 @@ pub(crate) async fn generate_local_project_asset(
|
||||
image_size: Option<String>,
|
||||
asset_name: Option<String>,
|
||||
output_path: Option<String>,
|
||||
reference_asset_ids: Option<Vec<String>>,
|
||||
// 前端 IPC 字段 `targetCategory`:本次生成完成登记时要落盘的正式栏目分类,
|
||||
// 只走 GUI 命令,取值必须是合法素材分类,Agent / Direct 路径不传。
|
||||
target_category: Option<String>,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
let request = prepare_local_project_asset_generation(
|
||||
&project_path,
|
||||
@@ -4846,6 +4980,8 @@ pub(crate) async fn generate_local_project_asset(
|
||||
image_size.as_deref(),
|
||||
asset_name.as_deref(),
|
||||
output_path.as_deref(),
|
||||
reference_asset_ids.as_deref().unwrap_or_default(),
|
||||
target_category.as_deref(),
|
||||
)?;
|
||||
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&request.root, "asset.register")?;
|
||||
@@ -4864,7 +5000,17 @@ mod local_project_asset_generation_tests {
|
||||
use super::*;
|
||||
|
||||
fn prepare(kind: &str, prompt: &str) -> Result<LocalProjectAssetGenerationRequest, String> {
|
||||
prepare_local_project_asset_generation("/tmp/project", kind, prompt, None, None, None, None)
|
||||
prepare_local_project_asset_generation(
|
||||
"/tmp/project",
|
||||
kind,
|
||||
prompt,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4904,6 +5050,8 @@ mod local_project_asset_generation_tests {
|
||||
Some("2K"),
|
||||
Some(" 主角图集 "),
|
||||
Some(" assets/hero.png "),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect("explicit options");
|
||||
assert_eq!(explicit.root, PathBuf::from("/tmp/project"));
|
||||
@@ -4931,8 +5079,18 @@ mod local_project_asset_generation_tests {
|
||||
#[test]
|
||||
fn invalid_toolbar_arguments_are_rejected_before_any_generation() {
|
||||
assert_eq!(
|
||||
prepare_local_project_asset_generation("", "image", "要求", None, None, None, None)
|
||||
.expect_err("empty project path"),
|
||||
prepare_local_project_asset_generation(
|
||||
"",
|
||||
"image",
|
||||
"要求",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("empty project path"),
|
||||
"项目路径不能为空"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -4943,6 +5101,60 @@ mod local_project_asset_generation_tests {
|
||||
prepare("game-art", "要求").expect_err("unverified kind"),
|
||||
"素材类型不受支持:game-art"
|
||||
);
|
||||
// 目标分类只接受合法素材分类枚举:栏目侧伪值 `version` / `all` 与任意其它值都失败关闭。
|
||||
for rejected in ["version", "all", "bogus", "UI"] {
|
||||
assert_eq!(
|
||||
prepare_local_project_asset_generation(
|
||||
"/tmp/project",
|
||||
"image",
|
||||
"要求",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
Some(rejected),
|
||||
)
|
||||
.expect_err("illegal target category"),
|
||||
format!("目标分类不是合法素材分类:{rejected}")
|
||||
);
|
||||
}
|
||||
// 合法值归一成落盘字符串(trim + kebab-case),供 manifest `category` 直接使用。
|
||||
assert_eq!(
|
||||
prepare_local_project_asset_generation(
|
||||
"/tmp/project",
|
||||
"image",
|
||||
"要求",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
Some(" ui-interaction "),
|
||||
)
|
||||
.expect("legal target category")
|
||||
.options
|
||||
.target_category
|
||||
.as_deref(),
|
||||
Some("ui-interaction")
|
||||
);
|
||||
assert_eq!(
|
||||
prepare_local_project_asset_generation(
|
||||
"/tmp/project",
|
||||
"image",
|
||||
"要求",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect("omitted target category")
|
||||
.options
|
||||
.target_category,
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
prepare(
|
||||
"spec",
|
||||
@@ -4959,7 +5171,9 @@ mod local_project_asset_generation_tests {
|
||||
Some("4:3"),
|
||||
None,
|
||||
None,
|
||||
None
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("unsupported ratio"),
|
||||
"图片比例不受支持:4:3"
|
||||
@@ -4972,7 +5186,9 @@ mod local_project_asset_generation_tests {
|
||||
None,
|
||||
Some("4K"),
|
||||
None,
|
||||
None
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("unsupported size"),
|
||||
"图片尺寸不受支持:4K"
|
||||
@@ -4985,7 +5201,9 @@ mod local_project_asset_generation_tests {
|
||||
None,
|
||||
None,
|
||||
Some("坏\u{7}名字"),
|
||||
None
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("control character in asset name"),
|
||||
"素材名称超出安全边界"
|
||||
@@ -4998,7 +5216,9 @@ mod local_project_asset_generation_tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1))
|
||||
Some(&"a".repeat(LOCAL_PROJECT_ASSET_MAX_OUTPUT_PATH_CHARS + 1)),
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.expect_err("oversized output path"),
|
||||
"输出路径超出安全边界"
|
||||
|
||||
@@ -1756,8 +1756,8 @@ pub(crate) fn build_static_delegate_structured_result_at(
|
||||
// (实测:option 对象里多写了一个 `id` 字段)就把整条委派判成投递失败,父
|
||||
// Supervisor 直接进 needs-reconciliation 停下等人。
|
||||
//
|
||||
// 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」。
|
||||
// 降级成 needs-repair 并把解析错误当返工理由
|
||||
// 信封格式属于「本次 Provider 输出写错」,不是「durable 权威损坏」,和
|
||||
// plan.submit_gdd 的业务拒绝同类。降级成 needs-repair 并把解析错误当返工理由
|
||||
// 带上:Supervisor 用既有的一次返工额度就能让子 Agent 重写,不需要人工介入。
|
||||
let (user_input_questions, user_input_questions_sha256, user_input_parse_error) =
|
||||
match parse_static_delegate_user_input_request(error) {
|
||||
@@ -3426,11 +3426,11 @@ mod tests {
|
||||
assert!(failed.contains("客观证据冲突"));
|
||||
|
||||
let mut missing = base.clone();
|
||||
missing.missing_expected_artifacts = vec!["design.md".to_string()];
|
||||
missing.missing_expected_artifacts = vec!["game/fast_gdd.md".to_string()];
|
||||
let missing_error = validate_static_delegate_structured_result(
|
||||
&missing,
|
||||
"completed",
|
||||
&["design.md".to_string()],
|
||||
&["game/fast_gdd.md".to_string()],
|
||||
)
|
||||
.expect_err("missing expected artifact must reject a user revision result");
|
||||
assert!(missing_error.contains("客观证据冲突"));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user