给 UI 设计文档三个工具补上可读的回执明细与入参摘要
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled

- 三个工具的持久回执补白名单分支:只放身份、相对路径、计数与回填说明,不再整块变成明细不可用
- 身份字段禁控制字符并限长,相对路径必须归一化后落在 ui/ 下,含宿主路径或 file URI 即失败关闭
- 回填说明按既有口径把绝对路径脱敏成占位符,超长或带控制字符的整条明细失败关闭
- 明细总长仍守 500 字符上限:清单能放多少放多少,放不下的部分用总数表达
- 入参摘要给出设计图逐张身份或目标文档 id,并把三个工具加进可读名单,不再只报哈希
- 补四条用例覆盖可读明细、脱敏、越界路径失败关闭与入参摘要
This commit is contained in:
2026-09-24 19:08:18 +08:00
parent 74609e2a5e
commit 065b4ea330
3 changed files with 415 additions and 0 deletions
@@ -677,6 +677,15 @@ fn agent_runtime_action_receipt_safe_detail_with_owner(
"revisionAdvanceCount": value.get("revisionAdvanceCount").and_then(serde_json::Value::as_u64).unwrap_or(0),
})).ok();
}
if matches!(
observation.tool.as_str(),
"ui-design-doc.from-images" | "ui-design-doc.run-workflow"
) {
return agent_runtime_ui_design_doc_safe_detail(root, observation.detail.as_deref());
}
if observation.tool == "ui-design-doc.into-js" {
return agent_runtime_ui_design_code_safe_detail(root, observation.detail.as_deref());
}
if observation.tool != "project.patchset" {
return None;
}
@@ -1020,6 +1029,184 @@ pub(in crate::agent) fn agent_runtime_action_receipt_safe_text(
Some(sanitized)
}
/// UI 设计文档回执里身份类字段的长度上限:素材 id / 文档 id / 生成文件名都远小于这个值。
const AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS: usize = 96;
/// 文档相对路径的长度上限;同时要求必须落在 `ui/` 下,避免把项目别处的路径带进回执。
const AGENT_RUNTIME_UI_DESIGN_SAFE_RELATIVE_PATH_MAX_CHARS: usize = 160;
/// 单条回填说明的长度上限与总量上限;超出总量的部分不逐条外传,改用总数表达。
const AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS: usize = 96;
const AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_CHAR_BUDGET: usize = 240;
/// 一份文档最多四张设计图,逐张身份也按同一上限放行。
const AGENT_RUNTIME_UI_DESIGN_SAFE_IMAGE_ID_LIMIT: usize = 4;
/// UI 设计文档链路(新建文档 / 跑工作流)的回执明细:只放身份、相对路径、计数与回填说明。
/// 计数与身份必须完全合格;体积较大的清单(设计图身份、回填说明)在回执长度上限内能放多少放多少,
/// 放不下的部分用总数补齐,绝不把原始明细整体外传。
fn agent_runtime_ui_design_doc_safe_detail(root: &Path, detail: Option<&str>) -> Option<String> {
let detail = serde_json::from_str::<serde_json::Value>(detail.unwrap_or_default()).ok()?;
let mut safe = serde_json::Map::new();
safe.insert(
"assetId".to_string(),
agent_runtime_action_receipt_identity_text(
root,
detail.get("assetId")?.as_str()?,
AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS,
"assetId",
)
.ok()?
.into(),
);
safe.insert(
"relativePath".to_string(),
agent_runtime_ui_design_safe_relative_path(root, detail.get("relativePath")?.as_str()?)?
.into(),
);
for key in [
"revision",
"revisionAdvanceCount",
"recognizedTreeCount",
"boundNodeCount",
"problematicNodeCount",
] {
if let Some(value) = detail.get(key) {
safe.insert(key.to_string(), value.as_u64()?.into());
}
}
if let Some(value) = detail.get("recoveredFromCheckpoint") {
safe.insert(
"recoveredFromCheckpoint".to_string(),
value.as_bool()?.into(),
);
}
if !agent_runtime_action_receipt_detail_fits(&safe) {
return None;
}
if let Some(image_ids) = detail.get("imageIds") {
let image_ids = image_ids.as_array()?;
if image_ids.is_empty() || image_ids.len() > AGENT_RUNTIME_UI_DESIGN_SAFE_IMAGE_ID_LIMIT {
return None;
}
let image_ids = image_ids
.iter()
.map(|value| {
agent_runtime_action_receipt_identity_text(
root,
value.as_str()?,
AGENT_RUNTIME_UI_DESIGN_SAFE_ID_MAX_CHARS,
"imageIds",
)
.ok()
})
.collect::<Option<Vec<_>>>()?;
agent_runtime_action_receipt_insert_while_fits(
&mut safe,
"imageIds",
serde_json::json!(image_ids),
);
}
if let Some(errors) = detail.get("backfillErrors") {
let errors = errors.as_array()?;
let mut safe_errors = Vec::new();
for value in errors {
// 回填说明是自由文本:绝对路径按既有口径脱敏成占位符,控制字符或超长则整条明细失败关闭。
let raw = value.as_str()?.trim();
let error = agent_runtime_action_receipt_safe_text(
root,
raw,
AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS,
None,
)?;
if sanitize_agent_runtime_text(&error, AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_MAX_CHARS)
!= error
{
return None;
}
let mut candidate = safe_errors.clone();
candidate.push(serde_json::Value::String(error));
let mut probe = safe.clone();
probe.insert("backfillErrors".to_string(), serde_json::json!(candidate));
probe.insert(
"backfillErrorCount".to_string(),
serde_json::json!(errors.len()),
);
if !agent_runtime_action_receipt_detail_fits(&probe) {
break;
}
safe_errors = candidate;
}
if !safe_errors.is_empty()
&& serde_json::to_string(&serde_json::json!(safe_errors))
.map(|text| text.chars().count())
.unwrap_or(0)
<= AGENT_RUNTIME_UI_DESIGN_SAFE_ERROR_CHAR_BUDGET
{
safe.insert("backfillErrors".to_string(), serde_json::json!(safe_errors));
}
safe.insert(
"backfillErrorCount".to_string(),
serde_json::json!(errors.len()),
);
}
serde_json::to_string(&serde_json::Value::Object(safe)).ok()
}
/// 代码生成回执:相对路径与计数;导出名清单只用来核对数量,不逐项外传。
fn agent_runtime_ui_design_code_safe_detail(root: &Path, detail: Option<&str>) -> Option<String> {
let detail = serde_json::from_str::<serde_json::Value>(detail.unwrap_or_default()).ok()?;
let relative_path =
agent_runtime_ui_design_safe_relative_path(root, detail.get("relativePath")?.as_str()?)?;
let tree_count = detail.get("treeCount")?.as_u64()?;
let node_count = detail.get("nodeCount")?.as_u64()?;
if usize::try_from(tree_count).ok()? != detail.get("treeExports")?.as_array()?.len() {
return None;
}
let safe = serde_json::to_string(&serde_json::json!({
"relativePath": relative_path,
"treeCount": tree_count,
"nodeCount": node_count,
}))
.ok()?;
(sanitize_agent_runtime_text(&safe, AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS) == safe)
.then_some(safe)
}
/// `ui/` 下的项目相对路径:绝对路径、反斜杠、上跳与超长一律拒绝。
fn agent_runtime_ui_design_safe_relative_path(root: &Path, value: &str) -> Option<String> {
let path = normalize_relative_path(value).ok()?;
if !path.starts_with("ui/") {
return None;
}
agent_runtime_action_receipt_identity_text(
root,
&path,
AGENT_RUNTIME_UI_DESIGN_SAFE_RELATIVE_PATH_MAX_CHARS,
"relativePath",
)
.ok()
}
fn agent_runtime_action_receipt_detail_fits(
detail: &serde_json::Map<String, serde_json::Value>,
) -> bool {
serde_json::to_string(&serde_json::Value::Object(detail.clone())).is_ok_and(|text| {
sanitize_agent_runtime_text(&text, AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS)
== text
})
}
fn agent_runtime_action_receipt_insert_while_fits(
detail: &mut serde_json::Map<String, serde_json::Value>,
key: &str,
value: serde_json::Value,
) {
let mut candidate = detail.clone();
candidate.insert(key.to_string(), value);
if agent_runtime_action_receipt_detail_fits(&candidate) {
*detail = candidate;
}
}
pub(in crate::agent) fn agent_runtime_public_action_input_summary(
root: &Path,
tool: &str,
@@ -1060,6 +1247,9 @@ pub(in crate::agent) fn agent_runtime_public_action_input_summary(
| "agent.schedule_ready"
| "agent.action_history"
| "agent.run_status"
| "ui-design-doc.from-images"
| "ui-design-doc.run-workflow"
| "ui-design-doc.into-js"
);
if public_shape_only {
return agent_runtime_action_receipt_safe_text(root, input_summary, 320, None);
@@ -1593,8 +1783,82 @@ pub(crate) fn agent_runtime_tool_action_input_summary(
text(&["agentId", "agent_id", "targetAgentId", "target_agent_id"]),
text(&["delegationId", "delegation_id"])
),
// UI 设计文档三兄弟:入参只有设计图引用或目标文档 id,直接给出可读摘要,
// 不再退化成"只报哈希"——否则模型看不到自己这次到底传了什么。
"ui-design-doc.from-images" => ui_design_doc_images_input_summary(input),
"ui-design-doc.run-workflow" | "ui-design-doc.into-js" => format!(
"designDocAssetId={}",
text(&["designDocAssetId", "design_doc_asset_id", "assetId", "asset_id"])
),
_ => String::new(),
};
let summary = redact_agent_runtime_project_paths(root, &summary, 320);
(!summary.trim().is_empty()).then_some(summary)
}
/// 新建文档工具的设计图入参摘要:逐张给出身份(素材 id 或相对路径),绝对路径按既有口径降级成拒绝标记。
fn ui_design_doc_images_input_summary(input: &serde_json::Value) -> String {
let Some(images) = input.get("images").and_then(serde_json::Value::as_array) else {
return String::new();
};
let described = images
.iter()
.map(|image| {
if let Some(asset_id) = image.get("assetId").and_then(serde_json::Value::as_str) {
return format!("assetId={asset_id}");
}
match image.get("path").and_then(serde_json::Value::as_str) {
Some(path) if Path::new(path).is_absolute() => {
"[absolute path rejected]".to_string()
}
Some(path) => format!("path={path}"),
None => "input".to_string(),
}
})
.collect::<Vec<_>>()
.join(", ");
format!("images={} · {described}", images.len())
}
#[cfg(test)]
mod ui_design_doc_public_input_summary_tests {
use super::*;
#[test]
fn ui_design_doc_input_summaries_are_readable_not_hashed() {
let root = Path::new("/nonexistent-ui-design-doc-input-summary-project");
for (tool, summary) in [
(
"ui-design-doc.from-images",
"images=2 · assetId=image-1, path=assets/two.png",
),
(
"ui-design-doc.run-workflow",
"designDocAssetId=generated-ui-design-1",
),
(
"ui-design-doc.into-js",
"designDocAssetId=generated-ui-design-1",
),
] {
let public = agent_runtime_public_action_input_summary(root, tool, Some(summary))
.expect("input summary");
assert_eq!(public, summary, "{tool} 的入参不能被压成摘要哈希");
}
}
#[test]
fn from_images_input_summary_lists_each_image_reference() {
let input = serde_json::json!({
"images": [
{"assetId": "image-1"},
{"path": "assets/two.png"},
{"path": "/tmp/host-only.png"},
]
});
assert_eq!(
ui_design_doc_images_input_summary(&input),
"images=3 · assetId=image-1, path=assets/two.png, [absolute path rejected]"
);
}
}
@@ -2230,3 +2230,145 @@ fn seed_refresh_preserves_completed_visual_tasks_when_registered_file_is_missing
fs::remove_dir_all(root).ok();
}
#[test]
fn ui_design_doc_receipts_keep_safe_detail_instead_of_dropping_it() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "UI 设计文档回执脱敏测试")
.expect("project init");
let from_images = AgentRuntimeToolObservation {
tool: "ui-design-doc.from-images".to_string(),
status: "ok".to_string(),
summary: "已新建 UI 设计文档".to_string(),
detail: Some(
serde_json::json!({
"assetId": "generated-ui-design-1",
"relativePath": "ui/UI 设计 1.json",
"imageIds": ["image-1", "image-2"],
"revisionAdvanceCount": 2,
})
.to_string(),
),
};
let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &from_images)
.expect("from-images 必须留下可用明细");
assert!(public.chars().count() <= 500, "{public}");
let public = serde_json::from_str::<Value>(&public).expect("parse from-images detail");
assert_eq!(public["assetId"], "generated-ui-design-1");
assert_eq!(public["relativePath"], "ui/UI 设计 1.json");
assert_eq!(public["imageIds"].as_array().map(Vec::len), Some(2));
assert_eq!(public["revisionAdvanceCount"], 2);
let run_workflow = AgentRuntimeToolObservation {
tool: "ui-design-doc.run-workflow".to_string(),
status: "ok".to_string(),
summary: "UI 设计文档工作流完成".to_string(),
detail: Some(
serde_json::json!({
"assetId": "generated-ui-design-1",
"relativePath": "ui/UI 设计 1.json",
"revision": 3,
"recoveredFromCheckpoint": true,
"recognizedTreeCount": 2,
"boundNodeCount": 9,
"problematicNodeCount": 1,
"backfillErrors": [
"未能登记自动切分素材图片:assets/cut.png",
format!("读取资源失败:{}", root.join("assets/cut.png").display()),
],
"revisionAdvanceCount": 3,
})
.to_string(),
),
};
let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &run_workflow)
.expect("run-workflow 必须留下可用明细");
assert!(public.chars().count() <= 500, "{public}");
assert!(
!public.contains(root.to_string_lossy().as_ref()),
"回填说明里的宿主路径必须脱敏:{public}"
);
let public = serde_json::from_str::<Value>(&public).expect("parse run-workflow detail");
assert_eq!(public["recoveredFromCheckpoint"], true);
assert_eq!(public["problematicNodeCount"], 1);
assert_eq!(public["backfillErrorCount"], 2);
assert_eq!(
public["backfillErrors"][0],
"未能登记自动切分素材图片:assets/cut.png"
);
let into_js = AgentRuntimeToolObservation {
tool: "ui-design-doc.into-js".to_string(),
status: "ok".to_string(),
summary: "已生成 UI 设计代码".to_string(),
detail: Some(
serde_json::json!({
"relativePath": "ui/generated-ui-design-1-0123456789abcdef.js",
"treeExports": ["treeA", "treeB"],
"treeCount": 2,
"nodeCount": 12,
})
.to_string(),
),
};
let public = agent_runtime_action_receipt_public_safe_detail_for_test(&root, &into_js)
.expect("into-js 必须留下可用明细");
let public = serde_json::from_str::<Value>(&public).expect("parse into-js detail");
assert_eq!(public["treeCount"], 2);
assert_eq!(public["nodeCount"], 12);
assert!(
public.get("treeExports").is_none(),
"导出名清单只用于核对数量,不逐项外传:{public}"
);
fs::remove_dir_all(root).ok();
}
#[test]
fn ui_design_doc_receipts_reject_out_of_scope_paths_and_malformed_detail() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "UI 设计文档回执边界测试")
.expect("project init");
let outside_ui_directory = AgentRuntimeToolObservation {
tool: "ui-design-doc.from-images".to_string(),
status: "ok".to_string(),
summary: "已新建 UI 设计文档".to_string(),
detail: Some(
serde_json::json!({
"assetId": "generated-ui-design-1",
"relativePath": "game/UI 设计 1.json",
"imageIds": ["image-1"],
"revisionAdvanceCount": 1,
})
.to_string(),
),
};
assert!(
agent_runtime_action_receipt_public_safe_detail_for_test(&root, &outside_ui_directory)
.is_none(),
"不在 ui/ 下的相对路径必须失败关闭"
);
let absolute_document_id = AgentRuntimeToolObservation {
tool: "ui-design-doc.run-workflow".to_string(),
status: "ok".to_string(),
summary: "UI 设计文档工作流完成".to_string(),
detail: Some(
serde_json::json!({
"assetId": root.join("ui/UI 设计 1.json").to_string_lossy(),
"relativePath": "ui/UI 设计 1.json",
"revisionAdvanceCount": 1,
})
.to_string(),
),
};
assert!(
agent_runtime_action_receipt_public_safe_detail_for_test(&root, &absolute_document_id)
.is_none(),
"身份字段不能是宿主路径"
);
fs::remove_dir_all(root).ok();
}
@@ -9275,6 +9275,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响面:`apps/ai-game-creator-shell/src-tauri/src/{main.rs,ui_editor/**}``src/features/ui-editor/**``src/view/ui-editor/**``src/view/project-development/index.tsx``tests/{uiEditorPage,uiEditorState,previewWorkspaceZoom}.test.*``docs/technical/【技术方案】UI编辑器代码地图与模块职责-2026-09-23.md``docs/【技术方案】UI工作流资源桥接与Runtime执行-2026-08-24.md``docs/technical/【设计】UI编辑器工作流完成通知弹窗-2026-09-04.md``docs/technical/【前端架构】UI编辑会话模块边界-2026-08-19.md`
- 验证:`cargo check``cargo test --bin genarrative-ai-game-creator-shell ui_editor`160 passed)通过,ts-rs 重新导出 `types/UIDesignImage.ts` 并删除三个已退役类型;`npx vitest run` 定向 `uiEditorPage` / `uiEditorState` / `uiDesignStateStore` / `previewWorkspaceZoom` / `appSurface` 全绿;AGC `tsc --noEmit`、改动文件 eslint、`cargo fmt --check``check:encoding``git diff --check` 通过。整套 Rust 测试在本容器仍有 60 条环境性失败(`/sbin -> usr/bin``command.exec` 沙箱 merged-usr 预检失败),与本次改动无关。
## 2026-09-24 UI 设计文档三个工具的持久回执明细与入参摘要改为可读白名单
- 背景:动作回执的"安全明细"是一份白名单——只有认识的工具才把明细整理成安全字段。`ui.workflow.run` 退役时删掉了它那段分支,接管的 `ui-design-doc.*` 三个工具没补,于是统一落到兜底:明细整块变成 `detailUnavailable`,模型与审计都看不到;入参那一栏也只剩哈希。原始明细既不落盘也不给模型,所以**没有宿主路径泄露**,丢的是可用性(例如切分没登记上的素材清单)。
- 决策:按既有白名单口径给三个工具各补一条分支。`from-images``assetId` / `relativePath` / `imageIds` / `revisionAdvanceCount``run-workflow` 再放文档 `revision`、恢复标记、识别与绑定计数、`backfillErrors` 与总数;`into-js` 只放相对路径与计数(导出名清单只用来核对数量,不逐项外传)。
- 决策(校验口径):身份字段走 `agent_runtime_action_receipt_identity_text`(禁控制字符、限长、含 file URI 或绝对路径即失败关闭);相对路径必须归一化后落在 `ui/` 下;回填说明是自由文本,按既有口径把绝对路径脱敏成占位符,含控制字符或超长则整条明细失败关闭。
- 决策(体积):回执明细总长仍受 `AGENT_RUNTIME_ACTION_RECEIPT_SAFE_DETAIL_MAX_CHARS`(500)约束。设计图身份与回填说明按长度上限能放多少放多少,放不下的部分用 `backfillErrorCount` 表达总数;必需字段本身就超限时整条明细不可用。
- 决策(入参摘要):`agent_runtime_tool_action_input_summary` 为三个工具产出可读摘要(设计图逐张身份或目标文档 id),并把工具名加进 `agent_runtime_public_action_input_summary` 的可读名单,不再退化成只报哈希。
- 验证方式:`tests::runtime_actions::action_execution::ui_design_doc_receipts_*` 两条用例(三个工具都能留下可用明细且不超长;宿主路径被脱敏;`ui/` 之外与宿主路径身份失败关闭)与 `action_audit::ui_design_doc_public_input_summary_tests` 两条用例;`cargo test --bin genarrative-ai-game-creator-shell receipt` 55 条全绿。
## 2026-09-24 UI 设计文档三个工具纳入项目变更门禁(成功返回即算改过项目)
- 背景:`ui.workflow.run` 退役时,项目变更门禁的两处工具名单(`agent/runtime_actions/project_gates.rs``is_agent_runtime_project_mutation_observation``agent_runtime_observation_advances_project_revision`)只删未补,新接管的 `ui-design-doc.from-images` / `ui-design-doc.run-workflow` / `ui-design-doc.into-js` 都没登记。后果:改完项目可能被判定"没改过",于是不要求验证就判完成、自动模式的 liveness 判据看不到进展;`agent_runtime_pending_expected_project_revision` 少算推进量又会误报 `pending_project_revision_drift`("并行项目变更使旧动作过期")。