给 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();
}