强化game-chat五分钟首版与平台美术硬门
将game-chat首版固定为美术、代码、静态检查和试玩四阶段并单轮收束。 由平台生成并登记art-spec图片,强制用于主要背景、玩家和目标的可达Canvas绘制。 为全部在途动作增加从根Run计时的300秒绝对截止和确定性失败清理。 修复代码revision归属判断,防止美术变更跳过唯一Provider写入。 将四阶段公开输出和final-reply逐条固化到聊天并更新进度投影。 补齐图片解码、路径、隐藏与不可达绕过、超时竞态及前端回归测试和文档。
This commit is contained in:
+8
-3
@@ -2,7 +2,7 @@ use super::*;
|
||||
|
||||
const AGENT_RUNTIME_COMPLETION_BLOCKER_TOOL_PLAN_PROTOCOL: &str = "通用完成阻断规则:如果最新 observation 的 tool 为 runtime.autonomous_completion 且 status 为 blocked,本轮禁止直接调用 respond_to_user,也禁止在 legacy response 中填写最终回复;必须先读取该 observation.detail 的 nextRequiredAction,并据此调用合适的读取、修复和验证工具。只有完成要求的动作、取得后续可信 observation 且完成门禁不再阻断后,才能给最终回复;不得反复提交 final response,也不得按项目正文硬编码某一种 blocker 的处理方式。";
|
||||
|
||||
const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。当前任务的第一目标是在一次 Provider planning 内产出首个完整可玩版本:如果最新 observation 尚未显示 game/index.html 已由本 run 写入,本响应必须直接调用一次 file.write,把完整、自包含、可运行的 game/index.html 一次写完;禁止先调用读取、搜索、任务查询、委派、只更新计划或提交半成品。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。HTML 应直接包含 ../assets/art-spritesheet.png 的可选图片引用,并在图片不可用时使用 Canvas 绘制兜底,不能让缺图导致白屏。一次写入后不要继续扩写功能;Runtime 会在下一步自动执行静态自检并在通过后立即试玩。";
|
||||
const GAME_CHAT_CODE_PROTOTYPE_FAST_PATH_PROMPT: &str = "game-chat 首版使用五分钟快车道。当前任务的第一目标是在一次 Provider planning 内产出首个完整可玩版本:如果最新 observation 尚未显示 game/index.html 已由本 run 写入,本响应必须直接调用一次 file.write,把完整、自包含、可运行的 game/index.html 一次写完;禁止先调用读取、搜索、任务查询、委派、只更新计划或提交半成品。HTML 必须满足下方固定试玩合同,包含真实 Canvas 游戏循环、键盘与触控输入、开始、主要操作、重开、胜负状态和移动端布局;可以采用保守的原创玩法默认值。已登记的平台视觉规范图 ../assets/art-spec.png 是首版必需资源,必须在主要游戏画面中显著可见使用:至少把规范图实际绘制为主要背景,并从规范图中绘制玩家角色和目标实体。禁止仅放置隐藏 img、透明或屏外元素、微小水印、不可见预加载或只在源码中引用;也禁止用纯几何图形冒充平台图片使用。若规范图无法加载,游戏必须明确失败关闭,不能退回纯 Canvas 几何兜底。一次写入后不要继续扩写功能;Runtime 会在下一步自动执行静态自检并在通过后立即试玩。";
|
||||
|
||||
fn game_chat_fast_path_prompt_for_root_source(
|
||||
agent_id: &str,
|
||||
@@ -518,8 +518,13 @@ mod tests {
|
||||
assert!(prompt.contains("一次 Provider planning"));
|
||||
assert!(prompt.contains("直接调用一次 file.write"));
|
||||
assert!(prompt.contains("禁止先调用读取、搜索、任务查询、委派"));
|
||||
assert!(prompt.contains("../assets/art-spritesheet.png"));
|
||||
assert!(prompt.contains("Canvas 绘制兜底"));
|
||||
assert!(prompt.contains("../assets/art-spec.png"));
|
||||
assert!(prompt.contains("平台视觉规范图"));
|
||||
assert!(prompt.contains("主要背景"));
|
||||
assert!(prompt.contains("玩家角色和目标实体"));
|
||||
assert!(prompt.contains("禁止仅放置隐藏 img"));
|
||||
assert!(prompt.contains("不能退回纯 Canvas 几何兜底"));
|
||||
assert!(!prompt.contains("art-spritesheet.png"));
|
||||
assert!(game_chat_fast_path_prompt_for_root_source(
|
||||
"quality-review",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
|
||||
@@ -211,6 +211,8 @@ mod interaction;
|
||||
mod lifecycle_control;
|
||||
mod main_loop;
|
||||
#[cfg(test)]
|
||||
mod main_loop_deadline_tests;
|
||||
#[cfg(test)]
|
||||
mod main_loop_tests;
|
||||
mod pending_execution;
|
||||
mod pending_recovery;
|
||||
|
||||
+188
-118
@@ -89,46 +89,54 @@ fn game_chat_fast_path_action(tool: &str, input: serde_json::Value) -> AgentRunt
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn game_chat_fast_path_fallback_write_plan(task: &str) -> AgentRuntimeToolPlan {
|
||||
game_chat_fast_path_action(
|
||||
fn game_chat_fast_path_fallback_write_plan_for_root(
|
||||
root: &Path,
|
||||
task: &str,
|
||||
) -> Result<AgentRuntimeToolPlan, String> {
|
||||
if !game_chat_fast_path_has_platform_art_asset(root) {
|
||||
return Err(
|
||||
"game-chat 首版缺少已登记且可验证的平台视觉规范图,拒绝退回纯几何 Canvas".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(game_chat_fast_path_action(
|
||||
"file.write",
|
||||
serde_json::json!({
|
||||
"path": AGENT_RUNTIME_GAME_INDEX_PATH,
|
||||
"content": render_game_chat_fast_path_html(task),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn game_chat_fast_path_fallback_write_plan_for_root(
|
||||
root: &Path,
|
||||
task: &str,
|
||||
) -> AgentRuntimeToolPlan {
|
||||
let has_platform_art = game_chat_fast_path_has_platform_art_asset(root);
|
||||
game_chat_fast_path_action(
|
||||
"file.write",
|
||||
serde_json::json!({
|
||||
"path": AGENT_RUNTIME_GAME_INDEX_PATH,
|
||||
"content": render_game_chat_fast_path_html_with_platform_art(task, has_platform_art),
|
||||
}),
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn game_chat_fast_path_has_platform_art_asset(root: &Path) -> bool {
|
||||
let Ok(manifest) = read_manifest_for_project(root) else {
|
||||
return false;
|
||||
};
|
||||
manifest.assets.iter().any(|asset| {
|
||||
asset.kind == "art-spritesheet"
|
||||
&& asset.local_path == "assets/art-spritesheet.png"
|
||||
&& root.join("assets/art-spritesheet.png").is_file()
|
||||
validate_manifest_required_visual_asset(root, &manifest, "art-director").is_ok()
|
||||
}
|
||||
|
||||
fn game_chat_fast_path_has_visual_asset(root: &Path, task_id: &str) -> bool {
|
||||
read_manifest_for_project(root).is_ok_and(|manifest| {
|
||||
validate_manifest_required_visual_asset(root, &manifest, task_id).is_ok()
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at(
|
||||
fn game_chat_fast_path_canvas_generation_failed(runtime: &AgentRuntimeState) -> bool {
|
||||
runtime.observations.iter().rev().any(|observation| {
|
||||
[
|
||||
"canvas.asset_generate:failed",
|
||||
"canvas.asset_generate:blocked",
|
||||
"canvas.asset_generate:rejected",
|
||||
"canvas.asset_generate:needs-reconciliation",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| observation.starts_with(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
fn game_chat_fast_path_root_task(
|
||||
root: &Path,
|
||||
budget: &GameChatFastPathBudget,
|
||||
_fallback_task: &str,
|
||||
) -> Result<AgentRuntimeToolPlan, String> {
|
||||
) -> Result<String, String> {
|
||||
let root_task = read_latest_game_creator_agent_runtime_task_by_run_id(
|
||||
root,
|
||||
&budget.root_agent_id,
|
||||
@@ -139,9 +147,45 @@ pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at(
|
||||
if root_task.trim().is_empty() {
|
||||
return Err("game-chat 首版快车道 root 任务为空".to_string());
|
||||
}
|
||||
Ok(game_chat_fast_path_fallback_write_plan_for_root(
|
||||
root, &root_task,
|
||||
))
|
||||
Ok(root_task)
|
||||
}
|
||||
|
||||
fn game_chat_fast_path_canvas_asset_plan(task_id: &str, root_task: &str) -> AgentRuntimeToolPlan {
|
||||
let theme = safe_theme_summary(root_task);
|
||||
let (prompt, output_path, aspect_ratio, image_size, asset_kind, asset_label) = match task_id {
|
||||
"art-director" => (
|
||||
format!(
|
||||
"为原创小游戏“{theme}”生成统一视觉规范图:清晰展示玩家主体、目标物、场景地块、障碍、UI 图标、状态反馈、统一色板和材质规则;同一张图必须可直接作为首版主要背景、玩家和目标的可见绘制来源,不得使用现有知名游戏角色或标识。"
|
||||
),
|
||||
AGENT_RUNTIME_ART_SPEC_PATH,
|
||||
"1:1",
|
||||
"1K",
|
||||
"icon-spec",
|
||||
"游戏统一视觉规范图",
|
||||
),
|
||||
_ => unreachable!("only deterministic game-chat art tasks use this helper"),
|
||||
};
|
||||
game_chat_fast_path_action(
|
||||
"canvas.asset_generate",
|
||||
serde_json::json!({
|
||||
"prompt": prompt,
|
||||
"outputPath": output_path,
|
||||
"aspectRatio": aspect_ratio,
|
||||
"imageSize": image_size,
|
||||
"assetKind": asset_kind,
|
||||
"assetLabel": asset_label,
|
||||
"replaceExisting": false,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn game_chat_fast_path_fallback_write_plan_for_budget_at(
|
||||
root: &Path,
|
||||
budget: &GameChatFastPathBudget,
|
||||
_fallback_task: &str,
|
||||
) -> Result<AgentRuntimeToolPlan, String> {
|
||||
let root_task = game_chat_fast_path_root_task(root, budget)?;
|
||||
game_chat_fast_path_fallback_write_plan_for_root(root, &root_task)
|
||||
}
|
||||
|
||||
fn game_chat_fast_path_verified_delivery_plan(
|
||||
@@ -201,6 +245,31 @@ pub(crate) fn game_chat_fast_path_plan_at(
|
||||
return Ok(None);
|
||||
};
|
||||
match runtime.agent_id.as_str() {
|
||||
"art-director" => {
|
||||
if game_chat_fast_path_has_visual_asset(root, "art-director") {
|
||||
return Ok(Some(game_chat_fast_path_verified_delivery_plan(
|
||||
runtime,
|
||||
"统一视觉规范图已生成并登记。",
|
||||
)));
|
||||
}
|
||||
if !editor_api_key_is_configured() {
|
||||
return Err(
|
||||
"game-chat 首版必须配置 External Editor API Key 才能生成平台美术资源"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if game_chat_fast_path_canvas_generation_failed(runtime) {
|
||||
return Err(
|
||||
"game-chat 统一视觉规范图生成失败,拒绝跳过美术阶段或退回纯几何首版"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let root_task = game_chat_fast_path_root_task(root, &budget)?;
|
||||
Ok(Some(game_chat_fast_path_canvas_asset_plan(
|
||||
"art-director",
|
||||
&root_task,
|
||||
)))
|
||||
}
|
||||
"preview-readiness" => {
|
||||
if game_chat_fast_path_current_revision_is_verified(root, runtime)? {
|
||||
Ok(Some(game_chat_fast_path_verified_delivery_plan(
|
||||
@@ -240,12 +309,12 @@ pub(crate) fn game_chat_fast_path_plan_at(
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
)?;
|
||||
let current_revision_changed = revision.revision > budget.baseline_revision;
|
||||
let current_revision_verified = current_revision_changed
|
||||
let owns_current_mutation = gate.mutation_revision == Some(revision.revision);
|
||||
let current_revision_verified = owns_current_mutation
|
||||
&& gate.verified_revision == Some(revision.revision)
|
||||
&& gate.last_verification_status.as_deref()
|
||||
== Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED);
|
||||
let current_revision_failed = current_revision_changed
|
||||
let current_revision_failed = owns_current_mutation
|
||||
&& gate.last_verification_status.as_deref()
|
||||
== Some(AGENT_RUNTIME_VERIFICATION_STATUS_FAILED);
|
||||
|
||||
@@ -255,7 +324,7 @@ pub(crate) fn game_chat_fast_path_plan_at(
|
||||
"首个可玩版本代码已生成并通过静态自检。",
|
||||
)));
|
||||
}
|
||||
if current_revision_changed && !current_revision_failed {
|
||||
if owns_current_mutation && !current_revision_failed {
|
||||
return Ok(Some(game_chat_fast_path_action(
|
||||
"command.run_limited",
|
||||
serde_json::json!({ "commandId": "game.static_smoke" }),
|
||||
@@ -280,19 +349,9 @@ pub(crate) fn game_chat_fast_path_plan_at(
|
||||
/// The summary is escaped before it is inserted into HTML. It is only placed in a data
|
||||
/// attribute and text nodes; it is never interpolated into JavaScript source.
|
||||
pub(crate) fn render_game_chat_fast_path_html(prompt: &str) -> String {
|
||||
render_game_chat_fast_path_html_with_platform_art(prompt, false)
|
||||
}
|
||||
|
||||
fn render_game_chat_fast_path_html_with_platform_art(
|
||||
prompt: &str,
|
||||
has_platform_art: bool,
|
||||
) -> String {
|
||||
let theme = html_escape(&safe_theme_summary(prompt));
|
||||
let platform_art = if has_platform_art {
|
||||
r#"<img id="platform-art" src="../assets/art-spritesheet.png" alt="平台美术资源" aria-hidden="true">"#
|
||||
} else {
|
||||
r#"<img id="platform-art" alt="平台美术资源" aria-hidden="true">"#
|
||||
};
|
||||
let platform_art =
|
||||
r#"<img id="platform-art" src="../assets/art-spec.png" alt="平台生成的统一视觉规范图">"#;
|
||||
FALLBACK_GAME_HTML
|
||||
.replace(FALLBACK_THEME_MARKER, &theme)
|
||||
.replace(FALLBACK_PLATFORM_ART_MARKER, platform_art)
|
||||
@@ -368,7 +427,7 @@ const FALLBACK_GAME_HTML: &str = r###"<!doctype html>
|
||||
button:hover, button:focus-visible { border-color: #9ed0ff; background: #214a76; outline: none; }
|
||||
.state-line { display: flex; justify-content: space-between; gap: 12px; padding: 12px 14px; color: #cce1fa; background: #102541; }
|
||||
.state-line strong { color: #8fe2c4; }
|
||||
#platform-art { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
#platform-art { position: absolute; z-index: 2; top: 18px; right: 18px; width: min(24%, 180px); aspect-ratio: 1; object-fit: contain; border: 2px solid #9ed0ff; border-radius: 16px; background: #07101dcc; box-shadow: 0 12px 28px #02081499; opacity: .88; pointer-events: none; }
|
||||
@media (max-width: 560px) { header { display: block; } .hud { justify-content: flex-start; margin-top: 10px; } .controls { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
@@ -409,6 +468,14 @@ const FALLBACK_GAME_HTML: &str = r###"<!doctype html>
|
||||
let playerX = 180;
|
||||
let targetX = 690;
|
||||
let pulse = 0;
|
||||
let artLoadFailed = false;
|
||||
|
||||
platformArt.addEventListener('error', () => {
|
||||
artLoadFailed = true;
|
||||
state.phase = 'lost';
|
||||
objectiveLabel.textContent = '平台美术资源加载失败,首版已停止';
|
||||
publish();
|
||||
});
|
||||
|
||||
function publish() {
|
||||
state.sequence += 1;
|
||||
@@ -426,6 +493,7 @@ const FALLBACK_GAME_HTML: &str = r###"<!doctype html>
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
if (artLoadFailed || !platformArt.complete || platformArt.naturalWidth === 0) return;
|
||||
if (state.phase === 'ready') setPhase('playing');
|
||||
canvas.focus();
|
||||
}
|
||||
@@ -474,22 +542,27 @@ const FALLBACK_GAME_HTML: &str = r###"<!doctype html>
|
||||
context.strokeStyle = '#2a5f85';
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(20, 20, canvas.width - 40, canvas.height - 132);
|
||||
context.fillStyle = '#8fe2c4';
|
||||
context.beginPath();
|
||||
context.arc(targetX, 250, 28 + Math.sin(pulse) * 4, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.fillStyle = '#ffcf66';
|
||||
context.fillRect(playerX, canvas.height - 145, 58, 53);
|
||||
const artReady = platformArt.complete && platformArt.naturalWidth > 0 && !artLoadFailed;
|
||||
if (artReady) {
|
||||
const sourceWidth = platformArt.naturalWidth;
|
||||
const sourceHeight = platformArt.naturalHeight;
|
||||
const halfWidth = Math.max(1, Math.floor(sourceWidth / 2));
|
||||
const halfHeight = Math.max(1, Math.floor(sourceHeight / 2));
|
||||
context.globalAlpha = 0.34;
|
||||
context.drawImage(platformArt, 0, 0, canvas.width, canvas.height);
|
||||
context.globalAlpha = 1;
|
||||
context.drawImage(platformArt, 0, 0, halfWidth, halfHeight, playerX - 18, canvas.height - 190, 116, 104);
|
||||
context.drawImage(platformArt, halfWidth, 0, sourceWidth - halfWidth, halfHeight, targetX - 52, 192 + Math.sin(pulse) * 5, 112, 112);
|
||||
} else if (!artLoadFailed) {
|
||||
context.fillStyle = '#eaf4ff';
|
||||
context.font = 'bold 22px system-ui, sans-serif';
|
||||
context.fillText('正在载入平台美术资源…', 38, 96);
|
||||
}
|
||||
context.fillStyle = '#eaf4ff';
|
||||
context.font = 'bold 22px system-ui, sans-serif';
|
||||
context.fillText(state.phase === 'playing' ? '继续前进' : state.phase === 'won' ? '挑战成功' : state.phase === 'lost' ? '挑战失败' : '点击开始', 38, 58);
|
||||
context.font = '16px system-ui, sans-serif';
|
||||
context.fillText(`目标能量 · ${state.score}/8`, 38, canvas.height - 42);
|
||||
if (platformArt.complete && platformArt.naturalWidth > 0) {
|
||||
context.globalAlpha = 0.18;
|
||||
context.drawImage(platformArt, canvas.width - 180, 34, 120, 120);
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
requestAnimationFrame(draw);
|
||||
publish();
|
||||
}
|
||||
@@ -506,6 +579,36 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::agent::{validate_game_html_smoke, validate_playable_game_html};
|
||||
|
||||
fn write_visual_png(path: &Path, alpha: u8) {
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([80, 160, 220, alpha]))
|
||||
.save(path)
|
||||
.expect("write valid PNG fixture");
|
||||
}
|
||||
|
||||
fn register_platform_art_spec(root: &Path) {
|
||||
write_visual_png(&root.join(AGENT_RUNTIME_ART_SPEC_PATH), u8::MAX);
|
||||
register_local_asset_at(
|
||||
root,
|
||||
AGENT_RUNTIME_ART_SPEC_PATH,
|
||||
"icon-spec",
|
||||
"image/png",
|
||||
"canvas",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("platform-art-canvas".to_string()),
|
||||
resource_id: Some("platform-art-spec-resource".to_string()),
|
||||
asset_object_id: Some("platform-art-spec-object".to_string()),
|
||||
task_id: Some("art-director".to_string()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
|
||||
generation_kind: Some("spec".to_string()),
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("register platform art spec fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgets_leave_a_soft_and_hard_window() {
|
||||
assert_eq!(GAME_CHAT_FIRST_PLAYABLE_SOFT_BUDGET_SECONDS, 240);
|
||||
@@ -532,56 +635,43 @@ mod tests {
|
||||
] {
|
||||
assert!(html.contains(marker), "missing fallback marker: {marker}");
|
||||
}
|
||||
assert!(!html.contains("../assets/art-spritesheet.png"));
|
||||
assert!(!html.contains(" src=\"../assets/art-spritesheet.png\""));
|
||||
assert!(html.contains("src=\"../assets/art-spec.png\""));
|
||||
assert!(!html.contains("art-spritesheet.png"));
|
||||
assert!(html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)"));
|
||||
assert!(html.contains("playerX - 18"));
|
||||
assert!(html.contains("targetX - 52"));
|
||||
assert!(!html.contains("opacity: 0"));
|
||||
assert!(!html.contains("width: 1px"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_html_loads_registered_platform_art_only_when_file_exists() {
|
||||
fn fallback_html_requires_registered_platform_art_and_uses_it_prominently() {
|
||||
let temporary = tempfile::tempdir().expect("temporary project");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "game-chat-platform-art", "platform art")
|
||||
.expect("initialize project");
|
||||
|
||||
let without_asset = game_chat_fast_path_fallback_write_plan_for_root(
|
||||
assert!(game_chat_fast_path_fallback_write_plan_for_root(
|
||||
&root,
|
||||
"没有美术资源时使用 Canvas fallback",
|
||||
);
|
||||
let without_html = without_asset.actions[0].input["content"]
|
||||
.as_str()
|
||||
.expect("fallback html without asset");
|
||||
assert!(!without_html.contains(" src=\"../assets/art-spritesheet.png\""));
|
||||
|
||||
fs::write(root.join("assets/art-spritesheet.png"), b"fixture")
|
||||
.expect("write platform art fixture");
|
||||
register_local_asset_at(
|
||||
&root,
|
||||
"assets/art-spritesheet.png",
|
||||
"art-spritesheet",
|
||||
"image/png",
|
||||
"canvas",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("platform-art-canvas".to_string()),
|
||||
resource_id: Some("platform-art-resource".to_string()),
|
||||
asset_object_id: Some("platform-art-object".to_string()),
|
||||
task_id: Some("art-asset-plan".to_string()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: Some("art-spritesheet".to_string()),
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
"没有美术资源时必须失败关闭",
|
||||
)
|
||||
.expect("register platform art fixture");
|
||||
.is_err());
|
||||
|
||||
register_platform_art_spec(&root);
|
||||
|
||||
let with_asset =
|
||||
game_chat_fast_path_fallback_write_plan_for_root(&root, "有美术资源时加载平台图集");
|
||||
game_chat_fast_path_fallback_write_plan_for_root(&root, "有美术资源时加载平台规范图")
|
||||
.expect("render art-backed fallback");
|
||||
let with_html = with_asset.actions[0].input["content"]
|
||||
.as_str()
|
||||
.expect("fallback html with asset");
|
||||
assert!(with_html.contains("src=\"../assets/art-spritesheet.png\""));
|
||||
assert!(with_html.contains("drawImage(platformArt"));
|
||||
assert!(with_html.contains("src=\"../assets/art-spec.png\""));
|
||||
assert!(!with_html.contains("art-spritesheet.png"));
|
||||
assert!(
|
||||
with_html.contains("context.drawImage(platformArt, 0, 0, canvas.width, canvas.height)")
|
||||
);
|
||||
assert!(with_html.contains("playerX - 18"));
|
||||
assert!(with_html.contains("targetX - 52"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -594,36 +684,15 @@ mod tests {
|
||||
"platform art missing",
|
||||
)
|
||||
.expect("initialize project");
|
||||
let asset_path = root.join("assets/art-spritesheet.png");
|
||||
fs::write(&asset_path, b"fixture").expect("write temporary asset");
|
||||
register_local_asset_at(
|
||||
&root,
|
||||
"assets/art-spritesheet.png",
|
||||
"art-spritesheet",
|
||||
"image/png",
|
||||
"canvas",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("platform-art-canvas".to_string()),
|
||||
resource_id: Some("platform-art-resource".to_string()),
|
||||
asset_object_id: None,
|
||||
task_id: Some("art-asset-plan".to_string()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: None,
|
||||
generation_kind: Some("art-spritesheet".to_string()),
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("register platform art fixture");
|
||||
register_platform_art_spec(&root);
|
||||
let asset_path = root.join(AGENT_RUNTIME_ART_SPEC_PATH);
|
||||
fs::remove_file(asset_path).expect("remove platform art fixture");
|
||||
|
||||
let plan =
|
||||
game_chat_fast_path_fallback_write_plan_for_root(&root, "缺图时仍使用 Canvas fallback");
|
||||
let html = plan.actions[0].input["content"]
|
||||
.as_str()
|
||||
.expect("fallback html");
|
||||
assert!(!html.contains(" src=\"../assets/art-spritesheet.png\""));
|
||||
assert!(game_chat_fast_path_fallback_write_plan_for_root(
|
||||
&root,
|
||||
"缺图时拒绝 Canvas fallback",
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -680,6 +749,7 @@ mod tests {
|
||||
baseline_revision: 0,
|
||||
elapsed_seconds: 240,
|
||||
};
|
||||
register_platform_art_spec(&root);
|
||||
|
||||
let plan = game_chat_fast_path_fallback_write_plan_for_budget_at(
|
||||
&root,
|
||||
|
||||
@@ -255,12 +255,222 @@ const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET: &str = "loop-budget-exhauste
|
||||
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed";
|
||||
const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed";
|
||||
|
||||
pub(super) fn game_chat_absolute_deadline_from_bound_at(
|
||||
now_instant: tokio::time::Instant,
|
||||
now_unix: u64,
|
||||
root_bound_at: u64,
|
||||
) -> tokio::time::Instant {
|
||||
let deadline_unix = root_bound_at.saturating_add(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS);
|
||||
now_instant + std::time::Duration::from_secs(deadline_unix.saturating_sub(now_unix))
|
||||
}
|
||||
|
||||
fn game_chat_absolute_deadline_at(
|
||||
root: &Path,
|
||||
runtime: &AgentRuntimeState,
|
||||
) -> Result<Option<tokio::time::Instant>, String> {
|
||||
if runtime.run_profile != AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD {
|
||||
return Ok(None);
|
||||
}
|
||||
let now_unix = unix_timestamp();
|
||||
let Some(budget) =
|
||||
game_chat_fast_path_budget_at(root, &runtime.agent_id, &runtime.run_id, now_unix)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let root_binding = read_game_creator_agent_runtime_run_profile_binding(
|
||||
root,
|
||||
&budget.root_agent_id,
|
||||
&budget.root_run_id,
|
||||
)?
|
||||
.ok_or_else(|| "game-chat 绝对硬截止缺少 root Run Profile 绑定".to_string())?;
|
||||
if root_binding.agent_id != budget.root_agent_id
|
||||
|| root_binding.run_id != budget.root_run_id
|
||||
|| root_binding.root_agent_id != root_binding.agent_id
|
||||
|| root_binding.root_run_id != root_binding.run_id
|
||||
|| root_binding.source != AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
{
|
||||
return Err("game-chat 绝对硬截止 root Run Profile 绑定身份不一致".to_string());
|
||||
}
|
||||
Ok(Some(game_chat_absolute_deadline_from_bound_at(
|
||||
tokio::time::Instant::now(),
|
||||
now_unix,
|
||||
root_binding.bound_at,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(super) async fn await_game_chat_absolute_deadline_at<F, T>(
|
||||
deadline: tokio::time::Instant,
|
||||
future: F,
|
||||
) -> Result<T, tokio::time::error::Elapsed>
|
||||
where
|
||||
F: std::future::Future<Output = T>,
|
||||
{
|
||||
tokio::time::timeout_at(deadline, future).await
|
||||
}
|
||||
|
||||
fn latest_game_chat_deadline_runtime_at(
|
||||
root: &Path,
|
||||
fallback: AgentRuntimeState,
|
||||
) -> AgentRuntimeState {
|
||||
fs::read_to_string(game_creator_agent_runtime_session_path(
|
||||
root,
|
||||
&fallback.agent_id,
|
||||
))
|
||||
.ok()
|
||||
.and_then(|content| serde_json::from_str::<AgentRuntimeState>(&content).ok())
|
||||
.filter(|runtime| {
|
||||
runtime.agent_id == fallback.agent_id
|
||||
&& runtime.session_id == fallback.session_id
|
||||
&& runtime.run_id == fallback.run_id
|
||||
})
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
pub(super) fn finish_game_chat_absolute_deadline_timeout_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
session_id: &str,
|
||||
fallback: AgentRuntimeState,
|
||||
) -> AgentBackgroundTaskOutcome {
|
||||
let runtime = latest_game_chat_deadline_runtime_at(root, fallback);
|
||||
let pending_action = read_game_creator_agent_runtime_pending_tool_action(
|
||||
root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id,
|
||||
)
|
||||
.ok();
|
||||
let error = format!(
|
||||
"{GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_ERROR_PREFIX}: root Run 自 bound_at 起已达到 {} 秒绝对硬截止;在途动作已取消并进入失败收尾",
|
||||
GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS
|
||||
);
|
||||
// The generic background failure helper deliberately preserves a durable
|
||||
// needs-reconciliation state. A hard deadline is different: no action may
|
||||
// remain recoverable after the root budget expires. Persist the terminal
|
||||
// failure first, explicitly bypassing that guard, and only then remove the
|
||||
// recovery material.
|
||||
let terminal_failure_error = fail_game_creator_agent_runtime_turn_at(
|
||||
root,
|
||||
runtime.clone(),
|
||||
&redact_agent_runtime_error(root, &error, 500),
|
||||
)
|
||||
.err();
|
||||
let terminal_runtime = latest_game_chat_deadline_runtime_at(root, runtime);
|
||||
let (_, preview_stopped) = game_creator_preview_registry().stop_for_project(Some(root));
|
||||
let process_cleanup_error = terminate_process_sessions_for_run_at(
|
||||
root,
|
||||
&terminal_runtime.agent_id,
|
||||
&terminal_runtime.run_id,
|
||||
)
|
||||
.err();
|
||||
let mut cleanup_errors = Vec::new();
|
||||
for result in [
|
||||
remove_game_creator_agent_runtime_pending_tool_action(
|
||||
root,
|
||||
&terminal_runtime.agent_id,
|
||||
&terminal_runtime.run_id,
|
||||
),
|
||||
remove_game_creator_agent_runtime_provider_action_batch(
|
||||
root,
|
||||
&terminal_runtime.agent_id,
|
||||
&terminal_runtime.run_id,
|
||||
),
|
||||
remove_game_creator_agent_runtime_confirmations(
|
||||
root,
|
||||
&terminal_runtime.agent_id,
|
||||
&terminal_runtime.run_id,
|
||||
),
|
||||
remove_game_creator_agent_runtime_provider_recovery_at(
|
||||
root,
|
||||
&terminal_runtime.agent_id,
|
||||
&terminal_runtime.run_id,
|
||||
),
|
||||
] {
|
||||
if let Err(error) = result {
|
||||
cleanup_errors.push(sanitize_agent_runtime_text(&error, 160));
|
||||
}
|
||||
}
|
||||
if let Some(error) = process_cleanup_error {
|
||||
cleanup_errors.push(sanitize_agent_runtime_text(&error, 160));
|
||||
}
|
||||
if let Some(error) = terminal_failure_error {
|
||||
cleanup_errors.push(sanitize_agent_runtime_text(&error, 160));
|
||||
}
|
||||
let _ = append_agent_db_record(
|
||||
root,
|
||||
serde_json::json!({
|
||||
"recordType": "agent.runtime.game_chat.absolute_hard_deadline",
|
||||
"agentId": terminal_runtime.agent_id,
|
||||
"taskId": terminal_runtime.task_id,
|
||||
"sessionId": terminal_runtime.session_id,
|
||||
"runId": terminal_runtime.run_id,
|
||||
"hardBudgetSeconds": GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS,
|
||||
"pendingActionId": pending_action.as_ref().map(|pending| pending.action_id.as_str()),
|
||||
"pendingTool": pending_action.as_ref().map(|pending| pending.action.tool.as_str()),
|
||||
"previewStopped": preview_stopped,
|
||||
"cleanupErrorCount": cleanup_errors.len(),
|
||||
}),
|
||||
);
|
||||
let _ = append_local_conversation_message_for_session_at(
|
||||
root,
|
||||
Some(agent_id),
|
||||
Some(session_id),
|
||||
LocalConversationMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: game_creator_agent_runtime_failure_conversation_message(agent_id, &error),
|
||||
agent_id: None,
|
||||
},
|
||||
);
|
||||
AgentBackgroundTaskOutcome::Finished
|
||||
}
|
||||
|
||||
pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_context(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
task: String,
|
||||
state: AgentRuntimeState,
|
||||
continuation: AgentRuntimeContinuationContext,
|
||||
) -> AgentBackgroundTaskOutcome {
|
||||
let session_id = state.session_id.clone();
|
||||
let deadline_runtime = state.clone();
|
||||
let deadline = match game_chat_absolute_deadline_at(&root, &state) {
|
||||
Ok(deadline) => deadline,
|
||||
Err(error) => {
|
||||
return fail_game_creator_agent_background_context_at(
|
||||
&root,
|
||||
&agent_id,
|
||||
&session_id,
|
||||
state,
|
||||
&format!("读取 game-chat 绝对硬截止失败:{error}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
let pass = run_game_creator_agent_background_task_pass_without_deadline(
|
||||
root.clone(),
|
||||
agent_id.clone(),
|
||||
task,
|
||||
state,
|
||||
continuation,
|
||||
);
|
||||
let Some(deadline) = deadline else {
|
||||
return pass.await;
|
||||
};
|
||||
match await_game_chat_absolute_deadline_at(deadline, pass).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(_) => finish_game_chat_absolute_deadline_timeout_at(
|
||||
&root,
|
||||
&agent_id,
|
||||
&session_id,
|
||||
deadline_runtime,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_game_creator_agent_background_task_pass_without_deadline(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
task: String,
|
||||
state: AgentRuntimeState,
|
||||
continuation: AgentRuntimeContinuationContext,
|
||||
) -> AgentBackgroundTaskOutcome {
|
||||
let session_id = state.session_id.clone();
|
||||
let mut runtime = state;
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
use super::main_loop::{
|
||||
await_game_chat_absolute_deadline_at, finish_game_chat_absolute_deadline_timeout_at,
|
||||
game_chat_absolute_deadline_from_bound_at,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn game_chat_absolute_deadline_is_root_bound_at_plus_hard_budget() {
|
||||
let now_instant = tokio::time::Instant::now();
|
||||
let bound_at = 10_000;
|
||||
let now_unix = bound_at + 17;
|
||||
|
||||
let deadline = game_chat_absolute_deadline_from_bound_at(now_instant, now_unix, bound_at);
|
||||
|
||||
assert_eq!(
|
||||
deadline.duration_since(now_instant),
|
||||
std::time::Duration::from_secs(GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS - 17)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_absolute_deadline_is_immediate_once_root_budget_is_exhausted() {
|
||||
let now_instant = tokio::time::Instant::now();
|
||||
let bound_at = 20_000;
|
||||
|
||||
let deadline = game_chat_absolute_deadline_from_bound_at(
|
||||
now_instant,
|
||||
bound_at + GAME_CHAT_FIRST_PLAYABLE_HARD_BUDGET_SECONDS + 1,
|
||||
bound_at,
|
||||
);
|
||||
|
||||
assert_eq!(deadline, now_instant);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn game_chat_absolute_deadline_stops_a_never_resolving_in_flight_action() {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(20);
|
||||
let started = std::time::Instant::now();
|
||||
|
||||
let result = await_game_chat_absolute_deadline_at(
|
||||
deadline,
|
||||
std::future::pending::<AgentBackgroundTaskOutcome>(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"never-resolving action must hit the hard deadline"
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < std::time::Duration::from_secs(1),
|
||||
"deadline test must not hang"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry() {
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1);
|
||||
|
||||
let result = await_game_chat_absolute_deadline_at(deadline, async { "completed" }).await;
|
||||
|
||||
assert_eq!(result.expect("in-flight action completes"), "completed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_absolute_deadline_forces_needs_reconciliation_to_failed_before_cleanup() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"genarrative-game-chat-deadline-reconciliation-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock")
|
||||
.as_nanos()
|
||||
));
|
||||
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
|
||||
.expect("project init");
|
||||
let mut runtime = start_game_creator_agent_runtime_task_at(
|
||||
&root,
|
||||
"code-prototype",
|
||||
"执行可能悬挂的首版写入",
|
||||
"game-chat-deadline-reconciliation-run",
|
||||
"agent-ready-task-scheduler",
|
||||
"正在执行首版写入",
|
||||
vec!["执行首版写入".to_string()],
|
||||
)
|
||||
.expect("start runtime");
|
||||
runtime.loop_iteration = 1;
|
||||
let action = AgentRuntimeToolAction {
|
||||
tool: "file.write".to_string(),
|
||||
reason: Some("模拟截止时仍在途的写入".to_string()),
|
||||
input: serde_json::json!({
|
||||
"path": "game/index.html",
|
||||
"content": "<!doctype html><title>deadline</title>"
|
||||
}),
|
||||
};
|
||||
let plan = AgentRuntimeToolPlan {
|
||||
thinking_summary: "准备首版写入".to_string(),
|
||||
plan_update: None,
|
||||
plan: vec!["写入首版".to_string()],
|
||||
actions: vec![action.clone()],
|
||||
response: String::new(),
|
||||
};
|
||||
let project_revision =
|
||||
read_game_creator_agent_runtime_project_revision(&root).expect("read project revision");
|
||||
let repository_fingerprint = build_repository_startup_context_at(&root)
|
||||
.expect("repository context")
|
||||
.fingerprint;
|
||||
let pending = build_game_creator_agent_runtime_pending_tool_action(
|
||||
&root,
|
||||
&runtime,
|
||||
&runtime.current_task,
|
||||
&plan,
|
||||
&[],
|
||||
&project_revision,
|
||||
&repository_fingerprint,
|
||||
&action,
|
||||
0,
|
||||
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
|
||||
AGENT_RUNTIME_PENDING_ACTION_STATUS_APPROVED,
|
||||
None,
|
||||
)
|
||||
.expect("build pending action");
|
||||
write_game_creator_agent_runtime_pending_tool_action(&root, &pending)
|
||||
.expect("write pending action");
|
||||
runtime.pending_tool_action = Some(pending.summary());
|
||||
runtime.status = "failed".to_string();
|
||||
runtime.phase = "needs-reconciliation".to_string();
|
||||
runtime.current_action = "等待人工核对在途动作".to_string();
|
||||
runtime.waiting_on = "开发者核对副作用".to_string();
|
||||
runtime.next_step = "核对后恢复".to_string();
|
||||
runtime.error = Some("模拟 needs-reconciliation".to_string());
|
||||
append_game_creator_agent_runtime_task(&root, &runtime).expect("append reconciliation task");
|
||||
write_game_creator_agent_runtime_state(&root, &runtime).expect("write reconciliation state");
|
||||
|
||||
let outcome = finish_game_chat_absolute_deadline_timeout_at(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.session_id,
|
||||
runtime.clone(),
|
||||
);
|
||||
|
||||
assert!(matches!(outcome, AgentBackgroundTaskOutcome::Finished));
|
||||
let terminal = read_game_creator_agent_runtime_at(&root, &runtime.agent_id)
|
||||
.expect("read terminal runtime")
|
||||
.state;
|
||||
assert_eq!(terminal.run_id, runtime.run_id);
|
||||
assert_eq!(terminal.status, "failed");
|
||||
assert_eq!(terminal.phase, "failed");
|
||||
assert!(terminal.pending_tool_action.is_none());
|
||||
assert!(!game_creator_agent_runtime_pending_tool_action_exists(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id
|
||||
));
|
||||
assert!(!game_creator_agent_runtime_provider_action_batch_exists(
|
||||
&root,
|
||||
&runtime.agent_id,
|
||||
&runtime.run_id
|
||||
));
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
@@ -25,6 +25,86 @@ fn register_autonomous_recovery_visual_fixture(root: &Path, local_path: &str, ki
|
||||
.expect("register autonomous recovery visual fixture");
|
||||
}
|
||||
|
||||
fn register_game_chat_art_spec_fixture(root: &Path) {
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([90, 140, 220, u8::MAX]))
|
||||
.save(root.join(AGENT_RUNTIME_ART_SPEC_PATH))
|
||||
.expect("write game-chat art spec PNG");
|
||||
register_local_asset_at(
|
||||
root,
|
||||
AGENT_RUNTIME_ART_SPEC_PATH,
|
||||
"icon-spec",
|
||||
"image/png",
|
||||
"canvas",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("game-chat-canvas".to_string()),
|
||||
resource_id: Some("game-chat-art-spec-resource".to_string()),
|
||||
asset_object_id: Some("game-chat-art-spec-object".to_string()),
|
||||
task_id: Some("art-director".to_string()),
|
||||
prompt: None,
|
||||
model: None,
|
||||
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
|
||||
generation_kind: Some("spec".to_string()),
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)
|
||||
.expect("register game-chat art spec");
|
||||
}
|
||||
|
||||
fn queue_game_chat_fast_path_child(
|
||||
root: &Path,
|
||||
root_run_id: &str,
|
||||
root_task: &str,
|
||||
child_id: &str,
|
||||
) -> (AgentRuntimeState, AgentRuntimeState) {
|
||||
let root_session = resolve_agent_conversation_session_id_at(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.expect("resolve game-chat root session");
|
||||
let root_record = append_unique_game_creator_agent_runtime_pending_task(
|
||||
root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
&root_session,
|
||||
root_task,
|
||||
root_run_id,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
|
||||
None,
|
||||
)
|
||||
.expect("queue game-chat root");
|
||||
let root_state = agent_runtime_state_from_task_record(&root_record);
|
||||
let manifest = read_manifest_for_project(root).expect("read game-chat manifest");
|
||||
let task = manifest
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.id == child_id)
|
||||
.unwrap_or_else(|| panic!("missing game-chat child task {child_id}"));
|
||||
let child_session = resolve_agent_conversation_session_id_at(root, child_id, None, true)
|
||||
.expect("resolve game-chat child session");
|
||||
let child_record = append_unique_game_creator_agent_runtime_pending_task(
|
||||
root,
|
||||
child_id,
|
||||
&child_session,
|
||||
&render_autonomous_manifest_ready_task_background_prompt(task),
|
||||
&autonomous_manifest_ready_task_run_id(root_run_id, child_id),
|
||||
"agent-ready-task-scheduler",
|
||||
None,
|
||||
Some(&AgentRuntimeTaskLink {
|
||||
parent_agent_id: Some(root_state.agent_id.clone()),
|
||||
parent_run_id: Some(root_state.run_id.clone()),
|
||||
delegation_id: None,
|
||||
}),
|
||||
)
|
||||
.expect("queue game-chat fast-path child");
|
||||
(
|
||||
root_state,
|
||||
agent_runtime_state_from_task_record(&child_record),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_parent_keeps_planning_before_scheduling_registered_legacy_derived_visuals() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
@@ -65,7 +145,7 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState
|
||||
write_local_project_file_at(
|
||||
root,
|
||||
AGENT_RUNTIME_GAME_INDEX_PATH,
|
||||
"<!doctype html><title>可玩塔防</title><canvas></canvas>",
|
||||
&render_game_chat_fast_path_html("可玩塔防"),
|
||||
)
|
||||
.expect("write autonomous game index");
|
||||
for (path, content) in [
|
||||
@@ -84,6 +164,7 @@ fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState
|
||||
}
|
||||
revision
|
||||
};
|
||||
register_game_chat_art_spec_fixture(root);
|
||||
for task in new_game_creation_app_seed_tasks() {
|
||||
update_manifest_task_status_at(root, &task.id, GameCreationAppTaskStatus::Completed)
|
||||
.expect("complete autonomous manifest task");
|
||||
@@ -263,6 +344,214 @@ fn autonomous_visual_ready_tasks_only_require_images_when_editor_api_key_is_conf
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_art_director_uses_deterministic_canvas_plan_without_provider_planning() {
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"editorApi":{"apiKey":"game-chat-fast-art-key"}}"#.to_string(),
|
||||
);
|
||||
let temporary = tempfile::tempdir().expect("create game-chat art root");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "game-chat-art-director", "星空飞船收集能量")
|
||||
.expect("init game-chat art project");
|
||||
let (root_state, child_state) = queue_game_chat_fast_path_child(
|
||||
&root,
|
||||
"game-chat-art-director-root",
|
||||
"制作星空飞船收集能量小游戏",
|
||||
"art-director",
|
||||
);
|
||||
let binding = read_game_creator_agent_runtime_run_profile_binding(
|
||||
&root,
|
||||
&root_state.agent_id,
|
||||
&root_state.run_id,
|
||||
)
|
||||
.expect("read game-chat root binding")
|
||||
.expect("game-chat root binding exists");
|
||||
|
||||
let plan = game_chat_fast_path_plan_at(
|
||||
&root,
|
||||
&child_state,
|
||||
&child_state.current_task,
|
||||
binding.bound_at,
|
||||
)
|
||||
.expect("build deterministic art-director plan")
|
||||
.expect("art-director fast path must bypass Provider planning");
|
||||
|
||||
assert_eq!(plan.actions.len(), 1);
|
||||
assert_eq!(plan.actions[0].tool, "canvas.asset_generate");
|
||||
assert_eq!(
|
||||
plan.actions[0].input["outputPath"],
|
||||
AGENT_RUNTIME_ART_SPEC_PATH
|
||||
);
|
||||
assert_eq!(plan.actions[0].input["assetKind"], "icon-spec");
|
||||
assert_eq!(plan.actions[0].input["aspectRatio"], "1:1");
|
||||
assert_eq!(plan.actions[0].input["imageSize"], "1K");
|
||||
assert_eq!(plan.actions[0].input["replaceExisting"], false);
|
||||
let serialized = serde_json::to_string(&plan).expect("serialize deterministic art plan");
|
||||
assert!(!serialized.contains("art-spritesheet"));
|
||||
assert!(!serialized.contains("icon-spritesheets/generations"));
|
||||
assert!(!root.join("assets/manifest.art.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_art_stage_fails_before_provider_when_editor_api_is_missing() {
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
let temporary = tempfile::tempdir().expect("create unconfigured game-chat art root");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "game-chat-art-unconfigured", "太空躲避")
|
||||
.expect("init unconfigured game-chat art project");
|
||||
let (root_state, child_state) = queue_game_chat_fast_path_child(
|
||||
&root,
|
||||
"game-chat-art-unconfigured-root",
|
||||
"制作太空躲避小游戏",
|
||||
"art-director",
|
||||
);
|
||||
let binding = read_game_creator_agent_runtime_run_profile_binding(
|
||||
&root,
|
||||
&root_state.agent_id,
|
||||
&root_state.run_id,
|
||||
)
|
||||
.expect("read game-chat root binding")
|
||||
.expect("game-chat root binding exists");
|
||||
|
||||
let error = game_chat_fast_path_plan_at(
|
||||
&root,
|
||||
&child_state,
|
||||
&child_state.current_task,
|
||||
binding.bound_at,
|
||||
)
|
||||
.expect_err("unconfigured game-chat art must fail closed before Provider planning");
|
||||
assert!(error.contains("External Editor API Key"));
|
||||
assert!(error.contains("平台美术资源"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_art_fast_path_idempotently_settles_registered_art_spec() {
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"editorApi":{"apiKey":"game-chat-idempotent-art-key"}}"#.to_string(),
|
||||
);
|
||||
|
||||
let art_director_temporary = tempfile::tempdir().expect("create idempotent art-spec root");
|
||||
let art_director_root = art_director_temporary.path().join("project");
|
||||
init_local_game_project_at(
|
||||
&art_director_root,
|
||||
"game-chat-idempotent-art-spec",
|
||||
"海岛收集",
|
||||
)
|
||||
.expect("init idempotent art-spec project");
|
||||
register_game_chat_art_spec_fixture(&art_director_root);
|
||||
let (root_state, art_director_state) = queue_game_chat_fast_path_child(
|
||||
&art_director_root,
|
||||
"game-chat-idempotent-art-spec-root",
|
||||
"制作海岛收集小游戏",
|
||||
"art-director",
|
||||
);
|
||||
let bound_at = read_game_creator_agent_runtime_run_profile_binding(
|
||||
&art_director_root,
|
||||
&root_state.agent_id,
|
||||
&root_state.run_id,
|
||||
)
|
||||
.expect("read idempotent art-spec root binding")
|
||||
.expect("idempotent art-spec root binding exists")
|
||||
.bound_at;
|
||||
let art_director_plan = game_chat_fast_path_plan_at(
|
||||
&art_director_root,
|
||||
&art_director_state,
|
||||
&art_director_state.current_task,
|
||||
bound_at,
|
||||
)
|
||||
.expect("settle registered art spec")
|
||||
.expect("registered art spec must use deterministic settlement");
|
||||
assert!(art_director_plan.actions.is_empty());
|
||||
assert!(art_director_plan.response.contains("已生成并登记"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_code_prototype_ignores_art_director_global_revision_before_its_own_mutation() {
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"editorApi":{"apiKey":"game-chat-code-revision-key"}}"#.to_string(),
|
||||
);
|
||||
let temporary = tempfile::tempdir().expect("create game-chat code revision root");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "game-chat-code-revision", "太空飞船收集能量")
|
||||
.expect("init game-chat code revision project");
|
||||
let (root_state, art_director_state) = queue_game_chat_fast_path_child(
|
||||
&root,
|
||||
"game-chat-code-revision-root",
|
||||
"制作太空飞船收集能量小游戏",
|
||||
"art-director",
|
||||
);
|
||||
{
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
&root,
|
||||
"test.game-chat.art-director.mutate",
|
||||
)
|
||||
.expect("acquire art-director mutation lock");
|
||||
let revision = prepare_agent_runtime_project_mutation_locked(
|
||||
&root,
|
||||
&art_director_state.agent_id,
|
||||
&art_director_state.run_id,
|
||||
"canvas.asset_generate",
|
||||
)
|
||||
.expect("advance global revision for art-director");
|
||||
assert_eq!(revision, 1);
|
||||
}
|
||||
|
||||
let manifest = read_manifest_for_project(&root).expect("read game-chat manifest");
|
||||
let code_task = manifest
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.id == "code-prototype")
|
||||
.expect("code-prototype manifest task");
|
||||
let code_session =
|
||||
resolve_agent_conversation_session_id_at(&root, "code-prototype", None, true)
|
||||
.expect("resolve code-prototype session");
|
||||
let code_record = append_unique_game_creator_agent_runtime_pending_task(
|
||||
&root,
|
||||
"code-prototype",
|
||||
&code_session,
|
||||
&render_autonomous_manifest_ready_task_background_prompt(code_task),
|
||||
&autonomous_manifest_ready_task_run_id(&root_state.run_id, "code-prototype"),
|
||||
"agent-ready-task-scheduler",
|
||||
None,
|
||||
Some(&AgentRuntimeTaskLink {
|
||||
parent_agent_id: Some(root_state.agent_id.clone()),
|
||||
parent_run_id: Some(root_state.run_id.clone()),
|
||||
delegation_id: None,
|
||||
}),
|
||||
)
|
||||
.expect("queue code-prototype child after art mutation");
|
||||
let code_state = agent_runtime_state_from_task_record(&code_record);
|
||||
let code_gate = read_game_creator_agent_runtime_verification_gate(
|
||||
&root,
|
||||
&code_state.agent_id,
|
||||
&code_state.run_id,
|
||||
)
|
||||
.expect("read code-prototype gate");
|
||||
assert_eq!(code_gate.mutation_revision, None);
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(&root)
|
||||
.expect("read global revision")
|
||||
.revision,
|
||||
1
|
||||
);
|
||||
let bound_at = read_game_creator_agent_runtime_run_profile_binding(
|
||||
&root,
|
||||
&root_state.agent_id,
|
||||
&root_state.run_id,
|
||||
)
|
||||
.expect("read game-chat root binding")
|
||||
.expect("game-chat root binding exists")
|
||||
.bound_at;
|
||||
|
||||
let plan = game_chat_fast_path_plan_at(&root, &code_state, &code_state.current_task, bound_at)
|
||||
.expect("evaluate code-prototype fast path");
|
||||
|
||||
assert!(
|
||||
plan.is_none(),
|
||||
"art-director's global revision must not skip the code Provider"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_supervisor_empty_plan_uses_deterministic_final_reply_fallback() {
|
||||
assert_eq!(
|
||||
@@ -331,7 +620,7 @@ fn game_chat_single_round_converges_without_another_provider_plan_after_playtest
|
||||
for task in new_game_creation_app_seed_tasks() {
|
||||
if !matches!(
|
||||
task.id.as_str(),
|
||||
"code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
"art-director" | "code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
) {
|
||||
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
|
||||
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
|
||||
@@ -398,7 +687,7 @@ fn game_chat_single_round_cannot_converge_after_the_hard_budget() {
|
||||
for task in new_game_creation_app_seed_tasks() {
|
||||
if !matches!(
|
||||
task.id.as_str(),
|
||||
"code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
"art-director" | "code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
) {
|
||||
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
|
||||
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
|
||||
|
||||
@@ -661,7 +661,8 @@ pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source(
|
||||
.into_iter()
|
||||
.filter_map(|mut task| {
|
||||
let dependencies = match task.id.as_str() {
|
||||
"code-prototype" => Some(Vec::new()),
|
||||
"art-director" => Some(Vec::new()),
|
||||
"code-prototype" => Some(vec!["art-director".to_string()]),
|
||||
"preview-readiness" => Some(vec!["code-prototype".to_string()]),
|
||||
"preview-playtest" => Some(vec!["preview-readiness".to_string()]),
|
||||
_ => None,
|
||||
@@ -1161,8 +1162,12 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
|
||||
manifest_task,
|
||||
&task_text,
|
||||
)?;
|
||||
let game_chat_requires_visual_asset = root_parent_binding.source
|
||||
== AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
|
||||
&& manifest_task.id == "art-director";
|
||||
if status == GameCreationAppTaskStatus::Completed
|
||||
&& autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id)
|
||||
&& (autonomous_manifest_ready_task_requires_visual_asset(&manifest_task.id)
|
||||
|| game_chat_requires_visual_asset)
|
||||
&& !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id)
|
||||
{
|
||||
status = GameCreationAppTaskStatus::Failed;
|
||||
@@ -1319,7 +1324,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
|
||||
let code_visual_asset_requirement = if task.id == "code-prototype"
|
||||
&& editor_api_key_is_configured()
|
||||
{
|
||||
" External Editor API 已配置时,必须先调用 asset.list 确认 assets/art-spritesheet.png 已登记且 source.kind=canvas、资源有效;game/index.html 必须实际通过 HTML、CSS background 或 Canvas drawImage 引用 assets/art-spritesheet.png 作为游戏 UI 素材,不得只用 emoji、色块、CSS 绘图或占位文本冒充。"
|
||||
" External Editor API 已配置时必须先调用 asset.list 核对 Canvas 登记与资源有效性:game-chat Run 使用 assets/art-spec.png(icon-spec、images/generations/spec),GUI/CLI Run 使用 assets/art-spritesheet.png;game/index.html 必须通过 HTML、CSS background 或 Canvas drawImage 可见使用对应资源,不得只用 emoji、色块、CSS 绘图或占位文本冒充。"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
+861
-25
File diff suppressed because it is too large
Load Diff
+220
-27
@@ -72,7 +72,7 @@ fn autonomous_supervisor_source_allowlist_includes_game_chat_only() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_manifest_seed_projection_is_a_serial_three_task_lane() {
|
||||
fn game_chat_manifest_seed_projection_is_a_serial_four_task_lane() {
|
||||
let game_chat_tasks =
|
||||
autonomous_manifest_seed_tasks_for_source(AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE);
|
||||
assert_eq!(
|
||||
@@ -80,15 +80,24 @@ fn game_chat_manifest_seed_projection_is_a_serial_three_task_lane() {
|
||||
.iter()
|
||||
.map(|task| task.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["code-prototype", "preview-readiness", "preview-playtest"]
|
||||
[
|
||||
"art-director",
|
||||
"code-prototype",
|
||||
"preview-readiness",
|
||||
"preview-playtest",
|
||||
]
|
||||
);
|
||||
assert_eq!(game_chat_tasks[0].dependencies, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
game_chat_tasks[1].dependencies,
|
||||
vec!["code-prototype".to_string()]
|
||||
vec!["art-director".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
game_chat_tasks[2].dependencies,
|
||||
vec!["code-prototype".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
game_chat_tasks[3].dependencies,
|
||||
vec!["preview-readiness".to_string()]
|
||||
);
|
||||
|
||||
@@ -107,7 +116,7 @@ fn game_chat_manifest_seed_projection_is_a_serial_three_task_lane() {
|
||||
for task in &mut manifest_tasks {
|
||||
if matches!(
|
||||
task.id.as_str(),
|
||||
"code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
"art-director" | "code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
) {
|
||||
task.status = GameCreationAppTaskStatus::Pending;
|
||||
}
|
||||
@@ -117,20 +126,20 @@ fn game_chat_manifest_seed_projection_is_a_serial_three_task_lane() {
|
||||
&manifest_tasks,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
),
|
||||
vec!["code-prototype".to_string()]
|
||||
vec!["art-director".to_string()]
|
||||
);
|
||||
|
||||
manifest_tasks
|
||||
.iter_mut()
|
||||
.find(|task| task.id == "code-prototype")
|
||||
.expect("code prototype task exists")
|
||||
.find(|task| task.id == "art-director")
|
||||
.expect("art director task exists")
|
||||
.status = GameCreationAppTaskStatus::Completed;
|
||||
assert_eq!(
|
||||
autonomous_manifest_ready_task_ids(
|
||||
&manifest_tasks,
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
),
|
||||
vec!["preview-readiness".to_string()]
|
||||
vec!["code-prototype".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,16 +188,13 @@ fn autonomous_fixture_with_setup(
|
||||
}
|
||||
|
||||
fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) {
|
||||
let bytes = if kind == "art-spritesheet" {
|
||||
use image::ImageEncoder;
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes)
|
||||
.write_image(&[12, 34, 56, 0], 1, 1, image::ColorType::Rgba8.into())
|
||||
.expect("encode transparent autonomous visual fixture");
|
||||
bytes
|
||||
} else {
|
||||
b"\x89PNG\r\n\x1a\nfixture".to_vec()
|
||||
};
|
||||
use image::ImageEncoder;
|
||||
let alpha = if kind == "art-spritesheet" { 0 } else { 255 };
|
||||
let pixels = [12, 34, 56, alpha].repeat(64 * 64);
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes)
|
||||
.write_image(&pixels, 64, 64, image::ColorType::Rgba8.into())
|
||||
.expect("encode autonomous visual fixture");
|
||||
fs::write(root.join(local_path), bytes).expect("write autonomous visual fixture");
|
||||
let (generation_route, generation_kind, reference_resource_ids) = match kind {
|
||||
"icon-spec" => (
|
||||
@@ -1074,7 +1080,7 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task
|
||||
for task in new_game_creation_app_seed_tasks() {
|
||||
if !matches!(
|
||||
task.id.as_str(),
|
||||
"code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
"art-director" | "code-prototype" | "preview-readiness" | "preview-playtest"
|
||||
) {
|
||||
update_manifest_task_status_at(&root, &task.id, GameCreationAppTaskStatus::Pending)
|
||||
.unwrap_or_else(|error| panic!("leave non-fast-path task pending: {error}"));
|
||||
@@ -1083,7 +1089,7 @@ fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_task
|
||||
let revision = advance_game_index_revision(
|
||||
&root,
|
||||
&state,
|
||||
"<!doctype html><html><body><img src=\"assets/art-spritesheet.png\"><canvas></canvas></body></html>",
|
||||
"<!doctype html><html><body><img id=\"art\" hidden src=\"../assets/art-spec.png\"><canvas></canvas><script>const image=document.getElementById('art');context.drawImage(image,0,0,canvas.width,canvas.height);context.drawImage(image,0,0,96,96);context.drawImage(image,120,80,112,112);</script></body></html>",
|
||||
);
|
||||
mark_verification_passed(&root, &state, "game.static_smoke");
|
||||
let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario);
|
||||
@@ -1142,24 +1148,160 @@ fn game_chat_schedule_ready_tool_cannot_bypass_the_single_round_publish_boundary
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_is_configured() {
|
||||
fn game_chat_code_prototype_requires_registered_canvas_art_spec_visible_use() {
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"editorApi":{"apiKey":"game-chat-art-gate-key"}}"#.to_string(),
|
||||
);
|
||||
let (_temporary, root, parent_state, _contract) =
|
||||
autonomous_fixture("创建一轮植物塔防游戏", "game-chat-code-art-gate-parent");
|
||||
let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source(
|
||||
"创建一轮植物塔防游戏",
|
||||
"game-chat-code-art-gate-parent",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
);
|
||||
update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running)
|
||||
.expect("mark code prototype running");
|
||||
let code_record =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype");
|
||||
let mut code_state = agent_runtime_state_from_task_record(&code_record);
|
||||
let code_state = agent_runtime_state_from_task_record(&code_record);
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><canvas></canvas></body></html>",
|
||||
);
|
||||
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
|
||||
.expect("missing spritesheet reference must block code prototype");
|
||||
.expect("missing art-spec reference must block code prototype");
|
||||
assert!(blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("assets/art-spec.png")));
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><img src=\"assets/art-spec.png\"><canvas></canvas></body></html>",
|
||||
);
|
||||
assert!(
|
||||
autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some(),
|
||||
"URL relative to game/index.html must resolve to the registered asset"
|
||||
);
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><canvas></canvas><script>const unused = '../assets/art-spec.png';</script></body></html>",
|
||||
);
|
||||
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
|
||||
.expect("an unused art-spec string must not satisfy visible-use validation");
|
||||
assert!(blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("missing-visible-art-spec-use")));
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><canvas></canvas><script>const fake = '<img src=\"../assets/art-spec.png\">';</script></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><style>.hidden-canvas{display:none}</style><img id=\"preload\" hidden src=\"../assets/art-spec.png\"><canvas class=\"hidden-canvas\"></canvas><script>const art=document.getElementById('preload');context.drawImage(art,0,0,canvas.width,canvas.height);context.drawImage(art,0,0,96,96);context.drawImage(art,120,80,112,112);</script>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><img id=\"preload\" hidden src=\"../assets/art-spec.png\"><canvas></canvas><script>const art=document.getElementById('preload');function never(){context.drawImage(art,0,0,canvas.width,canvas.height);context.drawImage(art,0,0,96,96);context.drawImage(art,120,80,112,112);}</script>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><img id=\"preload\" hidden src=\"../assets/art-spec.png\"><canvas></canvas><script>const art=document.getElementById('preload');function draw(){context.drawImage(art,0,0,canvas.width,canvas.height);context.drawImage(art,0,0,96,96);context.drawImage(art,120,80,112,112);}requestAnimationFrame(draw);</script>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none());
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><img hidden src=\"../assets/art-spec.png\"><canvas></canvas></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
|
||||
for hidden_html in [
|
||||
"<!doctype html><img width=\"1\" height=\"1\" src=\"../assets/art-spec.png\"><canvas></canvas>",
|
||||
"<!doctype html><img style=\"opacity:0.05\" src=\"../assets/art-spec.png\"><canvas></canvas>",
|
||||
"<!doctype html><img style=\"position:absolute;left:-9999px\" src=\"../assets/art-spec.png\"><canvas></canvas>",
|
||||
] {
|
||||
advance_game_index_revision(&root, &code_state, hidden_html);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
}
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><head><style>.hero{width:96px;height:96px;background-image:url('../assets/art-spec.png')}</style></head><body><div class=\"hero\"></div><canvas></canvas></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><canvas></canvas><script>const art=new Image(); art\n .src =\n '../assets/art-spec.png'; context.drawImage(art,0,0);</script></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><canvas></canvas><script>const art=new Image(); art.src='../assets/art-spec.png'; context.drawImage(art,0,0,1,1);</script></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><img id=\"preload\" hidden src=\"../assets/art-spec.png\"><canvas></canvas><script>const art=document.getElementById('preload'); context.drawImage(art,0,0,96,96);</script></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><img src=\"../assets/art-spec.png\"><canvas></canvas></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_some());
|
||||
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><img id=\"preload\" hidden src=\"../assets/art-spec.png\"><canvas></canvas><script>const art=document.getElementById('preload'); context.drawImage(art,0,0,canvas.width,canvas.height); context.drawImage(art,0,0,96,96); context.drawImage(art,120,80,112,112);</script></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_code_prototype_keeps_registered_canvas_spritesheet_gate_when_editor_is_configured() {
|
||||
let _config_guard = crate::tests::write_test_local_config(
|
||||
r#"{"editorApi":{"apiKey":"cli-art-gate-key"}}"#.to_string(),
|
||||
);
|
||||
let (_temporary, root, parent_state, _contract) =
|
||||
autonomous_fixture("创建完整小游戏", "cli-code-art-gate-parent");
|
||||
update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running)
|
||||
.expect("mark CLI code prototype running");
|
||||
let code_record =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype");
|
||||
let code_state = agent_runtime_state_from_task_record(&code_record);
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><img src=\"../assets/art-spec.png\"><canvas></canvas></body></html>",
|
||||
);
|
||||
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
|
||||
.expect("CLI must still require the art spritesheet");
|
||||
assert!(blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
@@ -1168,11 +1310,62 @@ fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_i
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><img src=\"assets/art-spritesheet.png\"><canvas></canvas></body></html>",
|
||||
"<!doctype html><html><body><img src=\"../assets/art-spritesheet.png\"><canvas></canvas></body></html>",
|
||||
);
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &code_state).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_requires_canvas_art_spec_even_without_editor_configuration() {
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source(
|
||||
"创建一轮必须使用平台美术的小游戏",
|
||||
"game-chat-unconfigured-art-gate-parent",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
);
|
||||
update_manifest_task_status_at(&root, "code-prototype", GameCreationAppTaskStatus::Running)
|
||||
.expect("mark code prototype running");
|
||||
let code_record =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "code-prototype");
|
||||
let code_state = agent_runtime_state_from_task_record(&code_record);
|
||||
fs::remove_file(root.join("assets/art-spec.png")).expect("remove registered art-spec file");
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><img src=\"../assets/art-spec.png\"><canvas></canvas></body></html>",
|
||||
);
|
||||
|
||||
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &code_state)
|
||||
.expect("game-chat must fail closed without a valid Canvas art spec");
|
||||
assert!(blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("canvas-registration-invalid")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_art_stage_fails_closed_without_editor_configuration_or_canvas_asset() {
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
let (_temporary, root, parent_state, _contract) = autonomous_fixture_with_source(
|
||||
"创建一轮必须先完成美术阶段的小游戏",
|
||||
"game-chat-unconfigured-art-stage-parent",
|
||||
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
|
||||
);
|
||||
update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Running)
|
||||
.expect("mark art director running");
|
||||
let art_record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "art-director");
|
||||
let art_state = agent_runtime_state_from_task_record(&art_record);
|
||||
fs::remove_file(root.join("assets/art-spec.png")).expect("remove Canvas art spec file");
|
||||
|
||||
let blocker = autonomous_game_build_completion_blocker_at_locked(&root, &art_state)
|
||||
.expect("game-chat art stage must fail closed without a valid Canvas asset");
|
||||
assert!(blocker.summary.contains("Canvas"));
|
||||
assert!(blocker
|
||||
.detail
|
||||
.as_deref()
|
||||
.is_some_and(|detail| detail.contains("task=art-director")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pending() {
|
||||
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
|
||||
@@ -1189,7 +1382,7 @@ fn game_chat_ready_child_can_converge_after_hydration_restores_manifest_to_pendi
|
||||
advance_game_index_revision(
|
||||
&root,
|
||||
&code_state,
|
||||
"<!doctype html><html><body><canvas></canvas><script>requestAnimationFrame(()=>{});</script></body></html>",
|
||||
"<!doctype html><html><body><img id=\"art\" hidden src=\"../assets/art-spec.png\"><canvas></canvas><script>const image=document.getElementById('art');context.drawImage(image,0,0,canvas.width,canvas.height);context.drawImage(image,0,0,96,96);context.drawImage(image,120,80,112,112);requestAnimationFrame(()=>{});</script></body></html>",
|
||||
);
|
||||
|
||||
assert!(
|
||||
|
||||
@@ -156,7 +156,12 @@ mod tests {
|
||||
None,
|
||||
)
|
||||
.expect("bind game-chat run");
|
||||
for task_id in ["code-prototype", "preview-readiness", "preview-playtest"] {
|
||||
for task_id in [
|
||||
"art-director",
|
||||
"code-prototype",
|
||||
"preview-readiness",
|
||||
"preview-playtest",
|
||||
] {
|
||||
update_manifest_task_status_at(root, task_id, GameCreationAppTaskStatus::Completed)
|
||||
.expect("complete game-chat seed task");
|
||||
}
|
||||
@@ -173,13 +178,13 @@ mod tests {
|
||||
assert!(!detail.contains("publish-package"), "{detail}");
|
||||
assert!(
|
||||
detail.contains(
|
||||
"seedTaskCounts: completed=3 running=0 pending=0 waiting=0 failed=0 total=3"
|
||||
"seedTaskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4"
|
||||
),
|
||||
"{detail}"
|
||||
);
|
||||
assert!(
|
||||
detail
|
||||
.contains("taskCounts: completed=3 running=0 pending=0 waiting=0 failed=0 total=3"),
|
||||
.contains("taskCounts: completed=4 running=0 pending=0 waiting=0 failed=0 total=4"),
|
||||
"{detail}"
|
||||
);
|
||||
|
||||
|
||||
@@ -258,11 +258,12 @@ pub(crate) fn validate_manifest_required_visual_asset(
|
||||
.and_then(|path| fs::read(path).ok())
|
||||
.filter(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n"))
|
||||
.ok_or_else(|| format!("规范视觉资产不是有效登记的 PNG 文件:{expected_path}"))?;
|
||||
if task_id == "art-asset-plan"
|
||||
&& !image::load_from_memory(&bytes)
|
||||
.ok()
|
||||
.is_some_and(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX))
|
||||
{
|
||||
let decoded = image::load_from_memory(&bytes)
|
||||
.map_err(|_| format!("规范视觉资产 PNG 无法完整解码:{expected_path}"))?;
|
||||
if decoded.width() == 0 || decoded.height() == 0 {
|
||||
return Err(format!("规范视觉资产 PNG 尺寸无效:{expected_path}"));
|
||||
}
|
||||
if task_id == "art-asset-plan" && !decoded.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX) {
|
||||
return Err("首版美术素材图没有真实透明像素".to_string());
|
||||
}
|
||||
let canvas_project_id = asset
|
||||
|
||||
@@ -331,6 +331,17 @@ fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_re
|
||||
.unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}"));
|
||||
}
|
||||
|
||||
let art_spec_path = root.join("assets/art-spec.png");
|
||||
let valid_art_spec = fs::read(&art_spec_path).expect("read valid art spec fixture");
|
||||
fs::write(&art_spec_path, &valid_art_spec[..valid_art_spec.len() / 2])
|
||||
.expect("write truncated art spec PNG");
|
||||
assert!(
|
||||
validate_manifest_required_visual_asset(&root, &manifest, "art-director")
|
||||
.expect_err("truncated PNG must fail complete decode")
|
||||
.contains("无法完整解码")
|
||||
);
|
||||
fs::write(&art_spec_path, valid_art_spec).expect("restore valid art spec PNG");
|
||||
|
||||
let ui = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
|
||||
@@ -268,6 +268,7 @@ type GameChatPreviewValidationCandidate = GameChatPlayableRevision & {
|
||||
};
|
||||
|
||||
const GAME_CHAT_STAGE_TASK_IDS = [
|
||||
'art-director',
|
||||
'code-prototype',
|
||||
'preview-readiness',
|
||||
'preview-playtest',
|
||||
@@ -292,6 +293,30 @@ function gameChatRuntimeHasTerminalOutcome(runtime: AgentRuntimeState) {
|
||||
);
|
||||
}
|
||||
|
||||
function mergeGameChatHydratedConversationMessages(
|
||||
historyMessages: ChatMessage[],
|
||||
currentMessages: ChatMessage[],
|
||||
) {
|
||||
const mergedMessages = mergeGameChatRuntimeEventMessagesIntoHistory(
|
||||
mergeGameChatFinalReplyMessagesIntoHistory(
|
||||
mergeGameChatRuntimeResponseMessagesIntoHistory(
|
||||
historyMessages,
|
||||
currentMessages,
|
||||
),
|
||||
currentMessages,
|
||||
),
|
||||
currentMessages,
|
||||
);
|
||||
const historyMessageReferences = new Set(historyMessages);
|
||||
const pendingMessages = mergedMessages
|
||||
.filter((message) => !historyMessageReferences.has(message))
|
||||
.sort((left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0));
|
||||
// Conversation persistence uses a positional cursor. Keep durable history
|
||||
// as one prefix so newly observed replies cannot sort ahead of that cursor
|
||||
// and be skipped during hydration.
|
||||
return [...historyMessages, ...pendingMessages];
|
||||
}
|
||||
|
||||
function gameChatPlayableRevisionIsAfterAuthorization(
|
||||
revision: GameChatPlayableRevision,
|
||||
authorization: GameChatAutoPreviewAuthorization,
|
||||
@@ -2711,14 +2736,8 @@ export function App({
|
||||
// so a committed game-chat response cannot be overwritten by stale
|
||||
// `latestMessagesRef` state captured before that callback ran.
|
||||
const nextMessages = gameChatOnly
|
||||
? mergeGameChatRuntimeEventMessagesIntoHistory(
|
||||
mergeGameChatFinalReplyMessagesIntoHistory(
|
||||
mergeGameChatRuntimeResponseMessagesIntoHistory(
|
||||
conversationMessages,
|
||||
current,
|
||||
),
|
||||
current,
|
||||
),
|
||||
? mergeGameChatHydratedConversationMessages(
|
||||
conversationMessages,
|
||||
current,
|
||||
)
|
||||
: conversationMessages;
|
||||
@@ -2848,14 +2867,8 @@ export function App({
|
||||
setProjectSupervisorRuntimeError(runtimeError || resumeError);
|
||||
setMessages((current) => {
|
||||
const nextConversationMessages = gameChatOnly
|
||||
? mergeGameChatRuntimeEventMessagesIntoHistory(
|
||||
mergeGameChatFinalReplyMessagesIntoHistory(
|
||||
mergeGameChatRuntimeResponseMessagesIntoHistory(
|
||||
conversationMessages,
|
||||
current,
|
||||
),
|
||||
current,
|
||||
),
|
||||
? mergeGameChatHydratedConversationMessages(
|
||||
conversationMessages,
|
||||
current,
|
||||
)
|
||||
: conversationMessages;
|
||||
|
||||
@@ -58,6 +58,7 @@ export type GameChatRuntimeEvent = {
|
||||
const GAME_CHAT_RUNTIME_EVENT_MESSAGE_PREFIX = 'game-chat-runtime-event:';
|
||||
const GAME_CHAT_FINAL_REPLY_MESSAGE_PREFIX = 'game-chat-final-reply:';
|
||||
const GAME_CHAT_FINAL_REPLY_AGENT_IDS = new Set([
|
||||
'art-director',
|
||||
'code-prototype',
|
||||
'preview-readiness',
|
||||
'preview-playtest',
|
||||
@@ -323,6 +324,7 @@ export function buildGameChatProgressEvidence(
|
||||
return null;
|
||||
}
|
||||
const fastPathTaskIds = new Set([
|
||||
'art-director',
|
||||
'code-prototype',
|
||||
'preview-readiness',
|
||||
'preview-playtest',
|
||||
|
||||
@@ -132,6 +132,17 @@ function gameChatRuntimeState(
|
||||
};
|
||||
}
|
||||
|
||||
const GAME_CHAT_STAGE_TASK_IDS = [
|
||||
'art-director',
|
||||
'code-prototype',
|
||||
'preview-readiness',
|
||||
'preview-playtest',
|
||||
] as const;
|
||||
|
||||
function isGameChatStageTask(taskId: string) {
|
||||
return (GAME_CHAT_STAGE_TASK_IDS as readonly string[]).includes(taskId);
|
||||
}
|
||||
|
||||
function gameChatPreviewPlaytestRuntime({
|
||||
parentRunId,
|
||||
revision,
|
||||
@@ -2834,6 +2845,8 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
updatedAt: 200 + responseRevision,
|
||||
});
|
||||
const messages = gameChatFinalReplyMessages([
|
||||
makeStream('art-director', 'ready', '视觉方向已完成'),
|
||||
makeStream('art-asset-plan', 'committed', '平台美术图集已生成并登记'),
|
||||
makeStream('code-prototype', 'ready', '代码原型已完成'),
|
||||
makeStream('preview-readiness', 'committed', '预览就绪检查已完成'),
|
||||
makeStream('preview-playtest', 'ready', '试玩验证已完成'),
|
||||
@@ -2842,13 +2855,14 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
requestKind: 'tool-plan',
|
||||
},
|
||||
]);
|
||||
expect(messages).toHaveLength(3);
|
||||
expect(messages).toHaveLength(4);
|
||||
expect(messages.map((message) => message.text)).toEqual([
|
||||
expect.stringContaining('视觉方向已完成'),
|
||||
expect.stringContaining('代码原型已完成'),
|
||||
expect.stringContaining('预览就绪检查已完成'),
|
||||
expect.stringContaining('试玩验证已完成'),
|
||||
]);
|
||||
expect(messages[0]?.messageId).toContain('code-prototype');
|
||||
expect(messages[0]?.messageId).toContain('art-director');
|
||||
expect(messages[0]?.messageId).toContain('game-chat-final-reply:');
|
||||
expect(messages.every((message) => message.agentId)).toBe(true);
|
||||
const hydrated = mergeGameChatFinalReplyMessagesIntoHistory(
|
||||
@@ -2910,6 +2924,13 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
};
|
||||
};
|
||||
const professionalResults = [
|
||||
makeRuntimeResult('art-director', 'ready', '视觉方向已完成', 180),
|
||||
makeRuntimeResult(
|
||||
'art-asset-plan',
|
||||
'committed',
|
||||
'平台美术图集已生成并登记',
|
||||
190,
|
||||
),
|
||||
makeRuntimeResult('code-prototype', 'ready', '代码原型已完成', 200),
|
||||
makeRuntimeResult('preview-readiness', 'committed', '预览就绪已完成', 210),
|
||||
makeRuntimeResult('preview-playtest', 'ready', '试玩验证已完成', 220),
|
||||
@@ -2951,6 +2972,8 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
);
|
||||
let rendered = renderRelease();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/视觉方向已完成/)).not.toBeNull();
|
||||
expect(screen.queryByText(/平台美术图集已生成并登记/)).toBeNull();
|
||||
expect(screen.getByText(/代码原型已完成/)).not.toBeNull();
|
||||
expect(screen.getByText(/预览就绪已完成/)).not.toBeNull();
|
||||
expect(screen.getByText(/试玩验证已完成/)).not.toBeNull();
|
||||
@@ -2962,14 +2985,14 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
String(args?.messageId ?? '').startsWith('game-chat-final-reply:'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(finalReplyAppends()).toHaveLength(3);
|
||||
expect(finalReplyAppends()).toHaveLength(4);
|
||||
});
|
||||
rendered.unmount();
|
||||
rendered = renderRelease();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/代码原型已完成/)).not.toBeNull();
|
||||
});
|
||||
expect(finalReplyAppends()).toHaveLength(3);
|
||||
expect(finalReplyAppends()).toHaveLength(4);
|
||||
rendered.unmount();
|
||||
});
|
||||
|
||||
@@ -3535,15 +3558,13 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
expect(statusCard.textContent).toContain('生成 Agent 工具计划(本轮)');
|
||||
});
|
||||
|
||||
it('counts only the three first-playable fast-path tasks in game-chat progress', () => {
|
||||
it('counts only the four first-playable fast-path tasks in game-chat progress', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'game-chat-progress-total',
|
||||
'game-chat-progress-total',
|
||||
);
|
||||
manifest.tasks = manifest.tasks.map((task) =>
|
||||
['code-prototype', 'preview-readiness', 'preview-playtest'].includes(
|
||||
task.id,
|
||||
)
|
||||
isGameChatStageTask(task.id)
|
||||
? { ...task, status: 'completed' as const }
|
||||
: task,
|
||||
);
|
||||
@@ -3557,7 +3578,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
renderGameChatStatus({ runtime, manifest });
|
||||
|
||||
const progress = screen.getByLabelText('Supervisor 进度播报');
|
||||
expect(progress.textContent).toContain('任务图 3/3');
|
||||
expect(progress.textContent).toContain('任务图 4/4');
|
||||
expect(progress.textContent).not.toContain('publish-strategy');
|
||||
expect(progress.textContent).not.toContain('publish-package');
|
||||
});
|
||||
@@ -3736,7 +3757,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
expect(within(progress).queryByText(/第 4 轮/u)).toBeNull();
|
||||
expect(
|
||||
within(progress).getByText(
|
||||
'任务图 0/3 · 进行中 1 · 计划 1/3',
|
||||
'任务图 1/4 · 进行中 1 · 计划 1/3',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(within(progress).getByText('核对首版试玩诊断')).not.toBeNull();
|
||||
@@ -3814,7 +3835,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
expect(within(progress).queryByText(/第 5 轮/u)).toBeNull();
|
||||
expect(
|
||||
within(progress).getByText(
|
||||
'任务图 0/3 · 进行中 1 · 计划 2/3',
|
||||
'任务图 1/4 · 进行中 1 · 计划 2/3',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(within(progress).getByText('安排程序 Agent 返工')).not.toBeNull();
|
||||
@@ -4044,9 +4065,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
'game-chat-stage-record',
|
||||
);
|
||||
manifest.tasks = manifest.tasks.map((task) =>
|
||||
['code-prototype', 'preview-readiness', 'preview-playtest'].includes(
|
||||
task.id,
|
||||
)
|
||||
isGameChatStageTask(task.id)
|
||||
? { ...task, status: 'completed' as const }
|
||||
: task,
|
||||
);
|
||||
@@ -4280,7 +4299,11 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
terminalManifest.tasks = terminalManifest.tasks.map((task) =>
|
||||
task.id === 'preview-playtest'
|
||||
? { ...task, status: 'failed' as const }
|
||||
: ['code-prototype', 'preview-readiness'].includes(task.id)
|
||||
: [
|
||||
'art-director',
|
||||
'code-prototype',
|
||||
'preview-readiness',
|
||||
].includes(task.id)
|
||||
? { ...task, status: 'completed' as const }
|
||||
: task,
|
||||
);
|
||||
@@ -4417,7 +4440,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
(stageRecordAppends()[0]?.[1] as { message?: { content?: string } })
|
||||
?.message?.content ?? '',
|
||||
);
|
||||
expect(stageRecord).toContain('2/3');
|
||||
expect(stageRecord).toContain('3/4');
|
||||
});
|
||||
|
||||
it('archives a terminal game-chat run restored during initial hydration exactly once', async () => {
|
||||
@@ -4428,9 +4451,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
'game-chat-stage-record-initial-terminal',
|
||||
);
|
||||
manifest.tasks = manifest.tasks.map((task) =>
|
||||
['code-prototype', 'preview-readiness', 'preview-playtest'].includes(
|
||||
task.id,
|
||||
)
|
||||
isGameChatStageTask(task.id)
|
||||
? { ...task, status: 'completed' as const }
|
||||
: task,
|
||||
);
|
||||
@@ -4550,9 +4571,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
'game-chat-stage-record-restart-hydration',
|
||||
);
|
||||
manifest.tasks = manifest.tasks.map((task) =>
|
||||
['code-prototype', 'preview-readiness', 'preview-playtest'].includes(
|
||||
task.id,
|
||||
)
|
||||
isGameChatStageTask(task.id)
|
||||
? { ...task, status: 'completed' as const }
|
||||
: task,
|
||||
);
|
||||
|
||||
@@ -5875,9 +5875,9 @@
|
||||
## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用
|
||||
|
||||
- 背景:game-chat 的 ready response 之前只作为 transient stream 展示,专业 Agent 的 `final-reply` 只进入各自私有 conversation,刷新或事件 / 轮询重放时项目聊天可能丢失这些输出;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。
|
||||
- 决策:game-chat 为 Supervisor ready 输出、三阶段专业 Agent `final-reply` 和每条后端批准公开的 Runtime 输出分配稳定消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到项目聊天。专业回复只接受 `code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed`;tool-plan 与流式半成品不进入聊天。Runtime 事件由 Rust 在写事件时生成唯一 `eventId` 和可选 `publicText`,前端只消费这两个字段,不重新解释 `summary / detail`;无公开投影的 legacy、Provider、Runner、tool payload、路径、指纹、哈希和敏感字段不得写 conversation。`append_local_conversation_message` 通过顶层 `messageId` 使用后端幂等追加。普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke` 和 `preview.validate`,不再调度 `publish-strategy` / `publish-package`,GUI / CLI 仍执行完整 DAG。`agent.schedule_ready` 同样必须按当前父 Run 的持久 source/profile 走 source-aware scheduler,不得回退通用完整 DAG;`task.list` 必须隐藏两个发布节点及其 ready/count 投影,`agent.delegate` 必须根据 root binding 拒绝直接委派这两个节点,所有绑定读取错误均失败关闭。
|
||||
- 单轮语义:Runtime 的 `loopIteration` 是同一父 Run 内的 Provider / 工具循环,不是用户可见的游戏版本轮次;game-chat 的进度卡、当前工作和最新状态统一显示“本轮”,不显示根或专业 Agent 的内部“第 N 轮”,完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,首版快车道改为三阶段 `x/3`,终态后移除运行中进度卡。`preview-playtest` 与全部完成门满足且非验证屏障清零后,Runtime 以确定性最终回复完成结构化计划并立即结算父 Run,不再把“是否继续”交给下一次 Provider tool-plan。
|
||||
- 美术资源门禁:External Editor API 有效时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 真实引用该文件;manifest、文件或 HTML 引用任一缺失均拒绝完成。确定性 Provider fixture 也必须带该引用,不能用占位内容绕过门禁。
|
||||
- 决策:game-chat 为 Supervisor ready 输出、四阶段专业 Agent `final-reply` 和每条后端批准公开的 Runtime 输出分配稳定消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到项目聊天。专业回复只接受 `art-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed`;`art-asset-plan` 不属于五分钟首版阶段,其回复不进入 game-chat 项目聊天。tool-plan 与流式半成品不进入聊天。Runtime 事件由 Rust 在写事件时生成唯一 `eventId` 和可选 `publicText`,前端只消费这两个字段,不重新解释 `summary / detail`;无公开投影的 legacy、Provider、Runner、tool payload、路径、指纹、哈希和敏感字段不得写 conversation。`append_local_conversation_message` 通过顶层 `messageId` 使用后端幂等追加。普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke` 和 `preview.validate`,不再调度 `publish-strategy` / `publish-package`,GUI / CLI 仍执行完整 DAG。`agent.schedule_ready` 同样必须按当前父 Run 的持久 source/profile 走 source-aware scheduler,不得回退通用完整 DAG;`task.list` 必须隐藏两个发布节点及其 ready/count 投影,`agent.delegate` 必须根据 root binding 拒绝直接委派这两个节点,所有绑定读取错误均失败关闭。
|
||||
- 单轮语义:Runtime 的 `loopIteration` 是同一父 Run 内的 Provider / 工具循环,不是用户可见的游戏版本轮次;game-chat 的进度卡、当前工作和最新状态统一显示“本轮”,不显示根或专业 Agent 的内部“第 N 轮”,完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,首版快车道改为四阶段 `x/4`,终态后移除运行中进度卡。`preview-playtest` 与全部完成门满足且非验证屏障清零后,Runtime 以确定性最终回复完成结构化计划并立即结算父 Run,不再把“是否继续”交给下一次 Provider tool-plan。
|
||||
- 美术资源门禁:live10 实测透明 `icon-spritesheet` 的生成和后处理超过 `300` 秒,不能纳入 game-chat 五分钟首版。game-chat 改为由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,`code-prototype` 必须把它显著用于用户可见的主要背景、玩家和目标;未配置 External Editor API、生成或登记失败、文件缺失、HTML 未引用或可见画面未使用均拒绝完成。普通 GUI / CLI autonomous 继续执行完整 DAG,并以正式透明 `assets/art-spritesheet.png` 及其真实引用作为原有美术硬门。
|
||||
- 验证:`agentRuntimeModel.test.ts` 10 项通过;新增 Rust source allowlist、game-chat parent completion 与 Canvas spritesheet reference 合同测试通过;`cargo fmt --check`、`npm run check:encoding`、`git diff --check` 通过。两项既有 Windows `os error 32` 文件锁竞态仍单独记录,未归因于本次改动。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。
|
||||
|
||||
@@ -5888,14 +5888,14 @@
|
||||
- 决策:game-chat 的 client-owned Runner 在活动任务期间禁止关闭客户端;关闭前复用既有 durable idle 真相源,避免另建 UI busy 状态。用户明确暂停/取消并达到 idle 后再退出,不能靠重启后自动重放未知 Provider 结果。
|
||||
- 决策:规范 Agent reasoning 默认由角色职责分层,显式 per-Agent patch 优先;配置状态对外展示实际 timing/retry,避免全局文件、per-Agent resolver 与历史 run snapshot 混淆。
|
||||
|
||||
## 2026-08-01 game-chat 首版三任务快车道与受控兜底
|
||||
## 2026-08-01 game-chat 首版四阶段快车道与美术硬门
|
||||
|
||||
- 决策:game-chat 首版只投影 `code-prototype`、`preview-readiness`、`preview-playtest` 三个阶段,页面进度显示 `x/3`;完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。
|
||||
- 预算:父 Run 接受请求后以 `240` 秒作为首版软预算;父 Run、所有 child Run、等待、回收和确定性验收共享 `300` 秒累计硬上限。软预算后不再扩展 Provider 规划,只能运行受控 fallback、`game.static_smoke` 和 `preview.validate`;硬上限未形成当前 revision 的通过证据时必须失败关闭,单轮确定性收束也必须复核累计时间,不能在上限后补写 completed。
|
||||
- 决策:game-chat 首版只投影 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,页面进度显示 `x/4`;四个专业 Agent 的安全 `final-reply` 均逐条进入项目聊天,`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。
|
||||
- 预算:父 Run 接受请求后以 `240` 秒作为首版软预算;父 Run、所有 child Run、等待、回收和确定性验收共享 `300` 秒累计硬上限。硬上限是从 root `bound_at` 计算的绝对 deadline,必须包住 Provider、图片生成、文件写入、静态检查、`preview.validate` 和 final-reply 的在途等待;超时先强制持久化 `failed` 终态,再清理 pending action、Provider batch、confirmation、recovery 和进程会话,不能留下 `needs-reconciliation` 悬空态。软预算后不再扩展 Provider 规划,只能运行受控 fallback、`game.static_smoke` 和 `preview.validate`;硬上限未形成当前 revision 的通过证据时必须失败关闭,单轮确定性收束也必须复核累计时间,不能在上限后补写 completed。
|
||||
- Provider:首版最多一次 Provider 规划 / 写入请求,禁止同一首版自动传输重试、第二次 tool-plan 或无限 repair。Provider 结束后由 Runtime 按当前 revision 依次执行确定性静态 smoke 与浏览器试玩。
|
||||
- 兜底:fallback HTML 必须自包含、无远程依赖,从 `ready` 开始并真实绘制 Canvas,持续更新 `playable-web-game-state.v1`,提供键盘 / 触控、start / primary-action / restart 和胜负状态;primary-action 后可保持 `playing`,restart 后可稳定恢复 `ready | playing`,不得开始前固定 `lost` 或用固定失败充当完成。可保留 `../assets/art-spritesheet.png` 引用,但图片缺失不能阻止 Canvas fallback 正常运行。
|
||||
- 美术边界:game-chat 首版平台图集是非阻塞增强,确定性兜底只在 manifest 已登记且项目文件真实存在时设置图集 `src` 并通过 Canvas 使用,缺失时不发起 404;图集稍后登记后可刷新同一预览。普通 GUI / CLI autonomous 仍执行完整 DAG,配置 Editor API Key 时正式图集产物和 HTML 引用继续作为硬完成门,缺失即失败关闭。
|
||||
- 兜底:fallback HTML 必须自包含、无远程运行依赖,从 `ready` 开始并真实绘制 Canvas,持续更新 `playable-web-game-state.v1`,提供键盘 / 触控、start / primary-action / restart 和胜负状态;primary-action 后可保持 `playing`,restart 后可稳定恢复 `ready | playing`,不得开始前固定 `lost` 或用固定失败充当完成。fallback 只有在 `assets/art-spec.png` 已有效登记并真实存在时才能生成,而且必须把该平台图片显著绘制为主要背景、玩家和目标;不得以纯 Canvas 视觉绕过平台图片硬门。
|
||||
- 美术边界:live10 已证明正式透明 `icon-spritesheet` 的后处理无法稳定收进 `300` 秒。game-chat 首版必须配置可用的 External Editor API,由 `art-director` 发起一次平台 `images/generations` 并登记 `assets/art-spec.png`,再由 `code-prototype` 在用户可见画面中把该图片作为主要背景、玩家和目标真实加载和绘制。图片必须可完整解码,引用必须从 `game/index.html` 正确解析到登记路径;活动 Canvas 上同一资源至少包含一次背景级和两次实体级的可达 `drawImage`,隐藏 Canvas、诱饵路径和永不执行函数均不能过门。未配置 API,或生成、登记、文件、引用、可见使用任一缺失时都必须失败关闭,不得写 completed 或 `single_round_converged`。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG,不采用 game-chat 的 `art-spec.png` 快车道。
|
||||
- Windows preview 稳定性:非阻塞 listener 接受连接后必须先把 accepted socket 恢复为阻塞模式,再有界读完拆分到达的请求头;完整响应 `flush + shutdown(Write)` 后执行短时有界 drain。Chromium speculative socket 导致的 `ConnectionAborted / ConnectionReset / Interrupted / TimedOut` 归为可继续监听的瞬时 accept 错误;单个中止连接不得令后续 `preview.validate` 复用或重建时连续得到 `net::ERR_SOCKET_NOT_CONNECTED`。
|
||||
- Runtime 恢复确认:GUI 自动扫描 `agent.resume` 前必须先用只读方式判断是否存在可恢复任务或 durable recovery artifact;全新项目与已完全终态、无任何恢复工作的项目直接返回空结果,不弹出“恢复未完成 Runtime 任务”;一旦存在 task、retry、handoff、finalization、pending action 或 reconciliation 等可恢复工作,仍必须经过原 `agent.resume` policy 门禁,不得通过吞掉 policy error 绕过确认。
|
||||
- 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和三阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。
|
||||
- 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和四阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。
|
||||
|
||||
@@ -81,11 +81,11 @@ cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml real_
|
||||
|
||||
修改 game-chat release flavor 后,至少执行壳配置门禁、AppSurface game-chat 定向测试、前端类型检查、AppData / 诊断日志 / release flavor 相关 Rust 定向测试、`npm run check:encoding` 和 `git diff --check`。打包 smoke 必须确认:安装信息和产物版本为 `0.1.1`;无参数启动直接进入且只能停留在 game-chat 页面;停止或断开 `api-server` 后本地工作台仍能打开;普通 dev / release 与 debug game-chat 仍走原认证入口;独立 AppData 生效。预览 smoke 应先让当前 run 成功验证 revision N,确认 Tauri registry 启动一个 server 且 iframe 自动出现;在 validate 后、start 取得锁前推进项目 revision,必须确认原子 `expectedRevision` 门禁拒绝启动且授权保留等待新证据;再验证 revision N+1,确认 server 进程和 loopback origin 不变、iframe 显示新版本且响应为 `no-store`。same-run steer 还要覆盖旧验证不消费新授权、旧异步 attempt 不清新 generation;Runner registry 单独 running、失败 / 相同 / 更低 revision 均不能触发用户预览或重复刷新,停止后顶部显示“预览未启动”。独立包退出时必须通过 `runner.shutdown_for_client_exit` 先进入 draining 再结束本 boot,保留 durable sidecar 供下次 reconciliation,不把中断任务写成 completed;Windows Runner 必须 `CREATE_SUSPENDED -> AssignProcessToJobObject -> ResumeThread`,分配或恢复失败时 kill + wait,客户端持有 kill-on-close Job 兜底,关闭主窗口后 Runner、MCP、command、ConPTY 及其后代都应消失。普通 dev / release 和 CLI 继续使用 `runner.shutdown_if_idle`。
|
||||
|
||||
game-chat 迭代还必须确认五条行为:Supervisor ready 输出、`code-prototype / preview-readiness / preview-playtest` 三个专业 Agent 的 durable `final-reply`,以及 Rust 明确生成 `eventId + publicText` 的每条公开 Runtime 输出都作为独立 assistant 消息固化在项目聊天;消息通过顶层 `messageId` 幂等追加,事件 / 轮询 / hydration 重放不重复。前端禁止从原始 `summary / detail`、tool plan、Provider / Runner 元数据、命令输出或路径自行拼接持久消息;UI 把一个父 Run 统一显示为“本轮生成进度”,最新状态也不得暴露内部“第 N 轮”,完整 GUI / CLI 任务图可显示 `x/14`,首版快车道显示 `x/3`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口(包括 `agent.schedule_ready`、`task.list` 结果驱动的直接 `agent.delegate`)均不得暴露或启动 `publish-strategy` / `publish-package`,同时 GUI / CLI autonomous 必须继续执行完整发布 DAG;普通 GUI / CLI autonomous 在配置 External Editor API 时,`code-prototype` 的 `game/index.html` 实际引用已由 Canvas 登记的 `assets/art-spritesheet.png`,缺少登记、文件或引用必须失败关闭,game-chat 首版则只在登记与文件均存在时加载图集。对应定向测试至少包括:
|
||||
game-chat 迭代还必须确认以下行为:Supervisor ready 输出、`art-director / code-prototype / preview-readiness / preview-playtest` 四个专业 Agent 的 durable `final-reply`,以及 Rust 明确生成 `eventId + publicText` 的每条公开 Runtime 输出都作为独立 assistant 消息逐条固化在项目聊天;`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。消息通过顶层 `messageId` 幂等追加,事件 / 轮询 / hydration 重放不重复。前端禁止从原始 `summary / detail`、tool plan、Provider / Runner 元数据、命令输出或路径自行拼接持久消息;UI 把一个父 Run 统一显示为“本轮生成进度”,最新状态也不得暴露内部“第 N 轮”,完整 GUI / CLI 任务图可显示 `x/14`,首版快车道显示 `x/4`,终态不保留运行中进度卡;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不请求下一次 Provider tool-plan;所有调度入口(包括 `agent.schedule_ready`、`task.list` 结果驱动的直接 `agent.delegate`)均不得暴露或启动 `publish-strategy` / `publish-package`。game-chat 必须配置可用的 External Editor API,由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,`code-prototype` 必须把它显著用于用户可见的主要背景、玩家和目标;未配置 API 或缺少任一环节都必须失败关闭。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。对应定向测试至少包括:
|
||||
|
||||
game-chat 首版快车道另有独立三阶段口径:只显示 `code-prototype`、`preview-readiness`、`preview-playtest` 的 `x/3`,不把完整 DAG 或内部 loop 计入分母。父 Run 与全部 child Run 共用 240 秒软预算和 300 秒累计硬上限;首版最多一次 Provider 规划 / 写入请求,软预算后只能运行确定性的本地 fallback、`game.static_smoke`、`preview.validate`,硬上限内未通过完成门必须失败,证据在上限后到齐也不得写 `single_round_converged`。fallback 模板必须自包含、可推进、可重开,从 `ready` 开始并避免固定 `lost`;平台图集在 game-chat 首版是非阻塞增强,只有登记和文件都存在时才设置 `src` 并 `drawImage`,缺失时不发起 404、继续使用 Canvas fallback。普通 GUI / CLI 继续完整 DAG;配置 Editor API Key 时正式 GUI / CLI 美术图集与 HTML 引用仍是硬门。对应定向测试至少包括:
|
||||
game-chat 首版快车道采用独立四阶段口径:只显示 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 的 `x/4`,不把完整 DAG 或内部 loop 计入分母。父 Run 与全部 child Run 共用 240 秒软预算和 300 秒累计硬上限;首版最多一次 Provider 规划 / 写入请求,软预算后只能运行确定性的本地 fallback、`game.static_smoke`、`preview.validate`,硬上限内未通过完成门必须失败,证据在上限后到齐也不得写 `single_round_converged`。live10 实测正式透明 `icon-spritesheet` 后处理超过 300 秒,因此 game-chat 改为一次平台 `images/generations` 生成并登记 `assets/art-spec.png`;fallback 只有该图片有效登记且真实存在时才允许生成,并必须把它显著绘制为主要背景、玩家和目标。未配置 External Editor API、图片生成失败、缺少 Canvas / manifest 登记、文件、HTML 引用或用户可见绘制时必须在 300 秒内失败关闭,绝不误报 completed。普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。对应定向测试至少包括:
|
||||
|
||||
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 四阶段后终态时,必须等到四任务最终状态后仅持久化一条 `【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
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
- 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新原始事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。原始 `summary / detail` 仍只作 Runtime 状态投影,不直接写入 conversation。需要进入聊天的事件必须由 Rust 同步生成唯一 `eventId` 与安全 `publicText`;前端只按这两个字段形成独立 assistant 消息,无 `eventId`、空 `publicText`、legacy 事件和内部 tool / Provider / Runner 协议一律忽略。
|
||||
- Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。
|
||||
- Provider 故障展示:Provider retry 的“是否可重试”继续使用 `upstream-5xx` 等稳定类别判断,但 durable retry record 保留安全的精确 `upstream-<HTTP status>` 身份。等待态必须从真实 record 显示 HTTP 状态、`nextAttempt/maxRetries` 与当前持久退避剩余秒数,例如“Provider 上游返回 HTTP 503,准备自动重试 1/3;预计 8 秒后重试”;不得以动画或前端自增计时伪造 attempt。重试耗尽的 Runtime 私有错误只保存 `kind/httpStatus/fingerprint/chars/retryAttempt/maxRetries/retryState`,前端和持久 conversation 仅在字段顺序、范围、状态一致且无尾随正文时派生“上游服务返回 HTTP 503;自动重试已耗尽(3/3)”;其它错误使用固定安全摘要。Provider 响应正文、URL/query、凭据、本地绝对路径、fingerprint、字符数和 `[redacted ...]` 占位符均不得进入用户可见消息。
|
||||
- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待 `code-prototype / preview-readiness / preview-playtest` 三项快车道任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/3` 提前归档,要暂存终态 Runtime 并在 manifest 刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。
|
||||
- 跨轮阶段记录:game-chat 父 run 进入真实 completed / failed / cancelled 终态后,客户端等待 `art-director / code-prototype / preview-readiness / preview-playtest` 四项快车道任务也全部投影到 completed / failed 终态,再把本轮、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。`art-asset-plan` 不属于五分钟首版阶段,不阻塞阶段记录。父 run 先终态而 manifest 仍在 hydration 时不得用陈旧 `0/4` 提前归档,要暂存终态 Runtime 并在 manifest 刷新后重试。页面初始 hydration 若直接读到缺少阶段记录的真实终态 run,也必须补写,但 `idle` 不是可归档终态。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。
|
||||
- 图片成果:当前 manifest 新增或恢复已登记的 PNG / JPEG / WebP 资源时,聊天消息流同步显示 Runtime-owned “Supervisor 成果图片”卡,最多展示最新 4 张并随 manifest 原位更新。图片必须通过现有 `read_local_project_image_preview` 读取,只允许当前授权项目中 `assets/` 下的已登记资源,继续执行 `file.read` auto 权限、真实格式、大小、尺寸、普通文件、祖先目录和项目根边界校验;前端只接受返回路径、媒体类型和 `data:` 前缀与请求完全一致的结果。缩略图点击后使用独立模态查看器,支持按钮与滚轮缩放、指针拖拽、双击 / 按钮复位、Esc / 按钮 / 遮罩关闭,移动端占满视口;不得在聊天卡下方追加展开区。图片卡不写入 conversation,不解析 assistant 文本中的任意 Markdown / 绝对路径,也不开放 `.agent` 验收截图读取。
|
||||
- Run 接管:External Runner 模式下首次提交可能返回“旧 canonical state + 新 `acceptedRunId`”;页面必须以 `acceptedRunId` 作为本轮权威身份,在 state 尚未切换时显示“已投递,正在同步 Agent Runner”,并允许该 run 的 Tauri event 或轮询结果接管。不得把旧 idle state 当作本轮结果、过滤新 run 事件,自动预览授权也必须绑定 `acceptedRunId`。
|
||||
- 运行容器:当前项目没有由 Tauri 客户端 `PreviewRegistry` 返回的有效 `running` 预览时,页面只渲染聊天,不显示游戏区域或占位文案,顶部运行状态必须明确显示“预览未启动”,不得再使用含义不明的“未启动”;预览运行后自动显示 iframe,桌面端按“游戏 2 / 聊天 1”分栏,移动端改为上下布局。预览停止、失败或切换项目后立即移除 iframe。运行容器继续只接受当前授权项目的 `http://127.0.0.1:*`,复用现有 CSP、iframe sandbox、autoplay、fullscreen 和 gamepad 约束;远程 URL、`file://`、手填地址或陈旧 manifest 状态均不得显示。
|
||||
@@ -52,20 +52,20 @@
|
||||
|
||||
## 2026-07-31 game-chat 输出、单轮预览与平台美术资源
|
||||
|
||||
- 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;`code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed` 的非空回复也分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签追加到项目聊天。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。
|
||||
- 对话输出:game-chat 的 Supervisor `ready` response stream 继续以稳定身份显示;`art-director / code-prototype / preview-readiness / preview-playtest` 的 `requestKind=final-reply` 且 `status=ready|committed` 的非空安全回复也分别以 Agent、Session、run、request slot 和 response revision 形成 durable message ID,并带 Agent 标签逐条追加到项目聊天;`art-asset-plan` 的回复不进入 game-chat 项目聊天。每条 Rust `eventId + publicText` 公开输出同样形成独立 durable 消息。所有这些消息通过 `append_local_conversation_message` 的顶层 `messageId` 幂等写入,事件、轮询、React StrictMode 和 hydration 重放不重复;tool-plan、半成品 stream、原始事件 detail、命令正文、绝对路径、Provider / Runner 元数据、哈希和凭据不得进入聊天。普通 `supervisor-chat` 保持原有 transient response 行为。
|
||||
- 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package`;`agent.schedule_ready` 必须按当前 Supervisor Run 的持久 source/profile 选择同一 source-aware scheduler,不能绕过该边界。`task.list` 对同一 root source 必须从任务行、readyTaskIds 和统计中排除两个发布节点,`agent.delegate` 也必须按 root binding 拒绝直接委派这两个节点,不能让 Provider 用“读取完整 DAG 后手工委派”恢复已裁掉的发布阶段。完成门满足且 collaboration、Provider batch、进程会话、视觉资源等非验证屏障全部清零后,Runtime 必须用确定性回复直接收束结构化计划并结束父 Run,不再请求下一次 Provider 工具计划。普通 GUI / CLI 仍执行完整发布 DAG。
|
||||
- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版快车道只按三阶段显示 `x/3`(详见 2026-08-01 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。
|
||||
- 平台美术资源:本条硬门仅适用于普通 GUI / CLI autonomous。配置 External Editor API 时,普通 GUI / CLI 的 `code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 实际引用该路径;缺少登记、文件或引用均 fail-closed。game-chat 首版按下方 2026-08-01 非阻塞例外执行,确定性兜底只在图集已登记且文件真实存在时加载该路径。
|
||||
- 轮次展示:`loopIteration` 只是同一父 Run 内的 Provider / 工具规划循环,用于委派、回执、返工和验收,不是用户发起的游戏生成轮次。game-chat 的进度卡、当前工作和“最新状态”事件统一显示“本轮”,整个页面不向用户显示“第 N 轮”;完整 GUI / CLI pre-publish 任务图仍可显示 `x/14`,但首版快车道只按四阶段显示 `x/4`(详见 2026-08-01 小节),不得把两个发布节点计入任一分母。父 Run 终态后移除运行中进度卡,只保留终态阶段记录与预览。
|
||||
- 平台美术资源:live10 实测正式透明 `icon-spritesheet` 的生成与后处理超过 `300` 秒,因此 game-chat 五分钟首版不运行 `art-asset-plan` 图集链路。它必须配置可用的 External Editor API,由 `art-director` 通过一次平台 `images/generations` 生成并登记 `assets/art-spec.png`,再由 `code-prototype` 在 `game/index.html` 的用户可见画面中把该图片显著用于主要背景、玩家和目标;未配置 API,或缺少生成、登记、文件、引用、可见使用任一证据时均 fail-closed。普通 GUI / CLI autonomous 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG,不采用该快车道。
|
||||
- 验证:前端运行时模型定向测试、Rust completion/source/asset 合同测试、`cargo fmt --check`、`npm run check:encoding` 与 `git diff --check` 必须全部执行;Windows 文件锁竞态只可作为既有测试失败单独记录,不得将其改写为本次改动的通过证据。
|
||||
|
||||
## 2026-08-01 game-chat 首版三任务快车道与受控兜底
|
||||
## 2026-08-01 game-chat 首版四阶段快车道与美术硬门
|
||||
|
||||
- 首版任务边界:game-chat 首版只展示 `code-prototype`、`preview-readiness`、`preview-playtest` 三个阶段,进度统一显示为 `x/3`,不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。
|
||||
- 时间预算:从 game-chat 父 Run 接受用户请求开始,首版可玩版本使用 `240` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享 `300` 秒硬上限,不能把硬上限拆成每个 Agent 独立计时。达到软预算后只允许进入确定性的本地兜底、静态 smoke 和浏览器试玩;达到硬上限仍未通过完成门必须失败关闭。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`,不得把超时伪装成 completed。
|
||||
- 首版任务边界:game-chat 首版只展示 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,进度统一显示为 `x/4`;`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。不把完整 GUI / CLI 任务图的其它节点投影到该页面,也不显示内部 Provider / child loop 轮次。
|
||||
- 时间预算:从 game-chat 父 Run 接受用户请求开始,首版可玩版本使用 `240` 秒软预算;父 Run 与其全部 child Run、等待和回收阶段共享从 root `bound_at` 计算的 `300` 秒绝对硬上限,不能把硬上限拆成每个 Agent 独立计时。整个 Runtime pass 必须受同一 `timeout_at` 约束,覆盖 Provider、图片生成、文件写入、静态检查、浏览器试玩和 final-reply 的在途等待;超时先强制持久化 `failed`,再清理恢复 sidecar 与进程会话。达到软预算后只允许进入确定性的本地兜底、静态 smoke 和浏览器试玩;达到硬上限仍未通过完成门必须失败关闭。即使完成证据恰好在上限后到齐,单轮确定性收束也必须再次检查累计预算并拒绝写入 `single_round_converged`,不得把超时伪装成 completed。
|
||||
- Provider 次数:首版快车道最多执行一次 Provider 首版规划 / 写入请求;后续不再请求第二次 Provider tool-plan、自动传输重试或无限 repair。Provider 成功返回后由 Runtime 依次执行确定性的 `game.static_smoke` 与 `preview.validate`,以当前 revision 和真实浏览器证据决定是否可交付。
|
||||
- 可玩兜底:软预算或首版 Provider 无法及时完成时,可以生成完整、自包含、无远程依赖的中文 HTML 模板。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 持续推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,也不得通过固定失败冒充试玩通过。只有 manifest 已登记且项目文件真实存在时,模板才给隐藏图片设置 `../assets/art-spritesheet.png` 的 `src` 并通过 Canvas `drawImage` 使用;缺少登记或文件时不得发起必然 404 的请求,必须继续使用本地 Canvas fallback。
|
||||
- 平台图集:game-chat 首版的平台图集属于非阻塞增强。External Editor API 或图集生成不可用时,先交付上述可玩本地视觉;图集稍后登记后可在同一预览 origin 刷新,不得阻塞首版 smoke / playtest。普通 GUI / CLI autonomous 仍执行完整 DAG;配置 Editor API Key 时,GUI / CLI 的正式美术产物与 `game/index.html` 图集引用继续是硬完成门,缺少登记、文件或引用必须失败关闭。
|
||||
- 关联验收:快车道必须分别验证 `x/3` 投影、240 / 300 秒累计预算、单次 Provider 请求、兜底模板可推进 / 可重开 / 非固定失败、当前 revision 的静态 smoke 与浏览器试玩;这些规则不改变普通 GUI / CLI 的完整任务图和美术硬门。
|
||||
- 可玩兜底:软预算或首版 Provider 无法及时完成时,可以生成完整、自包含、无远程运行依赖的中文 HTML 模板。模板必须从 `ready` 开始,包含真实 Canvas 绘制、`requestAnimationFrame`、键盘 / 触控主要操作、唯一可见且启用的 start / primary-action / restart 控件,状态 JSON 持续推进,并能在 primary-action 后保持 `playing`、在 restart 后稳定回到 `ready | playing`;不得在开始前固定进入 `lost`,也不得通过固定失败冒充试玩通过。模板只有在 `assets/art-spec.png` 已经完成有效登记并真实存在时才允许生成,且必须把该平台图片显著绘制为主要背景、玩家和目标;未配置 External Editor API、图片生成失败或缺少有效登记时直接失败关闭,不得生成纯几何首版。
|
||||
- 平台图片:live10 已证明透明 `icon-spritesheet` 后处理无法稳定收进 `300` 秒。game-chat 由 `art-director` 在首版预算内发起一次平台 `images/generations`,生成并登记 `assets/art-spec.png`;图片必须完整解码,`game/index.html` 的引用必须正确解析到该登记路径,且同一资源必须在非隐藏活动 Canvas 的可达执行路径中至少完成一次背景级和两次实体级 `drawImage`。`code-prototype` 必须在用户可见游戏画面中把它作为主要背景、玩家和目标真实加载和绘制。未配置 API,或生成、登记、文件存在、HTML 引用、可见使用任一缺失时,都必须在父 Run 的 `300` 秒累计硬上限内失败关闭,绝不写入 completed、`single_round_converged` 或其它成功结论。首版仍只在 `preview-playtest` 后单轮收束,不进入发布节点。普通 GUI / CLI 继续正式透明 `assets/art-spritesheet.png` 的完整 DAG。
|
||||
- 关联验收:快车道必须分别验证 `x/4` 投影、四个专业 Agent 安全 final-reply 逐条入聊天且排除 `art-asset-plan`、240 / 300 秒累计预算、单次 `images/generations`、External Editor API 缺失时失败关闭、`assets/art-spec.png` 生成 / 登记 / 文件 / 引用 / 主要背景与玩家及目标可见使用硬门,以及当前 revision 的静态 smoke 与浏览器试玩;这些规则不改变普通 GUI / CLI 的完整任务图和透明图集硬门。
|
||||
|
||||
## Runtime 边界
|
||||
|
||||
|
||||
Reference in New Issue
Block a user