修复游戏续跑与图集完成门

保持失败续跑的原始任务身份并向 Provider 传递有效目标
收紧俄罗斯方块玩法连续性和浏览器动作因果验证
将图集主图、四切片、清单与登记纳入可恢复原子提交
校验切片来源、规范像素唯一性、可见性与有界解码
保留 External 生成结果的稳定来源资源字段
补齐调度子 Agent 展示和事件流测试
同步 Runtime 技术方案与项目决策记录
This commit is contained in:
2026-08-03 23:02:04 +08:00
parent c2ef8631f8
commit 602723ea0d
22 changed files with 2337 additions and 235 deletions
@@ -17,6 +17,7 @@ pub(in crate::agent) use canvas_generation::{
commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at,
platform_art_generation_error_needs_reconciliation,
request_platform_art_asset_with_runtime_options_at,
validate_platform_art_png_bytes_with_limits,
};
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
pub(in crate::agent) use external_generation_state::{
File diff suppressed because it is too large Load Diff
@@ -390,15 +390,23 @@ pub(super) fn mark_platform_art_generation_runtime_legacy_completed(
fn safe_legacy_media_reference(value: &str) -> Option<String> {
let value = value.trim();
(value.starts_with('/') && !value.contains(['?', '#'])).then(|| value.to_string())
(value.starts_with('/')
&& !value.starts_with("//")
&& !value.contains(['?', '#', '\\'])
&& !value.chars().any(char::is_control)
&& !value.split('/').any(|segment| segment == ".."))
.then(|| value.to_string())
}
fn safe_legacy_object_key(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()
&& !value.starts_with(['/', '\\'])
&& !value.starts_with("http://")
&& !value.starts_with("https://")
&& !value.contains(['?', '#']))
&& !value.contains(['?', '#', '\\'])
&& !value.chars().any(char::is_control)
&& !value.split('/').any(|segment| segment == ".."))
.then(|| value.to_string())
}
@@ -421,6 +429,7 @@ fn durable_legacy_generation_object(
"projectId",
"taskId",
"assetObjectId",
"sourceResourceId",
"actualPrompt",
"prompt",
"model",
@@ -449,6 +458,11 @@ fn durable_legacy_generation_result(
result: &serde_json::Value,
) -> Result<serde_json::Value, String> {
let mut durable = durable_legacy_generation_object(result);
for field in ["spritesheetWidth", "spritesheetHeight"] {
if let Some(value) = result.get(field).and_then(serde_json::Value::as_u64) {
durable.insert(field.to_string(), serde_json::Value::from(value));
}
}
for field in [
"resource",
"spritesheetResource",
@@ -473,6 +487,53 @@ fn durable_legacy_generation_result(
}
}
}
if let Some(icons) = result
.get("iconImageSrcs")
.and_then(serde_json::Value::as_array)
{
if icons.len() > 64 {
return Err("External Editor 旧同步结果的图集切片超过 64 个".to_string());
}
let mut durable_icons = Vec::with_capacity(icons.len());
for (index, icon) in icons.iter().enumerate() {
let mut durable_icon = durable_legacy_generation_object(icon);
for field in ["name"] {
copy_legacy_string_field(icon, &mut durable_icon, field);
}
for field in ["width", "height"] {
if let Some(value) = icon.get(field).and_then(serde_json::Value::as_u64) {
durable_icon.insert(field.to_string(), serde_json::Value::from(value));
}
}
if let Some(resource) = icon.get("resource").filter(|value| value.is_object()) {
let resource = durable_legacy_generation_object(resource);
if !resource.is_empty() {
durable_icon
.insert("resource".to_string(), serde_json::Value::Object(resource));
}
}
let has_safe_download = |value: &serde_json::Value| {
json_string_field(value, "objectKey").is_some()
|| json_string_field(value, "imageSrc").is_some()
};
let durable_icon_value = serde_json::Value::Object(durable_icon);
if !has_safe_download(&durable_icon_value)
&& !durable_icon_value
.get("resource")
.is_some_and(has_safe_download)
{
return Err(format!(
"External Editor 旧同步结果的第 {} 个图集切片缺少可安全持久化的下载引用",
index + 1
));
}
durable_icons.push(durable_icon_value);
}
durable.insert(
"iconImageSrcs".to_string(),
serde_json::Value::Array(durable_icons),
);
}
let durable = serde_json::Value::Object(durable);
let has_safe_download = |value: &serde_json::Value| {
json_string_field(value, "objectKey").is_some()
@@ -882,6 +943,21 @@ mod external_generation_state_tests {
"objectKey": "generated/legacy.png",
"imageSrc": "https://signed.example.test/legacy.png?token=secret"
},
"iconImageSrcs": [{
"name": "玩家主体",
"width": 64,
"height": 64,
"resource": {
"resourceId": "legacy-slice-resource",
"assetObjectId": "legacy-slice-object",
"projectId": "canvas-project",
"taskId": "legacy-task",
"sourceResourceId": "legacy-resource",
"objectKey": "generated/legacy-slice.png",
"imageSrc": "https://signed.example.test/legacy-slice.png?token=secret"
},
"unknownSliceField": "drop"
}],
"warning": { "code": "source-only", "reason": "保留原图", "secret": "drop" },
"unknownSensitiveField": "drop-me"
}),
@@ -892,6 +968,25 @@ mod external_generation_state_tests {
assert_eq!(durable["resource"]["resourceId"], "legacy-resource");
assert_eq!(durable["resource"]["objectKey"], "generated/legacy.png");
assert!(durable["resource"].get("imageSrc").is_none());
assert_eq!(durable["iconImageSrcs"].as_array().map(Vec::len), Some(1));
assert_eq!(
durable["iconImageSrcs"][0]["resource"]["resourceId"],
"legacy-slice-resource"
);
assert_eq!(
durable["iconImageSrcs"][0]["resource"]["objectKey"],
"generated/legacy-slice.png"
);
assert_eq!(
durable["iconImageSrcs"][0]["resource"]["sourceResourceId"],
"legacy-resource"
);
assert!(durable["iconImageSrcs"][0]["resource"]
.get("imageSrc")
.is_none());
assert!(durable["iconImageSrcs"][0]
.get("unknownSliceField")
.is_none());
assert!(durable.get("unknownSensitiveField").is_none());
assert!(durable["warning"].get("secret").is_none());
@@ -79,6 +79,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
loop_index: usize,
mcp_catalog: &GameCreatorMcpCatalog,
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, String), String> {
let effective_task = autonomous_effective_root_task_at(root, agent_id, run_id, task)?;
let (llm, config_path, context, repository_context_fingerprint, prompt_observations) =
build_game_creator_background_agent_context(
root,
@@ -164,7 +165,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
context_preload_notice = context_preload_notice,
AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT = AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT,
context = context,
task = task,
task = effective_task,
steers_json = steers_json,
observations_json = observations_json,
canvas_asset_kind_catalog = canvas_asset_kind_catalog,
@@ -205,7 +206,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request(
));
system_prompt.push_str(AGENT_RUNTIME_AUTONOMOUS_TOOL_PLAN_PAYLOAD_GUIDANCE);
let playtest_scenario =
autonomous_playtest_scenario_for_run_at(root, agent_id, run_id, task)?;
autonomous_playtest_scenario_for_run_at(root, agent_id, run_id, &effective_task)?;
let playtest_contract = autonomous_playtest_contract_prompt(playtest_scenario);
system_prompt.push_str("\n\n");
system_prompt.push_str(playtest_contract);
@@ -261,6 +262,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request(
plan: &AgentRuntimeToolPlan,
observations: &[AgentRuntimeToolObservation],
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
let effective_task = autonomous_effective_root_task_at(root, agent_id, run_id, task)?;
let (llm, config_path, context, _repository_context_fingerprint, prompt_observations) =
build_game_creator_background_agent_context(
root,
@@ -289,7 +291,7 @@ pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request(
"开发者"
};
let prompt = format!(
"运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}"
"运行上下文如下。请只依据后台任务、运行中用户追加指令、收束摘要和已获准工具返回的 observation,给{audience}一个正常中文回复。不要输出 JSON,不要假装执行未执行的工具,也不要补充 observation 中不存在的项目事实。\n\n{context}\n\n后台任务:\n{effective_task}\n\n运行中用户追加指令:\n{steers_json}\n\n收束摘要:\n{plan_json}\n\n工具观察:\n{observations_json}"
);
let system_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
game_creator_project_supervisor_chat_system_prompt()
@@ -432,14 +432,42 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_with_session_filter_at(
}
}
}
if !state.run_id.trim().is_empty() && !state.current_task.trim().is_empty() {
match autonomous_effective_root_task_at(
if state.agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& !state.run_id.trim().is_empty()
&& !state.current_task.trim().is_empty()
{
let task_identity = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
&state.agent_id,
&state.run_id,
&state.current_task,
) {
Ok(effective_task) => state.current_task = effective_task,
)
.and_then(|record| {
let Some(record) = record else {
return Ok(None);
};
let state_effective_task = autonomous_effective_root_task_at(
root,
&state.agent_id,
&state.run_id,
&state.current_task,
)?;
let journal_effective_task = autonomous_effective_root_task_at(
root,
&state.agent_id,
&state.run_id,
&record.task,
)?;
if state_effective_task != journal_effective_task {
return Err("自主构建 Runtime 与 journal 的有效根任务不一致".to_string());
}
Ok(Some(record.task))
});
match task_identity {
// current_task 是 Runtime/task/provider/context 的持久身份锚点,必须保持
// journal 原文;自主续跑的原始玩法语义只通过 effective root task 读取。
Ok(Some(journal_task)) => state.current_task = journal_task,
Ok(None) => {}
Err(error) => {
state.status = "failed".to_string();
state.phase = "needs-reconciliation".to_string();
@@ -312,6 +312,22 @@ fn game_chat_fast_path_has_art_manifest(root: &Path) -> bool {
pub(in crate::agent) fn game_chat_fast_path_art_slice_paths(
root: &Path,
) -> Result<Vec<String>, String> {
Ok(game_chat_fast_path_validated_art_slices(root)?
.into_iter()
.map(|slice| slice.path)
.collect())
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(in crate::agent) struct GameChatValidatedArtSlice {
pub(in crate::agent) path: String,
pub(in crate::agent) width: u32,
pub(in crate::agent) height: u32,
}
pub(in crate::agent) fn game_chat_fast_path_validated_art_slices(
root: &Path,
) -> Result<Vec<GameChatValidatedArtSlice>, String> {
let file = read_local_project_file_at(root, "assets/art-spritesheet-slices/manifest.json")?;
let manifest: serde_json::Value = serde_json::from_str(&file.content)
.map_err(|error| format!("game-chat 图集切片清单不是有效 JSON:{error}"))?;
@@ -348,7 +364,9 @@ pub(in crate::agent) fn game_chat_fast_path_art_slice_paths(
"obstacles-and-scene",
"feedback-effects",
];
let mut paths = Vec::with_capacity(required_usages.len());
let mut validated_slices = Vec::with_capacity(required_usages.len());
let mut total_bytes = 0usize;
let mut pixel_sha256s = std::collections::HashSet::with_capacity(required_usages.len());
for usage in required_usages {
let slice = slices
.iter()
@@ -361,13 +379,63 @@ pub(in crate::agent) fn game_chat_fast_path_art_slice_paths(
.filter(|path| *path == expected_path)
.ok_or_else(|| format!("game-chat {usage} 切片路径无效"))?;
let absolute = resolve_local_project_path(root, path)?;
let file_bytes = usize::try_from(
fs::metadata(&absolute)
.map_err(|error| format!("game-chat {usage} 切片无法读取元数据:{error}"))?
.len(),
)
.map_err(|_| format!("game-chat {usage} 切片大小溢出"))?;
if file_bytes > 20 * 1024 * 1024 {
return Err(format!("game-chat {usage} 切片超过 20 MiB 校验上限"));
}
total_bytes = total_bytes
.checked_add(file_bytes)
.ok_or_else(|| "game-chat 图集切片累计大小溢出".to_string())?;
if total_bytes > 32 * 1024 * 1024 {
return Err("game-chat 图集切片累计超过 32 MiB 校验上限".to_string());
}
let bytes = fs::read(&absolute)
.map_err(|error| format!("game-chat {usage} 切片无法读取:{error}"))?;
image::load_from_memory(&bytes)
.map_err(|error| format!("game-chat {usage} 切片无法解码:{error}"))?;
paths.push(path.to_string());
if bytes.len() != file_bytes {
return Err(format!("game-chat {usage} 切片在读取期间发生变化"));
}
let validated = validate_platform_art_png_bytes_with_limits(
&bytes,
&format!("game-chat {usage} 切片"),
)?;
let expected_width = slice
.get("width")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok());
let expected_height = slice
.get("height")
.and_then(serde_json::Value::as_u64)
.and_then(|value| u32::try_from(value).ok());
if expected_width != Some(validated.width) || expected_height != Some(validated.height) {
return Err(format!("game-chat {usage} 切片尺寸与清单不一致"));
}
if slice
.get("contentSha256")
.and_then(serde_json::Value::as_str)
!= Some(validated.content_sha256.as_str())
|| slice.get("pixelSha256").and_then(serde_json::Value::as_str)
!= Some(validated.pixel_sha256.as_str())
{
return Err(format!("game-chat {usage} 切片内容摘要与清单不一致"));
}
if !validated.has_visible_pixels {
return Err(format!("game-chat {usage} 切片全透明且没有可见内容"));
}
if !pixel_sha256s.insert(validated.pixel_sha256) {
return Err("game-chat 四类切片存在相同规范像素内容".to_string());
}
validated_slices.push(GameChatValidatedArtSlice {
path: path.to_string(),
width: validated.width,
height: validated.height,
});
}
Ok(paths)
Ok(validated_slices)
}
pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at(
@@ -850,15 +918,15 @@ const FALLBACK_GAME_HTML: &str = r###"<!doctype html>
context.strokeRect(20, 20, canvas.width - 40, canvas.height - 132);
if (artReady) {
for (let tile = 0; tile < 8; tile += 1) {
context.drawImage(sceneArt, tile * 120 + 12, canvas.height - 106, 96, 80);
context.drawImage(sceneArt, Math.min(canvas.width - 96, Math.max(0, tile * 120 + 12)), Math.min(canvas.height - 80, Math.max(0, canvas.height - 106)), 96, 80);
}
context.drawImage(playerArt, playerX - 56, canvas.height - 198, 112, 112);
context.drawImage(playerArt, Math.min(canvas.width - 112, Math.max(0, playerX - 56)), Math.min(canvas.height - 112, Math.max(0, canvas.height - 198)), 112, 112);
}
const targetY = 192 + Math.sin(pulse) * 5;
if (artReady) {
context.drawImage(targetArt, targetX - 56, targetY - 56, 112, 112);
context.drawImage(targetArt, Math.min(canvas.width - 112, Math.max(0, targetX - 56)), Math.min(canvas.height - 112, Math.max(0, targetY - 56)), 112, 112);
if (state.score > 0) {
context.drawImage(feedbackArt, playerX + 32, canvas.height - 222, 72, 72);
context.drawImage(feedbackArt, Math.min(canvas.width - 72, Math.max(0, playerX + 32)), Math.min(canvas.height - 72, Math.max(0, canvas.height - 222)), 72, 72);
}
}
context.fillStyle = '#eaf4ff';
@@ -1118,7 +1186,7 @@ const FALLBACK_TETRIS_GAME_HTML: &str = r###"<!doctype html>
function draw() {
context.clearRect(0, 0, canvas.width, canvas.height);
if (artReady) context.drawImage(sceneArt, 0, 0, canvas.width, canvas.height);
if (artReady) context.drawImage(sceneArt, 0, 0, 360, 600);
context.fillStyle = '#071426cc';
context.fillRect(BOARD_X, 0, COLS * CELL, ROWS * CELL);
context.strokeStyle = '#315b7f88';
@@ -1129,6 +1197,8 @@ const FALLBACK_TETRIS_GAME_HTML: &str = r###"<!doctype html>
context.beginPath(); context.moveTo(BOARD_X, y * CELL); context.lineTo(BOARD_X + COLS * CELL, y * CELL); context.stroke();
}
if (artReady) {
context.drawImage(playerArt, 8, 8, 48, 48);
context.drawImage(targetArt, 8, 64, 48, 48);
board.forEach((row, y) => row.forEach((cell, x) => { if (cell) drawCell(targetArt, x, y, .92); }));
if (current) current.shape.forEach((row, rowIndex) => row.forEach((cell, columnIndex) => {
if (cell) drawCell(playerArt, current.x + columnIndex, current.y + rowIndex);
@@ -1262,12 +1332,18 @@ mod tests {
)
.save(root.join(&path))
.expect("write fast path slice fixture");
let bytes = fs::read(root.join(&path)).expect("read fast path slice fixture");
let validated =
validate_platform_art_png_bytes_with_limits(&bytes, "fast path slice fixture")
.expect("validate fast path slice fixture");
serde_json::json!({
"name": format!("素材 {}", index + 1),
"path": path,
"width": 32,
"height": 32,
"usage": usage,
"contentSha256": validated.content_sha256,
"pixelSha256": validated.pixel_sha256,
})
})
.collect::<Vec<_>>();
@@ -1294,6 +1370,58 @@ mod tests {
);
}
#[test]
fn art_slice_completion_validation_rejects_tampering_and_duplicate_pixels() {
let temporary = tempfile::tempdir().expect("create slice validation project");
let root = temporary.path();
init_local_game_project_at(root, "slice-validation", "切片完成门测试")
.expect("init project");
register_fast_path_visual_fixture(
root,
"assets/art-spritesheet.png",
"art-spritesheet",
vec!["fast-path-art-spec-resource".to_string()],
);
write_fast_path_art_slice_fixture(root);
assert_eq!(
game_chat_fast_path_validated_art_slices(root)
.expect("fresh strict slice contract is valid")
.len(),
4
);
let player_path = root.join("assets/art-spritesheet-slices/player.png");
let targets_path = root.join("assets/art-spritesheet-slices/blocks-and-targets.png");
fs::copy(&player_path, &targets_path).expect("replace targets with duplicate pixels");
let duplicate_bytes = fs::read(&targets_path).expect("read duplicate slice");
let duplicate = validate_platform_art_png_bytes_with_limits(
&duplicate_bytes,
"duplicate completion slice",
)
.expect("validate duplicate slice");
let manifest_path = root.join("assets/art-spritesheet-slices/manifest.json");
let mut manifest: serde_json::Value =
serde_json::from_slice(&fs::read(&manifest_path).expect("read slice manifest"))
.expect("parse slice manifest");
let target = manifest["slices"]
.as_array_mut()
.expect("slice manifest array")
.iter_mut()
.find(|slice| slice["usage"] == "blocks-and-targets")
.expect("target slice manifest");
target["contentSha256"] = serde_json::json!(duplicate.content_sha256);
target["pixelSha256"] = serde_json::json!(duplicate.pixel_sha256);
fs::write(
&manifest_path,
serde_json::to_vec_pretty(&manifest).expect("serialize tampered manifest"),
)
.expect("write tampered manifest");
let error = game_chat_fast_path_validated_art_slices(root)
.expect_err("updating the manifest cannot legitimize duplicate visual content");
assert!(error.contains("相同规范像素"));
}
#[test]
fn fallback_html_satisfies_playable_contract() {
let html = render_game_chat_fast_path_html("星河收集挑战");
@@ -93,12 +93,18 @@ fn register_game_chat_art_spritesheet_fixture(root: &Path) {
image::RgbaImage::from_pixel(32, 32, image::Rgba([90 + index as u8, 140, 220, 180]))
.save(root.join(&path))
.expect("write game-chat slice fixture");
let bytes = fs::read(root.join(&path)).expect("read game-chat slice fixture");
let validated =
validate_platform_art_png_bytes_with_limits(&bytes, "game-chat slice fixture")
.expect("validate game-chat slice fixture");
serde_json::json!({
"name": format!("素材 {}", index + 1),
"path": path,
"width": 32,
"height": 32,
"usage": usage,
"contentSha256": validated.content_sha256,
"pixelSha256": validated.pixel_sha256,
})
})
.collect::<Vec<_>>();
@@ -319,12 +319,18 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) {
image::RgbaImage::from_pixel(32, 32, image::Rgba([90 + index as u8, 140, 220, 180]))
.save(root.join(&path))
.expect("write autonomous slice fixture");
let bytes = fs::read(root.join(&path)).expect("read autonomous slice fixture");
let validated =
validate_platform_art_png_bytes_with_limits(&bytes, "autonomous slice fixture")
.expect("validate autonomous slice fixture");
serde_json::json!({
"name": format!("素材 {}", index + 1),
"path": path,
"width": 32,
"height": 32,
"usage": usage,
"contentSha256": validated.content_sha256,
"pixelSha256": validated.pixel_sha256,
})
})
.collect::<Vec<_>>();
@@ -617,6 +623,7 @@ fn autonomous_continuation_intent_is_exact_and_does_not_swallow_new_requirements
#[test]
fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let original_task = "做一个水晶主题的俄罗斯方块,完成移动、旋转、消行和重开";
let (_temporary, root, original_state, original_contract) = autonomous_fixture_with_source(
original_task,
@@ -697,6 +704,72 @@ fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress(
.expect("continued runtime keeps an autonomous completion contract"),
continuation_contract
);
let mut legacy_hydrated_state = continuation_state.clone();
legacy_hydrated_state.current_task = original_task.to_string();
write_game_creator_agent_runtime_state(&root, &legacy_hydrated_state)
.expect("persist legacy successor state with effective task text");
let recovered = read_game_creator_agent_runtime_for_session_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
Some(&continuation.session_id),
)
.expect("restart hydration accepts equivalent successor semantics")
.state;
assert_eq!(
recovered.current_task, continuation.task,
"restart hydration must restore the raw journal task identity"
);
validate_agent_runtime_context_task_parameter(&root, &recovered, original_task)
.expect("legacy effective context task remains semantically equivalent");
let legacy_bundle = build_game_creator_agent_runtime_context_bundle(
&root,
&recovered,
original_task,
&AgentRuntimeToolPlan::default(),
&[],
recovered.loop_iteration as usize,
&AgentRuntimeContextWindowTracker::default(),
)
.expect("build a legacy effective-task context bundle");
write_game_creator_agent_runtime_context_bundle(&root, &legacy_bundle)
.expect("persist legacy effective-task context bundle");
read_game_creator_agent_runtime_context_bundle(&root, &recovered)
.expect("restart accepts an effective-task context bundle")
.expect("legacy effective-task context bundle exists");
write_game_creator_agent_runtime_state(&root, &recovered)
.expect("persist canonical successor runtime state");
capture_game_creator_agent_runtime_provider_request_snapshot(
&root,
&recovered.agent_id,
&recovered.session_id,
&recovered.run_id,
"planning",
"successor-restart",
recovered.applied_steer_cursor,
)
.expect("provider snapshot accepts canonical successor hydration");
let catalog = GameCreatorMcpCatalog {
fingerprint: String::new(),
servers: Vec::new(),
tools: Vec::new(),
};
let (_, _, provider_request, _) = build_game_creator_agent_background_tool_plan_request(
&root,
&recovered.agent_id,
&recovered.session_id,
&recovered.run_id,
&recovered.current_task,
&[],
0,
&catalog,
)
.expect("build successor provider request with inherited task semantics");
let provider_prompt = &provider_request.messages[1].content;
assert!(provider_prompt.contains(original_task), "{provider_prompt}");
assert!(
!provider_prompt.contains("后台任务:\n继续完成。"),
"provider must not receive the continuation phrase as the business goal: {provider_prompt}"
);
update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Pending)
.expect("make one inherited task ready for scheduler validation");
let scheduled = schedule_autonomous_game_build_ready_tasks_at(
@@ -780,6 +853,62 @@ fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_progress(
.is_empty());
}
#[test]
fn gui_and_cli_pure_continue_inherit_only_within_the_same_source() {
for (source, prefix) in [
(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, "gui"),
(AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, "cli"),
] {
let original_task = "做一个水晶主题的俄罗斯方块,完成旋转、消行与重开";
let (_temporary, root, original_state, original_contract) = autonomous_fixture_with_source(
original_task,
&format!("{prefix}-same-source-original"),
source,
);
let original_record = read_latest_game_creator_agent_runtime_task_by_run_id(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&original_state.run_id,
)
.expect("read same-source original root")
.expect("same-source original root exists");
append_failed_autonomous_root_projection(&root, &original_record, "failed");
let continuation = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&original_state.session_id,
"继续",
&format!("{prefix}-same-source-continuation"),
source,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue same-source continuation");
let continuation_contract = read_autonomous_completion_contract(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&continuation.run_id,
)
.expect("read same-source continuation contract")
.expect("same-source continuation contract exists");
assert_eq!(
continuation_contract.task_sha256, original_contract.task_sha256,
"{source} must inherit the failed root contract within one session and source"
);
assert_eq!(
autonomous_effective_root_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&continuation.run_id,
&continuation.task,
)
.expect("resolve same-source effective root task"),
original_task
);
}
}
#[test]
fn game_chat_detailed_new_request_after_failure_resets_manifest() {
let (_temporary, root, original_state, original_contract) =
@@ -828,6 +957,97 @@ fn game_chat_detailed_new_request_after_failure_resets_manifest() {
.all(|task| task.status == GameCreationAppTaskStatus::Pending));
}
#[test]
fn inherited_tetris_contract_rejects_a_generic_collection_replacement() {
let original_task = "做一个水晶主题的俄罗斯方块,完成移动、旋转、下落锁定、消行和重开";
let (_temporary, root, original_state, _original_contract) = autonomous_fixture_with_source(
original_task,
"semantic-tetris-original-run",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
);
let original_record = read_latest_game_creator_agent_runtime_task_by_run_id(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&original_state.run_id,
)
.expect("read semantic original root")
.expect("semantic original root exists");
append_failed_autonomous_root_projection(&root, &original_record, "budget-exhausted");
let continuation = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&original_state.session_id,
"继续",
"semantic-tetris-continuation-run",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue semantic continuation");
let state = agent_runtime_state_from_task_record(&continuation);
let contract = read_autonomous_completion_contract(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&continuation.run_id,
)
.expect("read semantic continuation contract")
.expect("semantic continuation contract exists");
let collection_html = format!(
"{}<script>function collectEnergy(){{ score += 10; }} const collectionMode='energy';</script>",
cropped_spritesheet_game_html()
);
let revision = advance_game_index_revision(&root, &state, &collection_html);
mark_verification_passed(&root, &state, "game.static_smoke");
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state)
.expect("collection replacement must not complete an inherited tetris contract");
assert!(blocker.summary.contains("原玩法语义"));
assert!(blocker
.detail
.as_deref()
.is_some_and(|detail| detail.contains("tetris-identity")));
let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario);
let action = AgentRuntimeToolAction {
tool: "preview.validate".to_string(),
reason: Some("negative semantic continuity fixture".to_string()),
input: serde_json::json!({}),
};
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task);
let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint);
let receipt_error = write_autonomous_playtest_receipt_at(
&root,
&contract,
&action_id,
&action_fingerprint,
revision,
&result,
)
.expect_err("playtest evidence must not bless a different gameplay implementation");
assert!(receipt_error.contains("原玩法语义"));
let tetris_html = format!(
"{}<script>const gameMode='tetris'; let board=Array.from({{length:20}},()=>Array(10).fill(0)); let current={{shape:[[1,1],[1,1]],y:0}}; function rotatePiece(){{const rotated=current.shape.map((row)=>row.slice()).reverse(); current.shape=rotated;}} function mergePiece(){{board[current.y][0]=1; clearLines();}} function clearLines(){{board=board.filter((row)=>!row.every(Boolean));}} function stepDown(){{current.y+=1; if(current.y>18) mergePiece();}} document.addEventListener('click',rotatePiece); requestAnimationFrame(stepDown);</script>",
cropped_spritesheet_game_html()
);
advance_game_index_revision(&root, &state, &tetris_html);
mark_verification_passed(&root, &state, "game.static_smoke");
let next_blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state)
.expect("valid inherited tetris semantics should proceed to the playtest receipt gate");
assert!(next_blocker.summary.contains("交互试玩回执"));
let dead_semantics = format!(
"{}<script>const lure='tetris board rotate drop lock clearLines Array.from( board['; function rotatePiece(){{}} function stepDown(){{}} function mergePiece(){{}} function clearLines(){{}} if(false){{ board[0][0]=1; }}</script>",
cropped_spritesheet_game_html()
);
advance_game_index_revision(&root, &state, &dead_semantics);
mark_verification_passed(&root, &state, "game.static_smoke");
let dead_blocker = autonomous_game_build_completion_blocker_at_locked(&root, &state)
.expect("dead strings and empty functions must not satisfy inherited tetris semantics");
assert!(dead_blocker.summary.contains("原玩法语义"));
}
#[test]
fn game_chat_pure_continue_does_not_inherit_across_sessions() {
let (_temporary, root, original_state, original_contract) =
@@ -1644,6 +1864,29 @@ fn game_chat_code_prototype_requires_cropped_spritesheet_use() {
"guessing one atlas crop must not replace four persisted core slices"
);
advance_game_index_revision(
&root,
&code_state,
"<!doctype html><html><body><canvas width=320 height=180></canvas><script>const context=document.querySelector('canvas').getContext('2d');const playerArt=new Image();playerArt.src='../assets/art-spritesheet-slices/player.png';const targetArt=new Image();targetArt.src='../assets/art-spritesheet-slices/blocks-and-targets.png';const sceneArt=new Image();sceneArt.src='../assets/art-spritesheet-slices/obstacles-and-scene.png';const feedbackArt=new Image();feedbackArt.src='../assets/art-spritesheet-slices/feedback-effects.png';function neverDraw(){context.drawImage(playerArt,0,0,64,64);context.drawImage(targetArt,64,0,64,64);context.drawImage(sceneArt,128,0,64,64);context.drawImage(feedbackArt,192,0,64,64);}if(false) neverDraw();</script></body></html>",
);
assert!(
autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(),
"draw calls reachable only through a literal-false branch must not satisfy visible use"
);
advance_game_index_revision(
&root,
&code_state,
"<!doctype html><html><body><canvas width=320 height=180></canvas><script>const context=document.querySelector('canvas').getContext('2d');const playerArt=new Image();playerArt.src='../assets/art-spritesheet-slices/player.png';playerArt.src='data:image/png;base64,overridden';const targetArt=new Image();targetArt.src='../assets/art-spritesheet-slices/blocks-and-targets.png';const sceneArt=new Image();sceneArt.src='../assets/art-spritesheet-slices/obstacles-and-scene.png';const feedbackArt=new Image();feedbackArt.src='../assets/art-spritesheet-slices/feedback-effects.png';context.drawImage(playerArt,0,0,64,64);context.drawImage(targetArt,64,0,64,64);context.drawImage(sceneArt,128,0,64,64);context.drawImage(feedbackArt,192,0,64,64);</script></body></html>",
);
let overwritten_blocker =
autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
.expect("an overwritten slice source must not satisfy visible use");
assert!(overwritten_blocker
.detail
.as_deref()
.is_some_and(|detail| detail.contains("art-spritesheet-slices/player.png")));
advance_game_index_revision(&root, &code_state, cropped_spritesheet_game_html());
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none());
}
@@ -144,12 +144,16 @@ pub(in crate::agent) fn validate_agent_runtime_context_task_parameter(
runtime: &AgentRuntimeState,
task: &str,
) -> Result<(), String> {
let expected = redact_agent_runtime_project_paths(
let expected = autonomous_effective_root_task_at(
root,
&runtime.agent_id,
&runtime.run_id,
&runtime.current_task,
AGENT_RUNTIME_TASK_MAX_CHARS,
);
let actual = redact_agent_runtime_project_paths(root, task, AGENT_RUNTIME_TASK_MAX_CHARS);
)?;
let actual = autonomous_effective_root_task_at(root, &runtime.agent_id, &runtime.run_id, task)?;
let expected =
redact_agent_runtime_project_paths(root, &expected, AGENT_RUNTIME_TASK_MAX_CHARS);
let actual = redact_agent_runtime_project_paths(root, &actual, AGENT_RUNTIME_TASK_MAX_CHARS);
if actual != expected {
return Err("Agent Runtime 任务参数与当前状态不匹配".to_string());
}
@@ -473,12 +477,7 @@ pub(in crate::agent) fn read_game_creator_agent_runtime_context_bundle_with_supe
|| bundle.source != redact_agent_runtime_project_paths(root, &runtime.source, 120)
|| bundle.run_profile != runtime.run_profile
|| bundle.run_profile_binding_fingerprint != runtime.run_profile_binding_fingerprint
|| bundle.task
!= redact_agent_runtime_project_paths(
root,
&runtime.current_task,
AGENT_RUNTIME_TASK_MAX_CHARS,
)
|| validate_agent_runtime_context_task_parameter(root, runtime, &bundle.task).is_err()
|| bundle.verification_gate.project_id != bundle.project_id
|| bundle.verification_gate.agent_id != bundle.agent_id
|| bundle.verification_gate.run_id != bundle.run_id
@@ -242,7 +242,7 @@ pub(in crate::agent) fn wake_waiting_isolated_join_parent_run_at(
.state;
if state.run_id != current_task.run_id
|| state.session_id != current_task.session_id
|| state.current_task != current_task.task
|| validate_agent_runtime_context_task_parameter(root, &state, &current_task.task).is_err()
{
return Err("动态隔离 Agent parent-wake 的父 run 状态身份不一致".to_string());
}
@@ -276,7 +276,8 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
.state;
if state.run_id != parent_task.run_id
|| state.session_id != parent_task.session_id
|| state.current_task != parent_task.task
|| validate_agent_runtime_context_task_parameter(root, &state, &parent_task.task)
.is_err()
|| state.phase != "waiting-for-delegate-receipts"
{
return Err("静态委派 parent-wake 的父 run 状态身份不一致".to_string());
@@ -318,7 +319,7 @@ pub(in crate::agent) fn wake_waiting_static_delegate_parent_run_at(
.state;
if state.run_id != current_task.run_id
|| state.session_id != current_task.session_id
|| state.current_task != current_task.task
|| validate_agent_runtime_context_task_parameter(root, &state, &current_task.task).is_err()
{
return Err("静态委派 parent-wake 的父 run 状态身份不一致".to_string());
}
@@ -376,7 +377,7 @@ pub(in crate::agent) fn wake_waiting_autonomous_manifest_parent_run_at(
.state;
if state.run_id != current_task.run_id
|| state.session_id != current_task.session_id
|| state.current_task != current_task.task
|| validate_agent_runtime_context_task_parameter(root, &state, &current_task.task).is_err()
|| state.phase != "waiting-for-manifest-tasks"
{
return Err("manifest parent-wake 的父 run 状态身份不一致".to_string());
@@ -673,8 +673,18 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
};
}
let game_chat_requires_core_slices = if agent_id == "art-asset-plan" {
agent_runtime_root_source_at(root, agent_id, run_id)
.is_ok_and(|source| source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE)
match agent_runtime_root_source_at(root, agent_id, run_id) {
Ok(source) => source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: "无法校验 art-asset-plan 的 root source,已拒绝降级为普通图集语义"
.to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
}
} else {
false
};
@@ -742,9 +752,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
if game_chat_requires_core_slices && prepared.slice_count() != 4 {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
status: AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION.to_string(),
summary: format!(
"透明图集生成结果包含 {} 个独立切片;game-chat 必须恰好得到玩家、目标、场景和反馈四类真实素材,已在正式图集登记前失败关闭",
"透明图集生成结果包含 {} 个独立切片;External Editor 已完成并产生 durable 结果,game-chat 必须恰好得到玩家、目标、场景和反馈四类真实素材,已保留账本等待人工对账",
prepared.slice_count()
),
detail: None,
@@ -832,20 +842,6 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
};
match committed {
Ok(generated) => {
if game_chat_requires_core_slices {
if let Err(error) = write_local_project_file_at(
root,
"assets/manifest.art.json",
&game_chat_fast_path_art_manifest_content(),
) {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: "透明图集与四类切片已生成,但正式美术清单提交失败".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
}
let verification = begin_agent_runtime_project_verification_locked(
root,
agent_id,
@@ -452,16 +452,157 @@ pub(crate) async fn resolve_canvas_resource_download(
api_key: &str,
resource: &serde_json::Value,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit(
client,
api_base_url,
api_key,
resource,
20 * 1024 * 1024,
)
.await
}
fn external_asset_url_same_origin(url: &url::Url, api_base_url: &str) -> bool {
let Ok(api_base_url) = url::Url::parse(api_base_url) else {
return false;
};
url.scheme() == api_base_url.scheme()
&& url.host_str() == api_base_url.host_str()
&& url.port_or_known_default() == api_base_url.port_or_known_default()
}
fn external_asset_host_is_private(host: &str) -> bool {
let host = host.trim_start_matches('[').trim_end_matches(']');
if host.eq_ignore_ascii_case("localhost") || host.ends_with(".localhost") {
return true;
}
let Ok(address) = host.parse::<std::net::IpAddr>() else {
return false;
};
match address {
std::net::IpAddr::V4(address) => {
let [first, second, ..] = address.octets();
address.is_private()
|| address.is_loopback()
|| address.is_link_local()
|| address.is_broadcast()
|| address.is_unspecified()
|| address.is_multicast()
|| first == 0
|| (first == 100 && (64..=127).contains(&second))
|| (first == 198 && (18..=19).contains(&second))
}
std::net::IpAddr::V6(address) => {
address.is_loopback()
|| address.is_unspecified()
|| address.is_unique_local()
|| address.is_unicast_link_local()
|| address.is_multicast()
|| address
.to_ipv4_mapped()
.is_some_and(|mapped| external_asset_host_is_private(&mapped.to_string()))
}
}
}
fn validate_external_asset_download_url(
value: &str,
api_base_url: &str,
_came_from_stable_reference: bool,
) -> Result<url::Url, String> {
let url = url::Url::parse(value).map_err(|error| format!("画板资产下载地址无效:{error}"))?;
if !matches!(url.scheme(), "http" | "https") {
return Err("画板资产下载地址只允许 HTTP(S)".to_string());
}
if !url.username().is_empty() || url.password().is_some() {
return Err("画板资产下载地址不能包含用户凭据".to_string());
}
let host = url
.host_str()
.ok_or_else(|| "画板资产下载地址缺少主机".to_string())?;
// 配置中的 External Editor 本身可以是 localhost;同源媒体仍停留在这条已授权
// 边界内。任何跨 origin 的私网地址均拒绝,且下载客户端不跟随重定向。
if external_asset_host_is_private(host) && !external_asset_url_same_origin(&url, api_base_url) {
return Err("画板资产下载地址指向本机或私有网络,已拒绝请求".to_string());
}
Ok(url)
}
async fn build_external_asset_download_client(
url: &url::Url,
api_base_url: &str,
) -> Result<reqwest::Client, String> {
let mut builder = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::none());
let host = url
.host_str()
.ok_or_else(|| "画板资产下载地址缺少主机".to_string())?;
let host_is_literal_or_local = host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<std::net::IpAddr>()
.is_ok()
|| host.eq_ignore_ascii_case("localhost")
|| host.ends_with(".localhost");
if !host_is_literal_or_local && !external_asset_url_same_origin(url, api_base_url) {
let lookup_host = host.to_string();
let lookup_port = url
.port_or_known_default()
.ok_or_else(|| "画板资产下载地址缺少有效端口".to_string())?;
let addresses = tokio::task::spawn_blocking(move || {
std::net::ToSocketAddrs::to_socket_addrs(&(lookup_host.as_str(), lookup_port))
.map(|addresses| addresses.collect::<Vec<_>>())
})
.await
.map_err(|_| "解析画板资产下载域名的任务异常".to_string())?
.map_err(|error| format!("解析画板资产下载域名失败:{error}"))?;
if addresses.is_empty() {
return Err("画板资产下载域名没有可用地址".to_string());
}
if addresses
.iter()
.any(|address| external_asset_host_is_private(&address.ip().to_string()))
{
return Err("画板资产下载域名解析到本机或私有网络,已拒绝请求".to_string());
}
builder = builder.resolve_to_addrs(host, &addresses);
}
builder
.build()
.map_err(|error| format!("创建画板资产安全下载客户端失败:{error}"))
}
pub(crate) async fn resolve_canvas_resource_download_with_limit(
_client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
resource: &serde_json::Value,
max_bytes: usize,
) -> Result<Option<CanvasResourceDownload>, String> {
if max_bytes == 0 {
return Err("画板资产剩余下载预算为 0,已拒绝同步".to_string());
}
let secure_client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(60))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| format!("创建画板资产安全下载客户端失败:{error}"))?;
let object_key = json_string_field(resource, "objectKey");
let image_src = json_string_field(resource, "imageSrc");
let source_hint = object_key.as_deref().or(image_src.as_deref());
let signed_url = if let Some(object_key) = object_key.as_deref() {
let (signed_url, came_from_stable_reference) = if let Some(object_key) = object_key.as_deref() {
let read_url = format!(
"{}/api/external/v1/assets/read-url?objectKey={}",
api_base_url,
percent_encode_query_component(object_key)
);
Some(resolve_external_asset_signed_url(client, api_key, read_url).await?)
(
Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?),
true,
)
} else if let Some(image_src) = image_src.as_deref() {
if image_src.starts_with('/') {
let read_url = format!(
@@ -469,32 +610,43 @@ pub(crate) async fn resolve_canvas_resource_download(
api_base_url,
percent_encode_query_component(image_src)
);
Some(resolve_external_asset_signed_url(client, api_key, read_url).await?)
(
Some(resolve_external_asset_signed_url(&secure_client, api_key, read_url).await?),
true,
)
} else if image_src.starts_with("http://") || image_src.starts_with("https://") {
Some(image_src.to_string())
(Some(image_src.to_string()), false)
} else {
None
(None, false)
}
} else {
None
(None, false)
};
let Some(url) = signed_url else {
return Ok(None);
};
let response = client
let url = validate_external_asset_download_url(&url, api_base_url, came_from_stable_reference)?;
let download_client = build_external_asset_download_client(&url, api_base_url).await?;
let mut response = download_client
.get(url)
.send()
.await
.map_err(|error| format!("下载画板资产失败:{error}"))?;
let status = response.status();
if status.is_redirection() {
return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string());
}
if !status.is_success() {
return Err(format!("下载画板资产失败:HTTP {}", status.as_u16()));
}
if response
.content_length()
.is_some_and(|size| size > 20 * 1024 * 1024)
.is_some_and(|size| size > max_bytes as u64)
{
return Err("画板资产超过 20 MiB,已拒绝同步".to_string());
return Err(format!(
"画板资产超过当前 {} 字节下载预算,已拒绝同步",
max_bytes
));
}
let media_type = response
.headers()
@@ -504,26 +656,40 @@ pub(crate) async fn resolve_canvas_resource_download(
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = response
.bytes()
let mut bytes = Vec::with_capacity(
response
.content_length()
.and_then(|size| usize::try_from(size).ok())
.unwrap_or_default()
.min(max_bytes),
);
while let Some(chunk) = response
.chunk()
.await
.map_err(|error| format!("读取画板资产失败:{error}"))?;
if bytes.len() > 20 * 1024 * 1024 {
return Err("画板资产超过 20 MiB,已拒绝同步".to_string());
.map_err(|error| format!("读取画板资产失败:{error}"))?
{
let next_len = bytes
.len()
.checked_add(chunk.len())
.ok_or_else(|| "画板资产下载大小溢出".to_string())?;
if next_len > max_bytes {
return Err(format!(
"画板资产超过当前 {} 字节下载预算,已拒绝同步",
max_bytes
));
}
bytes.extend_from_slice(&chunk);
}
validate_canvas_downloaded_asset_content(
source_hint,
image_src.is_some(),
&media_type,
&bytes,
bytes.as_slice(),
)?;
if bytes.is_empty() {
return Ok(None);
}
Ok(Some(CanvasResourceDownload {
bytes: bytes.to_vec(),
media_type,
}))
Ok(Some(CanvasResourceDownload { bytes, media_type }))
}
pub(crate) async fn resolve_external_asset_signed_url(
@@ -890,6 +1056,22 @@ pub(crate) fn register_local_asset_entry(
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("set asset test read timeout");
let mut bytes = Vec::new();
let mut buffer = [0_u8; 1024];
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut buffer).expect("read asset test request");
if read == 0 {
break;
}
bytes.extend_from_slice(&buffer[..read]);
}
}
#[test]
fn canvas_download_accepts_supported_image_magic() {
@@ -971,4 +1153,109 @@ mod tests {
)
.expect("video downloads are outside image magic validation");
}
#[test]
fn canvas_download_url_blocks_private_direct_sources_but_allows_configured_resign_origin() {
for url in [
"http://127.0.0.1/internal.png",
"http://169.254.169.254/latest/meta-data",
"http://[::1]/internal.png",
"http://localhost/internal.png",
] {
assert!(
validate_external_asset_download_url(url, "http://127.0.0.1:3101", false,).is_err()
);
}
validate_external_asset_download_url(
"http://127.0.0.1:3101/api/assets/object/stable.png",
"http://127.0.0.1:3101",
true,
)
.expect("configured External Editor origin may serve a resigned stable object");
validate_external_asset_download_url(
"https://cdn.example.test/assets/stable.png",
"http://127.0.0.1:3101",
false,
)
.expect("public HTTPS asset is allowed");
}
#[tokio::test]
async fn canvas_download_rejects_redirects_before_following_private_targets() {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind redirect download fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let signed_url = format!("{base_url}/signed.png");
let server = std::thread::spawn(move || {
let (mut signing, _) = listener.accept().expect("accept signing request");
read_asset_test_request(&mut signing);
let body = serde_json::json!({"read": {"signedUrl": signed_url}}).to_string();
write!(
signing,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.expect("write signing response");
let (mut download, _) = listener.accept().expect("accept asset request");
read_asset_test_request(&mut download);
write!(
download,
"HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
.expect("write redirect response");
});
let error = resolve_canvas_resource_download(
&reqwest::Client::new(),
&base_url,
"test-api-key",
&serde_json::json!({"objectKey": "stable/slice.png"}),
)
.await
.err()
.expect("redirect must fail closed");
server.join().expect("join redirect fixture");
assert!(error.contains("重定向"));
}
#[tokio::test]
async fn canvas_download_applies_remaining_budget_before_buffering_body() {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("bind bounded download fixture");
let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
let signed_url = format!("{base_url}/signed.png");
let server = std::thread::spawn(move || {
let (mut signing, _) = listener.accept().expect("accept signing request");
read_asset_test_request(&mut signing);
let body = serde_json::json!({"read": {"signedUrl": signed_url}}).to_string();
write!(
signing,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.expect("write signing response");
let (mut download, _) = listener.accept().expect("accept bounded asset request");
read_asset_test_request(&mut download);
write!(
download,
"HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: 9\r\nConnection: close\r\n\r\n"
)
.expect("write oversized response headers");
});
let error = resolve_canvas_resource_download_with_limit(
&reqwest::Client::new(),
&base_url,
"test-api-key",
&serde_json::json!({"objectKey": "stable/slice.png"}),
8,
)
.await
.err()
.expect("content length over remaining budget must fail before buffering");
server.join().expect("join bounded fixture");
assert!(error.contains("下载预算"));
}
}
@@ -1,6 +1,7 @@
use std::time::Duration;
use chromiumoxide::Page;
use serde::Deserialize;
use tokio::time::Instant;
use super::{
@@ -20,6 +21,124 @@ pub(in crate::browser) const GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP: Duration =
pub(in crate::browser) const GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES: usize = 8;
pub(in crate::browser) const GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES: usize = 12;
pub(in crate::browser) const GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES: usize = 12;
pub(in crate::browser) const GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT: &str =
"primary-action=synchronous-click-sequence-advance\nrestart=synchronous-click-sequence-advance";
const GENERIC_ACTION_SEQUENCE_PROBE_KEY: &str = "__genarrativeGenericActionSequenceProbe";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct GenericActionSequenceProbe {
status: String,
before_sequence: Option<u64>,
after_sequence: Option<u64>,
}
fn generic_action_sequence_probe_script(selector: &str) -> Result<String, String> {
let selector = serde_json::to_string(selector)
.map_err(|_| "固定试玩动作因果探针 selector 无法编码".to_string())?;
let key = serde_json::to_string(GENERIC_ACTION_SEQUENCE_PROBE_KEY)
.map_err(|_| "固定试玩动作因果探针 key 无法编码".to_string())?;
Ok(format!(
r#"(() => {{
const key = {key};
const controls = document.querySelectorAll({selector});
if (controls.length !== 1 || !(controls[0] instanceof HTMLElement)) {{
globalThis[key] = {{ status: 'invalid-control', beforeSequence: null, afterSequence: null }};
return 'invalid-control';
}}
const control = controls[0];
const readSequence = () => {{
const surface = document.querySelectorAll('script#playable-web-game-state');
if (surface.length !== 1 || surface[0].getAttribute('type') !== 'application/json') return null;
try {{
const value = JSON.parse(String(surface[0].textContent || ''));
return Number.isSafeInteger(value.sequence) && value.sequence >= 0 ? value.sequence : null;
}} catch (_) {{
return null;
}}
}};
globalThis[key] = {{ status: 'armed', beforeSequence: null, afterSequence: null }};
control.addEventListener('click', () => {{
const beforeSequence = readSequence();
globalThis[key] = {{ status: 'captured', beforeSequence, afterSequence: null }};
queueMicrotask(() => {{
const afterSequence = readSequence();
globalThis[key] = {{ status: 'completed', beforeSequence, afterSequence }};
}});
}}, {{ capture: true, once: true }});
return 'armed';
}})()"#
))
}
async fn arm_generic_action_sequence_probe(
page: &Page,
selector: &'static str,
action: &'static str,
deadline: Instant,
) -> Result<(), String> {
let remaining = deadline
.checked_duration_since(Instant::now())
.ok_or_else(|| format!("固定试玩动作 {action} 已超过总时限"))?;
let script = generic_action_sequence_probe_script(selector)?;
let evaluated = tokio::time::timeout(remaining, page.evaluate(script))
.await
.map_err(|_| format!("固定试玩动作 {action} 因果探针安装超时"))?
.map_err(|_| format!("固定试玩动作 {action} 因果探针安装失败"))?;
let status = evaluated
.into_value::<String>()
.map_err(|_| format!("固定试玩动作 {action} 因果探针安装结果无效"))?;
if status != "armed" {
return Err(format!("固定试玩动作 {action} 因果探针无法绑定唯一控件"));
}
Ok(())
}
fn validate_generic_action_sequence_probe(
action: &str,
probe: &GenericActionSequenceProbe,
) -> Result<(), String> {
if probe.status != "completed" {
return Err(format!(
"generic-v1 {action} 未形成同步动作因果证据:status={}",
probe.status
));
}
let before = probe
.before_sequence
.ok_or_else(|| format!("generic-v1 {action} 点击前 sequence 无效"))?;
let after = probe
.after_sequence
.ok_or_else(|| format!("generic-v1 {action} 点击后 sequence 无效"))?;
if after <= before {
return Err(format!(
"generic-v1 {action} 自身未推进 sequencebefore={before}, after={after};不得用 RAF/timer 自增冒充动作结果"
));
}
Ok(())
}
async fn verify_generic_action_sequence_probe(
page: &Page,
action: &'static str,
deadline: Instant,
) -> Result<(), String> {
let remaining = deadline
.checked_duration_since(Instant::now())
.ok_or_else(|| format!("固定试玩动作 {action} 已超过总时限"))?;
let key = serde_json::to_string(GENERIC_ACTION_SEQUENCE_PROBE_KEY)
.map_err(|_| "固定试玩动作因果探针 key 无法编码".to_string())?;
let script = format!("globalThis[{key}] || null");
let evaluated = tokio::time::timeout(remaining, page.evaluate(script))
.await
.map_err(|_| format!("固定试玩动作 {action} 因果证据读取超时"))?
.map_err(|_| format!("固定试玩动作 {action} 因果证据读取失败"))?;
let probe = evaluated
.into_value::<GenericActionSequenceProbe>()
.map_err(|_| format!("固定试玩动作 {action} 因果证据无效"))?;
validate_generic_action_sequence_probe(action, &probe)
}
pub(in crate::browser) fn generic_start_phase_is_valid(state: &PlayableWebGameState) -> bool {
state.phase == BrowserPlaytestPhase::Playing
@@ -332,6 +451,13 @@ async fn execute_generic_primary_action_attempt(
generic_start_phase_is_valid,
)?;
arm_generic_action_sequence_probe(
page,
PLAYTEST_PRIMARY_ACTION_SELECTOR,
"primary-action",
deadline,
)
.await?;
click_playtest_control(
page,
PLAYTEST_PRIMARY_ACTION_SELECTOR,
@@ -339,6 +465,7 @@ async fn execute_generic_primary_action_attempt(
deadline,
)
.await?;
verify_generic_action_sequence_probe(page, "primary-action", deadline).await?;
if record_contract_assertions {
result.set_assertion("primary-action-control-clicked", true);
}
@@ -454,7 +581,9 @@ pub(super) async fn execute_generic_playtest(
)
.await?;
arm_generic_action_sequence_probe(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?;
click_playtest_control(page, PLAYTEST_RESTART_SELECTOR, "restart", deadline).await?;
verify_generic_action_sequence_probe(page, "restart", deadline).await?;
result.set_assertion("restart-control-clicked", true);
let restarted = poll_playable_web_game_state(
page,
@@ -557,3 +686,28 @@ pub(super) async fn execute_generic_playtest(
result.set_assertion("non-loss-progression-observed", true);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn action_sequence_probe_rejects_timer_only_progress() {
let timer_only = GenericActionSequenceProbe {
status: "completed".to_string(),
before_sequence: Some(9),
after_sequence: Some(9),
};
let error = validate_generic_action_sequence_probe("primary-action", &timer_only)
.expect_err("a later timer tick must not count as click-driven sequence progress");
assert!(error.contains("RAF/timer"));
let click_driven = GenericActionSequenceProbe {
status: "completed".to_string(),
before_sequence: Some(9),
after_sequence: Some(10),
};
validate_generic_action_sequence_probe("restart", &click_driven)
.expect("a synchronous click-driven increment is valid");
}
}
@@ -17,9 +17,9 @@ pub(super) use generic::{
finish_generic_stability_observation, generic_non_loss_progression_phase_is_valid,
generic_primary_action_phase_is_valid, generic_restart_phase_is_valid,
generic_start_phase_is_valid, validate_generic_stability_sample,
GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP, GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_POST_ACTION_WINDOW, GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW,
GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT, GENERIC_PLAYTEST_MAX_FINAL_SAMPLE_GAP,
GENERIC_PLAYTEST_POST_ACTION_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_POST_ACTION_WINDOW,
GENERIC_PLAYTEST_RESTART_MIN_STABILITY_SAMPLES, GENERIC_PLAYTEST_RESTART_STABILITY_WINDOW,
GENERIC_PLAYTEST_START_OPPORTUNITY_MIN_STABILITY_SAMPLES,
GENERIC_PLAYTEST_START_OPPORTUNITY_WINDOW,
};
@@ -270,6 +270,10 @@ pub(crate) fn browser_playtest_scenario_fingerprint(scenario: BrowserPlaytestSce
update_playtest_fingerprint_component(&mut hasher, PROBE_PLAYTEST_CONTROL_SCRIPT);
match scenario {
BrowserPlaytestScenario::GenericV1 => {
update_playtest_fingerprint_component(
&mut hasher,
GENERIC_PLAYTEST_ACTION_CAUSALITY_FINGERPRINT,
);
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_START_SELECTOR);
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_PRIMARY_ACTION_SELECTOR);
update_playtest_fingerprint_component(&mut hasher, PLAYTEST_RESTART_SELECTOR);
@@ -258,7 +258,7 @@ fn playtest_scenario_fingerprints_are_fixed_lowercase_sha256_values() {
assert_eq!(
generic,
"c3af84c2501755935eb30c1eed91bc6ef2a4cb2057224f4d3a4a100aef1a2908"
"8b7b6525e6fdd2517ad25da1a06644b96e57e744fcacdd70bbab35b09d64adb0"
);
assert_eq!(
lane,
@@ -1329,10 +1329,14 @@ export function projectSupervisorCollaboratingAgentRuntimes(
}
const runtimesByAgentId = new Map<string, AgentRuntimeState>();
for (const runtime of Object.values(runtimeByAgentId)) {
const isVisibleChildSource =
['agent-delegate', 'agent-delegate-retry'].includes(runtime?.source ?? '') ||
(supervisorRuntime.source === 'project-supervisor-game-chat' &&
runtime?.source === 'agent-ready-task-scheduler');
if (
!runtime ||
runtime.agentId === PROJECT_SUPERVISOR_AGENT_ID ||
!['agent-delegate', 'agent-delegate-retry'].includes(runtime.source) ||
!isVisibleChildSource ||
runtime.parentAgentId !== PROJECT_SUPERVISOR_AGENT_ID ||
runtime.parentRunId !== supervisorRuntime.runId
) {
@@ -2665,6 +2665,7 @@ export function registerProjectSupervisorSurfaceTests() {
});
const supervisor = gameChatRuntimeState({
runId: 'active-parent-run',
source: 'project-supervisor-game-chat',
recentEvents: [
gameChatRuntimeEvent({
runId: 'active-parent-run',
@@ -5924,7 +5924,7 @@
- 事故事实:`game01` 的首个根 run 已交付并验证水晶下落方块原型,但父 run 在上下文压缩后因 token 上限失败;用户随后输入“继续”时,客户端创建了 task 仅为“继续”的新 game-chat root,完成合同重置 seed manifest,五分钟 fallback 再把该短语当主题整文件覆盖 `game/index.html`。这不是模型随机换题,而是 root 目标未继承、每新 run 无条件 reset 和 fallback 无条件 `file.write` 叠加形成的确定性缺陷。
- 续跑决策:同一 Supervisor Session、同一持久 source 的最近失败根 run 后,严格继续短语创建 successor root,并继承前序原始任务、baseline revision / artifact 身份;纯继续识别统一由一个精确函数负责,覆盖“继续 / 接着 / continue / go on”等无新约束短语。真正新需求、跨 Session、跨 GUI / CLI / game-chat source、前序正常完成或含具体新约束的输入继续创建独立新任务并重置本轮 manifest。successor 不复用旧网络请求、pending、action、Provider lifecycle 或 sidecar,只继承业务目标和已提交项目事实。
- 覆盖决策:game-chat fallback 只允许初始化缺失/占位入口的首次落盘。非占位 `game/index.html` 必须保留,并由当前 `code-prototype` 先读取和实际 patch,取得本人 `mutationRevision` 后才能运行 `game.static_smoke` 与交付;只读 smoke 不得冒充续作。确定性 fallback 只允许已实现真实语义的显式玩法模板:俄罗斯方块模板必须具备 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败,收集模板只用于明确收集类目标,未知玩法失败关闭。纯继续目标未恢复时同样失败关闭。完成门新增 baseline 玩法连续性和 action-driven state 检查,generic Canvas 非空、三个按钮存在或静态 smoke 通过都不能单独证明任务没有换题。
- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四,全部切片累计下载最多 `32 MiB`;主图、四张 canonical 切片与切片清单作为一个提交合同,主图安装或资产登记失败时必须恢复整组旧合同`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。
- 美术决策:`art-spec.png` 回归为规范图和下游派生 reference,不能铺作完整场景,也不能裁剪成玩家/目标。首版必须继续由 `art-asset-plan` 通过 icon-spritesheet 生成透明 `art-spritesheet.png`;桌面 Runtime 同时下载服务端 `iconImageSrcs`,按当前图集 resourceId 写入本地切片清单。game-chat 在任何本地落盘前要求稳定、非空的图集 resourceId,并在正式图集登记前要求切片严格等于四、每片 `sourceResourceId` 精确绑定整图,全部切片累计下载最多 `32 MiB`;四类差异按尺寸加规范 RGBA 像素摘要判定,PNG 编码字节不同不代表视觉内容不同。主图、四张 canonical 切片与切片清单作为一个提交合同,主图安装或资产登记失败时必须恢复整组旧合同;generation 账本恢复允许对摘要一致的已落盘主图幂等补齐合同,摘要冲突不得覆盖。完成门重新读取切片时继续有界解码并复核清单内容摘要、规范像素摘要、可见 alpha 和四类唯一性`code-prototype` 在活动 Canvas 中分别绘制玩家、方块/目标、障碍/场景和反馈四类不同切片。纯代码核心画面、猜测 atlas 等分坐标、单个裁切冒充全部类别、整图展示与路径诱饵失败关闭;编辑器仍允许仅有 `sliceWarning` 的完整透明图集完成,但 game-chat 必须等到真实切片可用。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs``start-dev-stack.mjs``src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``response_stream.rs``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导
@@ -5948,7 +5948,7 @@
## 2026-07-31 External v1 生成统一异步并提供托管 MCP 与完整 Skill 包
- 异步契约:External v1 的图片生成、图片编辑、图标图集、UI 素材提取、角色动画、视频、音效和背景音乐八类 POST 固定持久化入 `external_generation_job` 并返回 HTTP `202 + operationId/statusUrl/pollAfterMs`;不受站内 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 影响。每次逻辑生成必须携带稳定 `Idempotency-Key`,网络结果未知或调用方轮询超时时复用原键和原 operationId,不得换键重提。
- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 仍受父 run 五分钟总截止约束,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。响应丢失、旧 `200` 结果损坏、`202` 缺 operationId、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败统一投影为不可自动重生的对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。
- 发布窗口兼容:AI 游戏创作桌面客户端严格按 HTTP 状态分流生成首响应;旧服务 `200` 只作为已经完成且含可下载媒体的同步结果消费,旧图集允许从顶层 `spritesheetImageSrc` 换签下载且无效值不得遮蔽可用 `objectKey`;新服务 `202` 必须取得 `operationId` 后轮询,轮询间隔按 OpenAPI 限制在 `250..=5000ms`,其他 2xx 失败关闭。Runtime 在 POST 前原子持久化精确请求体、请求 SHA-256 与稳定幂等键,`202` 后先原子追加 `operationId` 并回读一致再查询;重启时 `accepted` 账本只恢复 GET`prepared` 表示提交结果未知并禁止自动 POST。生成 POST 使用独立三十五分钟等待预算且不自动重提;game-chat 的两次串行生成纳入父 run `4200` 秒软预算与从 root `bound_at` 起算的 `4500` 秒绝对硬截止,但截止时若 `canvas.asset_generate` 已进入 executing,客户端与预览照常退出,Runtime 保留 pending action、provider batch、生成账本与 `needs-reconciliation`。响应丢失、旧 `200` 结果损坏、`202` 缺 operationId、轮询超时、状态损坏、透明派生失败或外部完成后的本地提交失败统一投影为不可自动重生的对账边界。非阻断 general warning 继续消费结果并与 `sliceWarning` 分别展示。权威 External v1 OpenAPI 仍只声明新异步 `202`,不把部署过渡兼容公开成正式双协议。
- 查询与结果:新增 owner-safe `GET /api/external/v1/generations/{operationId}``queued/running` 返回 phase/progress`completed` 返回 compact 稳定 artifact 引用,`failed` 返回脱敏错误,跨 owner 按不存在处理。compact result 允许 objectKey、resource/asset ID、assetObjectId、尺寸、媒体类型、taskId 和告警;禁止完整 project/canvas、Data URL、Blob URL、临时 signed URL、内部 provider 原文和 lease/fencing 控制字段。
- 客户端 durable 查询约束:私有生成账本同时绑定 base URL/API Key 配置指纹,指纹不一致不查询旧 operation。旧 `200` 兼容结果只持久恢复允许字段和安全媒体引用。operation 明确 failed 的账本保留到 pending observation 和 Provider batch 终态落盘后再清理。生成提交只有契约明确的 `400 / 401 / 403` 可判定为入队前拒绝并清理 prepared 账本;其它非成功状态一律保留账本进入对账。账本路径解析、扫描和删除逐级拒绝符号链接,非法控制路径失败关闭。
- MCP:新增托管 `/api/external/v1/mcp`,使用现有 External API Key Bearer 鉴权和无协议 session 的 Streamable HTTP JSON direct 模式。MCP tools 从同一 OpenAPI operation 形成并复用 External REST router;生成 tool 显式要求 `idempotencyKey`,另有统一任务查询 tool。MCP resources 提供使用说明、OpenAPI、Skill 入口 `SKILL.md``references/capability-routing.md``references/api-operations.md``references/authentication-and-safety.md``references/requests-and-outputs.md` 四篇稳定 reference;日后新增 reference 时必须同步新增独立 resource。MCP Agent 直接调用托管 tools,不安装 CLI,也不将脚本、测试或 workflow 暴露为 MCP resources。禁止开放内部 SpacetimeDB MCP、worker procedure、controller 或队列控制面。
@@ -87,7 +87,7 @@ game-chat 素材完整快车道采用七任务口径。父 Run 与全部 child R
失败续跑还必须覆盖同 Session 同 source 继承、跨 Session / 跨 source 不继承、首次与连续 successor 的 effective task / contract / scheduler 一致性,以及中英文纯继续短语使用同一识别函数。非占位入口的新 `code-prototype` 必须先产生本人 mutation 再 smoke;连续只读 smoke 不得收束。占位 fallback 只允许显式支持的真实玩法模板,俄罗斯方块必须验证棋盘、下落、旋转、锁定和消行语义,未知玩法必须失败关闭。
game-chat GUI 恢复还要覆盖两类竞态:root Runtime 先终态、manifest 四阶段后终态时,必须等到任务最终状态后仅持久化一条 `【Supervisor 阶段记录】`;页面初始 hydration 直接读到真实终态时也要补写缺失记录,但不得把 `idle` 当作完成。同时,GUI 启动的 `agent.resume` 自动扫描必须先做只读恢复工作预检:新项目或无 task / retry / handoff / finalization / pending / reconciliation 工作的已终态项目不弹确认,存在任何 durable recovery artifact 则仍必须命中 `agent.resume` policy。
game-chat GUI 恢复还要覆盖两类竞态:root Runtime 先终态、manifest 七任务 lane 后终态时,必须等到七个首版任务最终状态后仅持久化一条 `【Supervisor 阶段记录】`;页面初始 hydration 直接读到真实终态时也要补写缺失记录,但不得把 `idle` 当作完成。同时,GUI 启动的 `agent.resume` 自动扫描必须先做只读恢复工作预检:新项目或无 task / retry / handoff / finalization / pending / reconciliation 工作的已终态项目不弹确认,存在任何 durable recovery artifact 则仍必须命中 `agent.resume` policy。
```bash
npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run
@@ -64,13 +64,13 @@
- 时间预算:从 game-chat 父 Run 接受用户请求开始,素材完整首版使用 `4200` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享从 root `bound_at` 计算的 `4500` 秒绝对硬上限。该值来自现有两次串行生成各自最长 35 分钟的客户端等待合同,并为代码兜底、静态检查和双视口试玩保留 5 分钟。整个 Runtime pass 受同一 `timeout_at` 约束;硬上限内未通过完成门必须失败关闭,不得为了守时跳过图集、换普通生图或回退纯代码核心画面。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`
- Provider 次数:首波 `design-director / code-director` 的规划请求与 `art-director` 的确定性规范图生成并行;规范图和设计方向就绪后,Runtime 确定性派发 `art-asset-plan` 的 icon-spritesheet 生成。图集登记后 `code-prototype` 最多执行一次 Provider 首版写入请求;软预算耗尽时只允许生成真实加载并裁切图集的确定性本地兜底。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke``preview.validate`
- 可玩兜底:软预算或首版 Provider 无法及时完成时,只能为已显式实现真实语义的玩法生成完整、自包含、无远程运行依赖的中文 HTML 模板;未知玩法失败关闭,不能只替换标题后套用固定收集游戏。俄罗斯方块模板必须包含 10×20 棋盘、下落、移动、旋转、锁定、消行和触顶失败;收集模板只匹配明确收集类目标。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 只随真实输入、状态迁移或模拟状态变化推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,不得通过固定失败冒充试玩通过,也不得由纯渲染帧空转 `sequence`。兜底只允许写入缺失或精确初始化占位的 `game/index.html`;存在非占位入口时,当前 `code-prototype` 必须读取并实际 patch,取得本人 `mutationRevision` 后再静态检查和试玩,不得反复用只读 smoke 冒充续作。
- 平台图片:`art-spec.png` 只用于约束色板、材质、形状与后续派生,不是运行时背景、角色、目标或图集。`art-asset-plan` 必须以其稳定资源 ID 走专用 icon-spritesheet 路由,透明像素、generation route/kind、同画布归属和 reference resource ID 继续由 Runtime 验证。完整透明图集在编辑器合同中即使产生 `sliceWarning` 仍可登记,但 game-chat 不能据此猜测 atlas 坐标;图集 resourceId 缺失或空白时必须在任何本地写入前失败,切片数量也必须在正式图集登记前严格等于四,多或少都失败关闭。主图、四张 canonical 切片和切片清单必须共同提交,后续主图安装或登记失败时恢复旧切片合同,避免出现旧图集绑定新切片。全部切片下载累计最多 `32 MiB``postprocess-failed-source-preserved`、无真实 alpha、缺文件或缺登记同样失败关闭。首版只在 `preview-playtest` 后单轮收束,不进入发布节点。
- 平台图片:`art-spec.png` 只用于约束色板、材质、形状与后续派生,不是运行时背景、角色、目标或图集。`art-asset-plan` 必须以其稳定资源 ID 走专用 icon-spritesheet 路由,透明像素、generation route/kind、同画布归属和 reference resource ID 继续由 Runtime 验证。完整透明图集在编辑器合同中即使产生 `sliceWarning` 仍可登记,但 game-chat 不能据此猜测 atlas 坐标;图集 resourceId 缺失或空白时必须在任何本地写入前失败,四个切片均须保留与整图完全一致的 `sourceResourceId`,切片数量也必须在正式图集登记前严格等于四,多或少都失败关闭。四类唯一性按尺寸与解码后的规范 RGBA 像素摘要判断,不能用不同 PNG 压缩或 ancillary chunk 冒充不同素材。主图、四张 canonical 切片和切片清单必须共同提交,后续主图安装或登记失败时恢复旧切片合同,避免出现旧图集绑定新切片;进程在固定主图落盘后崩溃时,保留的 generation 账本只允许按远端预期摘要幂等补齐同一组合同,路径内容冲突继续失败关闭。全部切片下载累计最多 `32 MiB`。完成门重新读取本地素材时仍执行相同文件大小、解码内存、内容摘要、规范像素摘要、可见 alpha 与四类唯一性校验`postprocess-failed-source-preserved`、无真实 alpha、缺文件或缺登记同样失败关闭。首版只在 `preview-playtest` 后单轮收束,不进入发布节点。
- 关联验收:快车道必须分别验证三 Director 首波并行、`art-asset-plan` 在代码前完成、`x/7` 投影、七个专业 Agent 安全 final-reply、4200 / 4500 秒累计预算、规范图到 icon-spritesheet 的真实引用、`iconImageSrcs` 本地持久化与资源 ID 绑定、失败续跑目标继承、非占位入口禁止整文件覆盖、纯代码核心画面、猜测单个 atlas 裁切与整图展示失败、四类独立切片可见使用通过、action-driven `sequence`,以及当前 revision 的静态 smoke 与浏览器试玩。
## 2026-08-03 game-chat 开发态同源与持久输出修复
- 开发态启动必须在 Tauri CLI 之前预检固定 `3080`。现有 marker 只包含 API target,不能证明监听器属于当前 worktree;因此只有端口空闲时才允许继续,任何已存在的 AGC Vite、非 HTTP 监听器或其它服务都必须在原生窗口创建前失败关闭。启动器不擅自终止无法证明归属的旧服务,也不得把当前 Rust 壳 / Runner 与其它 worktree 的旧 Vite 前端混用。Tauri CLI 任意退出后,外层启动器必须有界收束已启动的客户端进程树,避免 `beforeDevCommand` 失败后留下假在线窗口。
- game-chat root binding 的 `source` 必须精确为 `project-supervisor-game-chat`。只有该持久 source 才能选择首波并行 `design-director + art-director + code-director`,随后 `code-prototype → preview-readiness → preview-playtest` 的六任务 lane、平台 `art-spec.png` 美术门、单轮确定性收束和自动预览;若绑定为 `project-supervisor-gui`,必须视为启动链路错误,不能用完整 16 节点 DAG 的运行状态伪装 game-chat 进度。
- game-chat root binding 的 `source` 必须精确为 `project-supervisor-game-chat`。只有该持久 source 才能选择首波并行 `design-director + art-director + code-director`,随后 `art-asset-plan → code-prototype → preview-readiness → preview-playtest` 推进七任务 lane、规范图派生图集美术门、单轮确定性收束和自动预览;若绑定为 `project-supervisor-gui`,必须视为启动链路错误,不能用完整 16 节点 DAG 的运行状态伪装 game-chat 进度。
- source-aware lane 的首波 ready child 可能在 UI hydration 写回时短暂恢复为 `Pending`。该例外必须从当前 root source 的种子 lane 解析全部零依赖任务,不得硬编码某个 Agent;当前 game-chat 首波是 `design-director / art-director / code-director`,后续 code prototype / preview child 仍严格拒绝 `Pending` 收束。
- 专业 Agent 的非流式 final reply 继续由既有 finalization journal 重建并提交 `responseStream``streaming / ready` 投影仍必须匹配当前项目 revision;已经 finalization 提交的 `committed` 回复以 Agent / Session / run / request slot / response revision 稳定身份为准,不得因后续阶段推进项目 revision 而从 game-chat 查询中消失。
@@ -1276,8 +1276,12 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
let Some(resource) = object.get_mut(field).and_then(Value::as_object_mut) else {
continue;
};
resource
.retain(|key, _| matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId"));
resource.retain(|key, _| {
matches!(
key.as_str(),
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
)
});
}
if let Some(icon_image_srcs) = object
.get_mut("iconImageSrcs")
@@ -1289,7 +1293,10 @@ fn compact_editor_generation_result(mut result: Value) -> Value {
};
if let Some(resource) = icon.get_mut("resource").and_then(Value::as_object_mut) {
resource.retain(|key, _| {
matches!(key.as_str(), "resourceId" | "objectKey" | "assetObjectId")
matches!(
key.as_str(),
"resourceId" | "objectKey" | "assetObjectId" | "sourceResourceId"
)
});
}
icon.retain(|key, _| {
@@ -1417,6 +1424,7 @@ fn compact_external_generation_resource(resource: &mut serde_json::Map<String, V
| "sourceType"
| "assetKind"
| "taskId"
| "sourceResourceId"
)
});
remove_unstable_external_generation_media_fields(resource);
@@ -2088,6 +2096,7 @@ mod tests {
"resourceId": "icon-resource-1",
"objectKey": "users/user-1/backpack.png",
"assetObjectId": "icon-object-1",
"sourceResourceId": "sheet-resource",
"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST",
},
"asset": { "assetId": "icon-asset-1" },
@@ -2101,6 +2110,7 @@ mod tests {
"resourceId": "icon-resource-2",
"objectKey": "users/user-1/map.png",
"assetObjectId": "icon-object-2",
"sourceResourceId": "sheet-resource",
},
},
],
@@ -2122,6 +2132,7 @@ mod tests {
"resourceId": "icon-resource-1",
"objectKey": "users/user-1/backpack.png",
"assetObjectId": "icon-object-1",
"sourceResourceId": "sheet-resource",
})
);
assert!(
@@ -2263,6 +2274,7 @@ mod tests {
"projectId": "project-1",
"objectKey": "users/user-1/generated/main.png",
"assetObjectId": "asset-object-main",
"sourceResourceId": "source-resource-main",
"imageSrc": "https://cdn.example.test/main.png?signature=secret",
"width": 1024,
"height": 1024,
@@ -2318,6 +2330,10 @@ mod tests {
);
assert_eq!(result["assetObjectId"], json!("asset-object-main"));
assert_eq!(result["resource"]["resourceId"], json!("resource-main"));
assert_eq!(
result["resource"]["sourceResourceId"],
json!("source-resource-main")
);
assert_eq!(
result["resource"]["objectKey"],
json!("users/user-1/generated/main.png")