优化 game-chat 单轮生成与聊天输出
Project CI / Repository checks (push) Failing after 59s
Project CI / Frontend tests (push) Successful in 3m11s
Project CI / Backend tests (push) Successful in 3m33s
Project CI / Native shell tests (push) Successful in 10m30s

每条 Supervisor ready 输出按稳定身份固化到 game-chat 聊天框并修复 hydration 竞态。

新增 game-chat source 白名单,试玩完成后跳过发布任务并收紧完成门。

配置 External Editor API 时强制使用并引用 Canvas 美术图集,补齐前端、Rust、AppSurface 测试与协作文档。
This commit is contained in:
2026-07-31 17:34:44 +08:00
parent 386534a415
commit 831181faec
18 changed files with 679 additions and 43 deletions
@@ -120,7 +120,7 @@ export function deterministicLaneDefenseInitialHtml() {
*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f4f8ee;color:#18351f;font:16px system-ui,sans-serif}main{width:min(960px,100%);margin:auto;padding:18px}h1{margin:0 0 4px;font-size:clamp(28px,7vw,46px)}p{margin:4px 0 14px}.toolbar,.plants{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}button{min-height:44px;border:1px solid #315d35;background:#fff;color:#18351f;padding:9px 14px;font:inherit;font-weight:700;cursor:pointer}button:hover{background:#e6f3dc}.board{display:grid;gap:10px;background:#d9edc8;border:2px solid #315d35;padding:10px}.lane{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}.cell{min-height:54px;background:#eef8e7}.status{font-weight:700;min-height:24px}#game{display:none;width:100%;height:auto;aspect-ratio:20/9;background:#18351f;border:2px solid #315d35}@media(max-width:520px){main{padding:12px}button{flex:1 1 44%}.cell{min-height:44px}}
</style>
</head>
<body><main>
<body><main><img src="assets/art-spritesheet.png" alt="Garden defenders" width="96" height="96">
<h1>灵露花园</h1><p>GENARRATIVE_REAL_E2E_VISIBLE</p><p>Goal: defend the garden and win every wave.</p>
<div class="toolbar"><button data-playtest-id="start">Start Game</button><button data-playtest-id="speed-up">Speed Up</button><button data-playtest-id="next-level">Next Level</button><button data-playtest-id="restart">Restart</button></div>
<div class="plants"><button data-playtest-id="defender-option">露华花</button><button id="thorn">棘刺芽</button></div>
@@ -560,7 +560,32 @@ pub(in crate::agent) fn autonomous_manifest_dag_in_progress_at(
root: &Path,
) -> Result<bool, String> {
let manifest = read_manifest_for_project(root)?;
let seed_task_ids = new_game_creation_app_seed_tasks()
let source = read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID)
.ok()
.filter(|runtime| {
runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& agent_runtime_supervisor_source_is_trusted(&runtime.state.source)
})
.map(|runtime| runtime.state.source)
.or_else(|| {
let path = game_creator_agent_runtime_task_path(
root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
);
read_all_game_creator_agent_runtime_tasks(&path)
.ok()
.map(latest_game_creator_agent_runtime_tasks)
.and_then(|records| {
records.into_iter().rev().find_map(|record| {
(record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& record.parent_run_id.is_none()
&& agent_runtime_supervisor_source_is_trusted(&record.source))
.then_some(record.source)
})
})
})
.unwrap_or_else(|| AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE.to_string());
let seed_task_ids = autonomous_manifest_seed_tasks_for_source(&source)
.into_iter()
.map(|task| task.id)
.collect::<BTreeSet<_>>();
@@ -100,6 +100,16 @@ pub(crate) const AGENT_RUNTIME_ISOLATED_CHILD_SOURCE: &str = "agent-isolated-chi
pub(crate) const AGENT_RUNTIME_ISOLATED_JOIN_SOURCE: &str = "agent-isolated-join";
pub(crate) const AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE: &str = "project-supervisor-gui";
pub(crate) const AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE: &str = "project-supervisor-cli";
pub(crate) const AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE: &str = "project-supervisor-game-chat";
pub(crate) fn agent_runtime_supervisor_source_is_trusted(source: &str) -> bool {
matches!(
source.trim(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE
| AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
| AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
)
}
pub(super) const AGENT_RUNTIME_RUN_PROFILE_BINDING_SCHEMA_VERSION: &str =
"game-creator-run-profile-binding.v1";
pub(super) const AGENT_RUNTIME_AUTONOMOUS_COMPLETION_CONTRACT_SCHEMA_VERSION: &str =
@@ -761,10 +761,7 @@ pub(crate) fn resolve_game_creator_agent_runtime_retry_configuration_at(
if binding.parent_run_id.is_some()
|| binding.root_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| binding.root_run_id != task.run_id
|| !matches!(
binding.source.as_str(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
)
|| !agent_runtime_supervisor_source_is_trusted(&binding.source)
{
return Err("自主构建 Agent Runtime 重试绑定不是可信 Supervisor 根 Run".to_string());
}
@@ -119,10 +119,7 @@ pub(crate) fn start_game_creator_supervisor_background_task_for_session_at(
source: &str,
run_profile: &str,
) -> Result<AgentRuntimeResult, String> {
if !matches!(
source,
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
) {
if !agent_runtime_supervisor_source_is_trusted(source) {
return Err("Project Supervisor 提交 source 不受信任".to_string());
}
normalize_agent_runtime_run_profile(Some(run_profile))?;
@@ -641,10 +638,7 @@ fn current_autonomous_game_build_root_task_at(
&& record.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& record.parent_agent_id.is_none()
&& record.parent_run_id.is_none()
&& matches!(
record.source.as_str(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
)
&& agent_runtime_supervisor_source_is_trusted(&record.source)
&& seen_run_ids.insert(record.run_id.clone())
{
root_run_ids.push(record.run_id.clone());
@@ -658,8 +652,25 @@ fn current_autonomous_game_build_root_task_at(
.find(|record| record.run_id == *current_run_id))
}
fn autonomous_manifest_ready_task_ids(tasks: &[GameCreationAppTaskState]) -> Vec<String> {
new_game_creation_app_seed_tasks()
pub(in crate::agent) fn autonomous_manifest_seed_tasks_for_source(
source: &str,
) -> Vec<GameCreationAppTaskState> {
let seed_tasks = new_game_creation_app_seed_tasks();
if source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE {
seed_tasks
.into_iter()
.take_while(|task| task.id != "publish-strategy")
.collect()
} else {
seed_tasks
}
}
fn autonomous_manifest_ready_task_ids(
tasks: &[GameCreationAppTaskState],
source: &str,
) -> Vec<String> {
autonomous_manifest_seed_tasks_for_source(source)
.into_iter()
.filter_map(|seed_task| {
let task = tasks.iter().find(|task| task.id == seed_task.id)?;
@@ -893,9 +904,17 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
.count();
let available = 3usize.saturating_sub(active_count);
let seed_task_order = new_game_creation_app_seed_tasks();
let allowed_seed_task_ids =
autonomous_manifest_seed_tasks_for_source(&parent_binding.source)
.into_iter()
.map(|task| task.id)
.collect::<BTreeSet<_>>();
let mut candidates = Vec::new();
for seed_task in &seed_task_order {
if !allowed_seed_task_ids.contains(&seed_task.id) {
continue;
}
let Some(task) = manifest.tasks.iter().find(|task| task.id == seed_task.id) else {
continue;
};
@@ -917,7 +936,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at(
candidates.push((task.clone(), false));
}
}
for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks)
for task_id in autonomous_manifest_ready_task_ids(&manifest.tasks, &parent_binding.source)
.into_iter()
.take(limit.min(available))
{
@@ -1183,9 +1202,11 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
}),
)?;
let completed = status == GameCreationAppTaskStatus::Completed;
let game_chat_single_round =
root_parent_binding.source == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE;
let root = root.to_path_buf();
tauri::async_runtime::spawn(async move {
if completed {
if completed && !game_chat_single_round {
if let Err(error) = schedule_autonomous_game_build_ready_tasks_at(
&root,
&parent_agent_id,
@@ -1242,9 +1263,17 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
} else {
""
};
return format!(
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 绘图或占位文本冒充。"
} else {
""
};
let owner_prompt = format!(
"{base}\n\n这是 autonomous-game-build 的正式 owner 写入任务。必须实际生成并写入非空正式产物:{paths};JSON 文件必须是可解析 JSONcode-prototype 的 game/index.html 不能沿用初始化占位。{publish_package_requirement}任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。完成修改后按当前 run 的验证门完成验证并直接交付结论;不要调用 task.updateRuntime 会在子 Run 终态后幂等投影 manifest。"
);
return format!("{owner_prompt}{code_visual_asset_requirement}");
}
format!(
"{base}\n\n这是 autonomous-game-build 的只读协调任务,不要修改项目文件,也不要为了 manifest 内部回执路径写入 memory/、game/、assets/ 或 exports/。只读取当前项目事实,完成方向协调、审查或验收并直接交付结论;不要调用 task.updateRuntime 会在子 Run 终态后幂等投影 manifest。"
@@ -6,6 +6,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool {
if read_game_creator_agent_runtime_at(root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID).is_ok_and(
|runtime| {
runtime.state.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& agent_runtime_supervisor_source_is_trusted(&runtime.state.source)
&& !matches!(
runtime.state.phase.as_str(),
"completed" | "failed" | "cancelled" | "budget-exhausted"
@@ -22,10 +23,7 @@ pub(crate) fn autonomous_game_build_root_run_active_at(root: &Path) -> bool {
tasks.into_iter().any(|task| {
task.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& task.parent_run_id.is_none()
&& matches!(
task.source.as_str(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
)
&& agent_runtime_supervisor_source_is_trusted(&task.source)
&& matches!(
task.status.as_str(),
"pending"
@@ -383,6 +381,42 @@ fn autonomous_manifest_owner_artifact_gaps_at(
Ok(gaps)
}
fn autonomous_code_prototype_art_asset_reference_gap_at(
root: &Path,
task_id: &str,
) -> Result<Option<String>, String> {
if task_id != "code-prototype" || !editor_api_key_is_configured() {
return Ok(None);
}
let manifest = read_manifest_for_project(root)?;
if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, "art-asset-plan") {
return Ok(Some(format!(
"assets/art-spritesheet.png(canvas-registration-invalid:{})",
sanitize_agent_runtime_text(&error, 240)
)));
}
let Some((_, html)) = read_autonomous_evidence_file_at(
root,
AGENT_RUNTIME_GAME_INDEX_PATH,
"autonomous code-prototype 游戏入口",
AGENT_RUNTIME_AUTONOMOUS_GAME_INDEX_MAX_BYTES,
)?
else {
return Ok(Some(
"game/index.html(missing-art-spritesheet-reference)".to_string(),
));
};
if !html
.windows(b"assets/art-spritesheet.png".len())
.any(|window| window == b"assets/art-spritesheet.png")
{
return Ok(Some(
"game/index.html(missing-art-spritesheet-reference)".to_string(),
));
}
Ok(None)
}
pub(in crate::agent) fn game_creation_app_task_status_label(
status: &GameCreationAppTaskStatus,
) -> String {
@@ -397,7 +431,13 @@ fn autonomous_manifest_parent_completion_gaps_at(
contract: &AgentRuntimeAutonomousCompletionContract,
) -> Result<(Vec<String>, Vec<AutonomousManifestArtifactGap>), String> {
let manifest = read_manifest_for_project(root)?;
let seed_tasks = new_game_creation_app_seed_tasks();
let binding = read_game_creator_agent_runtime_run_profile_binding(
root,
&contract.agent_id,
&contract.run_id,
)?
.ok_or_else(|| "自主构建根 Supervisor Run 缺少 Run Profile 绑定".to_string())?;
let seed_tasks = crate::agent::autonomous_manifest_seed_tasks_for_source(&binding.source);
let mut missing_tasks = Vec::new();
let mut missing_paths = Vec::new();
for seed_task in &seed_tasks {
@@ -416,6 +456,13 @@ fn autonomous_manifest_parent_completion_gaps_at(
contract.baseline_index_sha256.as_deref(),
&contract.baseline_artifacts,
)?);
if seed_task.id == "code-prototype" {
if let Some(gap) =
autonomous_code_prototype_art_asset_reference_gap_at(root, &seed_task.id)?
{
missing_paths.push(AutonomousManifestArtifactGap::new(gap));
}
}
}
missing_paths.sort_by(|left, right| left.summary.cmp(&right.summary));
missing_paths.dedup_by(|left, right| left.summary == right.summary);
@@ -500,6 +547,24 @@ fn autonomous_manifest_ready_task_completion_blocker_at_locked(
));
}
};
match autonomous_code_prototype_art_asset_reference_gap_at(root, &state.agent_id) {
Ok(Some(gap)) => {
return Some(autonomous_completion_blocker(
"code-prototype 必须实际使用平台生成的美术资源",
format!(
"task={} missingPaths={},请先通过 asset.list 核对 Canvas 来源并在 game/index.html 中引用 assets/art-spritesheet.png",
state.agent_id, gap
),
));
}
Ok(None) => {}
Err(error) => {
return Some(autonomous_completion_blocker(
"code-prototype 美术资源引用无法安全核对",
error,
));
}
}
if gaps.is_empty() {
return None;
}
@@ -758,6 +823,7 @@ pub(in crate::agent) fn validate_autonomous_completion_contract(
|| binding.parent_run_id.is_some()
|| binding.root_agent_id != contract.agent_id
|| binding.root_run_id != contract.run_id
|| !agent_runtime_supervisor_source_is_trusted(&binding.source)
{
return Err("自主构建完成合同与根 Supervisor Run 不匹配".to_string());
}
@@ -797,10 +863,7 @@ pub(in crate::agent) fn ensure_autonomous_completion_contract_for_task_at(
return Ok(());
}
if task.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| !matches!(
task.source.as_str(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
)
|| !agent_runtime_supervisor_source_is_trusted(&task.source)
{
return Err("自主构建完成合同只允许可信根 Supervisor Run".to_string());
}
@@ -12,6 +12,65 @@ fn autonomous_fixture(
autonomous_fixture_with_setup(task, run_id, |_| {})
}
fn autonomous_fixture_with_source(
task: &str,
run_id: &str,
source: &str,
) -> (
tempfile::TempDir,
PathBuf,
AgentRuntimeState,
AgentRuntimeAutonomousCompletionContract,
) {
let temporary = tempfile::tempdir().expect("create autonomous fixture root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "autonomous-project", task).expect("init project");
let session_id = resolve_agent_conversation_session_id_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
None,
true,
)
.expect("resolve supervisor session");
let record = append_unique_game_creator_agent_runtime_pending_task(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&session_id,
task,
run_id,
source,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("queue autonomous task");
let state = agent_runtime_state_from_task_record(&record);
let contract = read_autonomous_completion_contract(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
&record.run_id,
)
.expect("read completion contract")
.expect("completion contract exists");
prepare_completed_autonomous_manifest_fixture(&root);
(temporary, root, state, contract)
}
#[test]
fn autonomous_supervisor_source_allowlist_includes_game_chat_only() {
assert!(agent_runtime_supervisor_source_is_trusted(
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE
));
assert!(agent_runtime_supervisor_source_is_trusted(
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
));
assert!(agent_runtime_supervisor_source_is_trusted(
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE
));
assert!(!agent_runtime_supervisor_source_is_trusted(
"project-supervisor-forged"
));
}
fn autonomous_fixture_with_setup(
task: &str,
run_id: &str,
@@ -843,6 +902,80 @@ fn autonomous_parent_completion_lists_missing_seed_tasks_and_formal_artifacts()
assert!(detail.contains("missingPaths=game/balance.json"));
}
#[test]
fn game_chat_parent_completion_stops_after_preview_playtest_without_publish_tasks() {
let _config_guard = crate::tests::write_test_local_config("{}".to_string());
let (_temporary, root, state, contract) = autonomous_fixture_with_source(
"创建一轮植物塔防游戏",
"game-chat-single-round-parent",
AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE,
);
update_manifest_task_status_at(
&root,
"publish-strategy",
GameCreationAppTaskStatus::Pending,
)
.expect("leave publish strategy pending");
update_manifest_task_status_at(&root, "publish-package", GameCreationAppTaskStatus::Pending)
.expect("leave publish package pending");
let revision = advance_game_index_revision(
&root,
&state,
"<!doctype html><html><body><img src=\"assets/art-spritesheet.png\"><canvas></canvas></body></html>",
);
mark_verification_passed(&root, &state, "game.static_smoke");
let result = browser_result_fixture(&root, &state, revision, contract.playtest_scenario);
let action = AgentRuntimeToolAction {
tool: "preview.validate".to_string(),
reason: Some("game-chat single round preview".to_string()),
input: serde_json::json!({}),
};
let action_fingerprint = agent_runtime_tool_action_fingerprint(&action, &state.current_task);
let action_id = agent_runtime_tool_action_id(&state.run_id, 1, 0, 1, &action_fingerprint);
write_autonomous_playtest_receipt_at(
&root,
&contract,
&action_id,
&action_fingerprint,
revision,
&result,
)
.expect("persist game-chat playtest receipt");
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &state).is_none());
}
#[test]
fn code_prototype_requires_registered_canvas_spritesheet_reference_when_editor_is_configured() {
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");
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);
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");
assert!(blocker
.detail
.as_deref()
.is_some_and(|detail| detail.contains("assets/art-spritesheet.png")));
advance_game_index_revision(
&root,
&code_state,
"<!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 autonomous_ready_child_missing_or_invalid_owner_artifact_is_blocked() {
let (_temporary, root, parent_state, _contract) =
@@ -111,10 +111,7 @@ pub(in crate::agent) fn validate_agent_runtime_run_profile_binding_record(
}
if binding.profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& (binding.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
|| !matches!(
binding.source.as_str(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE | AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE
))
|| !agent_runtime_supervisor_source_is_trusted(&binding.source))
{
return Err("自主构建 Run Profile 只允许可信 Supervisor 入口绑定".to_string());
}
@@ -489,6 +489,7 @@ pub(crate) fn start_game_creator_supervisor_runtime_task(
task: String,
run_id: String,
run_profile: Option<String>,
source: Option<String>,
) -> Result<AgentRuntimeResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
@@ -499,12 +500,20 @@ pub(crate) fn start_game_creator_supervisor_runtime_task(
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(AGENT_RUNTIME_RUN_PROFILE_STANDARD);
let source = source
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE);
if !agent_runtime_supervisor_source_is_trusted(source) {
return Err("Project Supervisor 提交 source 不受信任".to_string());
}
start_game_creator_supervisor_background_task_for_session_at(
root,
session_id.as_deref(),
task.trim(),
run_id.trim(),
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
source,
run_profile,
)
}
@@ -663,11 +672,30 @@ pub(crate) fn steer_game_creator_agent_runtime_task(
steer_id: String,
instruction: String,
run_profile: Option<String>,
source: Option<String>,
) -> Result<AgentRuntimeSteerResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
if let Some(source) = source
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
if !agent_runtime_supervisor_source_is_trusted(source) {
return Err("Project Supervisor steer source 不受信任".to_string());
}
let task = read_latest_game_creator_agent_runtime_task_by_run_id(
root,
agent_id.trim(),
run_id.trim(),
)?
.ok_or_else(|| "Agent Runtime steer 的 Run 不存在".to_string())?;
if task.source != source {
return Err("Agent Runtime steer source 与当前 Run 不一致".to_string());
}
}
let mut result = steer_game_creator_agent_runtime_task_for_profile_at(
root,
agent_id.trim(),
@@ -356,6 +356,7 @@ fn steer_and_wait_for_swarm_turn<W: Write>(
steer_id.clone(),
message.to_string(),
Some(run_profile.to_string()),
None,
)?;
writeln!(
output,
@@ -355,6 +355,7 @@ pub(super) fn wait_for_swarm_turn<W: Write>(
steer_id.clone(),
message,
Some(run_profile.to_string()),
None,
)?;
writeln!(
output,
+70 -8
View File
@@ -108,12 +108,14 @@ import {
isRuntimeConfigMissingError,
matchingAgentRuntimeForSteer,
mergeAgentRuntimeStateIntoMap,
mergeGameChatRuntimeResponseMessagesIntoHistory,
mergeProjectSupervisorConversation,
mergeProjectSupervisorResponseStream,
normalizeAgentRuntimeState,
projectNameFromPath,
projectProfessionalAgentLabel,
projectSupervisorPendingRepairMatchesProfessional,
projectSupervisorResponseStreamIdentity,
readProjectSupervisorActiveSessionId,
sameAgentRuntimeRun,
submitProjectSupervisorRuntimeTask,
@@ -573,6 +575,7 @@ export function App({
const gameChatAutoPreviewAttemptedRef = useRef(new Set<string>());
const gameChatObservedRunKeysRef = useRef(new Set<string>());
const gameChatArchivedRunKeysRef = useRef(new Set<string>());
const gameChatCommittedResponseStreamKeysRef = useRef(new Set<string>());
const initialSupervisorMessageLatchRef = useRef({
projectPath: initialProjectPath,
prompt: initialSupervisorMessage.trim(),
@@ -822,7 +825,41 @@ export function App({
runtime,
);
const candidateStream = nextStream;
if (
if (candidateStream?.status === 'ready' && gameChatOnly) {
const text = candidateStream.accumulatedText.trim();
const responseKey = projectSupervisorResponseStreamIdentity(
candidateStream,
);
const messageId = `runtime-response:${responseKey}`;
const alreadyCommitted =
gameChatCommittedResponseStreamKeysRef.current.has(responseKey) ||
latestMessagesRef.current.some(
(message) => message.messageId === messageId,
);
if (text && !alreadyCommitted) {
gameChatCommittedResponseStreamKeysRef.current.add(responseKey);
const nextMessage: ChatMessage = {
role: 'assistant',
text: candidateStream.accumulatedText,
messageId,
agentId: PROJECT_SUPERVISOR_AGENT_ID,
updatedAt: candidateStream.updatedAt,
runtimeOwned: true,
};
setMessages((current) => {
if (current.some((message) => message.messageId === messageId)) {
latestMessagesRef.current = current;
return current;
}
const nextMessages = [...current, nextMessage];
latestMessagesRef.current = nextMessages;
return nextMessages;
});
}
// Ready text is now a normal chat message; transientReply must not show
// the same response a second time while polling/event delivery catches up.
nextStream = null;
} else if (
candidateStream &&
latestMessagesRef.current.some(
(message) =>
@@ -833,6 +870,7 @@ export function App({
message.updatedAt >= candidateStream.startedAt,
)
) {
// Keep the existing supervisor-chat behavior for restored history.
nextStream = null;
}
projectSupervisorResponseStreamRef.current = nextStream;
@@ -846,6 +884,7 @@ export function App({
projectSupervisorRuntimeRef.current = null;
projectSupervisorExpectedRunIdRef.current = null;
projectSupervisorResponseStreamRef.current = null;
gameChatCommittedResponseStreamKeysRef.current.clear();
projectSupervisorRuntimeSyncingRef.current.clear();
setProjectSupervisorSessionId(null);
setProjectSupervisorRuntime(null);
@@ -2445,6 +2484,7 @@ export function App({
);
const transientResponse = projectSupervisorResponseStreamRef.current;
if (
!gameChatOnly &&
conversationContainsProjectSupervisorResponseStream(
supervisorConversation.messages,
transientResponse,
@@ -2453,11 +2493,23 @@ export function App({
projectSupervisorResponseStreamRef.current = null;
setProjectSupervisorResponseStream(null);
}
savedConversationProjectPathRef.current = nextProjectPath;
savedConversationCountRef.current = conversationMessages.length;
latestMessagesRef.current = conversationMessages;
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
setMessages(conversationMessages);
setMessages((current) => {
// React may apply this hydration update after a ready stream callback
// that was queued in the same turn. Read `current` inside the updater
// so a committed game-chat response cannot be overwritten by stale
// `latestMessagesRef` state captured before that callback ran.
const nextMessages = gameChatOnly
? mergeGameChatRuntimeResponseMessagesIntoHistory(
conversationMessages,
current,
)
: conversationMessages;
savedConversationProjectPathRef.current = nextProjectPath;
savedConversationCountRef.current = nextMessages.length;
latestMessagesRef.current = nextMessages;
return nextMessages;
});
const terminalRuntime = projectSupervisorRuntimeRef.current;
if (
runId &&
@@ -2557,6 +2609,7 @@ export function App({
supervisorConversation?.messages ?? [],
);
if (
!gameChatOnly &&
conversationContainsProjectSupervisorResponseStream(
supervisorConversation?.messages ?? [],
runtimeResponseStream,
@@ -2575,6 +2628,12 @@ export function App({
}
setProjectSupervisorRuntimeError(runtimeError || resumeError);
setMessages((current) => {
const nextConversationMessages = gameChatOnly
? mergeGameChatRuntimeResponseMessagesIntoHistory(
conversationMessages,
current,
)
: conversationMessages;
const hasOnlyDefaultGreeting =
current.length === 1 &&
current[0]?.role === 'assistant' &&
@@ -2594,8 +2653,8 @@ export function App({
}
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
savedConversationProjectPathRef.current = nextProjectPath;
savedConversationCountRef.current = conversationMessages.length;
latestMessagesRef.current = conversationMessages;
savedConversationCountRef.current = nextConversationMessages.length;
latestMessagesRef.current = nextConversationMessages;
setWorkspaceStatus((workspaceStatus) => {
if (mode === 'replace') {
return `已读取项目对话历史:${conversationMessages.length}`;
@@ -2604,7 +2663,7 @@ export function App({
? `已打开:${nextProjectPath}`
: workspaceStatus;
});
return conversationMessages;
return nextConversationMessages;
});
} catch (error) {
if (
@@ -5366,6 +5425,9 @@ export function App({
prompt,
runtime: runtimeAtSubmission,
runProfile: submissionRunProfile,
...(gameChatOnly
? { source: 'project-supervisor-game-chat' }
: {}),
});
const runtimeResult = submission.runtimeResult;
const acceptedRunId = submission.acceptedRunId.trim();
@@ -477,6 +477,22 @@ export function sameProjectSupervisorResponseStream(
);
}
/**
* Durable identity for one final-reply response. The response text is
* intentionally excluded so two turns with identical wording remain
* distinct messages in game-chat.
*/
export function projectSupervisorResponseStreamIdentity(
stream: Pick<
AgentRuntimeResponseStream,
'runId' | 'requestSlot' | 'responseRevision'
>,
) {
return [stream.runId, stream.requestSlot, stream.responseRevision].join(
'\u001f',
);
}
export function mergeProjectSupervisorResponseStream(
current: AgentRuntimeResponseStream | null,
incoming: AgentRuntimeResponseStream | null | undefined,
@@ -523,6 +539,44 @@ export function conversationContainsProjectSupervisorResponseStream(
);
}
/**
* Keep final responses that were committed into the game-chat window while
* conversation history is being hydrated. Runtime responses have a durable
* local id; matching by text or timestamp would collapse two different runs
* that happen to produce the same wording.
*/
export function mergeGameChatRuntimeResponseMessagesIntoHistory(
historyMessages: ChatMessage[],
currentMessages: ChatMessage[],
) {
const historyMessageIds = new Set(
historyMessages
.map((message) => message.messageId?.trim())
.filter((messageId): messageId is string => Boolean(messageId)),
);
const addedMessageIds = new Set<string>();
const responsesToKeep = currentMessages.filter((message) => {
const messageId = message.messageId?.trim();
if (
!message.runtimeOwned ||
message.role !== 'assistant' ||
!messageId?.startsWith('runtime-response:') ||
historyMessageIds.has(messageId) ||
addedMessageIds.has(messageId)
) {
return false;
}
addedMessageIds.add(messageId);
return true;
});
if (responsesToKeep.length === 0) {
return historyMessages;
}
return [...historyMessages, ...responsesToKeep].sort(
(left, right) => (left.updatedAt ?? 0) - (right.updatedAt ?? 0),
);
}
export function agentRuntimeWaitingOnFromPhase(phase: string) {
switch (phase) {
case 'planning':
@@ -765,6 +819,7 @@ export async function submitProjectSupervisorRuntimeTask({
prompt,
runtime,
runProfile,
source,
}: {
invoke: TauriInvoke;
projectPath: string;
@@ -772,6 +827,7 @@ export async function submitProjectSupervisorRuntimeTask({
prompt: string;
runtime: AgentRuntimeState | null;
runProfile: 'standard' | 'autonomous-game-build';
source?: string;
}) {
const steerRuntime = matchingAgentRuntimeForSteer(
[runtime],
@@ -790,6 +846,7 @@ export async function submitProjectSupervisorRuntimeTask({
steerId: createAgentChatRunId('project-supervisor-steer'),
instruction: prompt,
runProfile,
...(source ? { source } : {}),
},
);
return {
@@ -807,6 +864,7 @@ export async function submitProjectSupervisorRuntimeTask({
task: prompt,
runId: requestedRunId,
runProfile,
...(source ? { source } : {}),
},
);
return {
@@ -1,14 +1,221 @@
import { describe, expect, test } from 'vitest';
import { describe, expect, test, vi } from 'vitest';
import type { AgentRuntimeState } from '../src/app/types';
import type {
AgentRuntimeResponseStream,
AgentRuntimeResult,
AgentRuntimeState,
ChatMessage,
} from '../src/app/types';
import {
formatAgentRuntimeEvent,
projectRuntimeVisibleCurrentWork,
projectRuntimeVisibleError,
projectSupervisorChatRuntimeStatus,
projectSupervisorResponseStreamIdentity,
projectSupervisorVisibleConversationText,
mergeGameChatRuntimeResponseMessagesIntoHistory,
submitProjectSupervisorRuntimeTask,
} from '../src/features/agent-runtime/model';
describe('Game Chat stream identity and source', () => {
test('response stream identity binds run, slot, and revision', () => {
const base: AgentRuntimeResponseStream = {
schemaVersion: 'game-creator-runtime-response-stream.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'session-1',
runId: 'run-1',
requestKind: 'final-reply',
requestSlot: 'final-reply-loop-1-revision-0',
appliedSteerCursor: 0,
responseRevision: 0,
sequence: 1,
status: 'ready',
accumulatedText: 'same text',
finishReason: 'stop',
startedAt: 1,
updatedAt: 2,
};
expect(projectSupervisorResponseStreamIdentity(base)).toBe(
'run-1\u001ffinal-reply-loop-1-revision-0\u001f0',
);
expect(
projectSupervisorResponseStreamIdentity({ ...base, runId: 'run-2' }),
).not.toBe(projectSupervisorResponseStreamIdentity(base));
expect(
projectSupervisorResponseStreamIdentity({
...base,
requestSlot: 'final-reply-loop-2-revision-0',
}),
).not.toBe(projectSupervisorResponseStreamIdentity(base));
expect(
projectSupervisorResponseStreamIdentity({ ...base, responseRevision: 1 }),
).not.toBe(projectSupervisorResponseStreamIdentity(base));
});
test('game-chat source is forwarded for start and steer without changing default source behavior', async () => {
const runtimeResult = {
state: {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'project-supervisor',
taskId: 'project-supervisor',
sessionId: 'session-provider-retry',
runId: 'accepted-run',
source: 'project-supervisor-chat',
status: 'running',
phase: 'planning',
currentTask: null,
currentAction: null,
waitingOn: null,
nextStep: null,
plan: [],
planSteps: [],
observations: [],
allowedTools: [],
lastResponse: null,
error: null,
updatedAt: 1,
},
sessionPath: 'session.json',
eventPath: 'events.jsonl',
} satisfies AgentRuntimeResult;
const invoke = vi.fn(
async (_command: string, _args?: Record<string, unknown>) => runtimeResult,
);
await submitProjectSupervisorRuntimeTask({
invoke,
projectPath: '/tmp/game-chat',
sessionId: 'session-provider-retry',
prompt: 'make a game',
runtime: null,
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
});
expect(invoke).toHaveBeenCalledWith(
'start_game_creator_supervisor_runtime_task',
expect.objectContaining({ source: 'project-supervisor-game-chat' }),
);
invoke.mockClear();
await submitProjectSupervisorRuntimeTask({
invoke,
projectPath: '/tmp/game-chat',
sessionId: 'session-provider-retry',
prompt: 'chat',
runtime: null,
runProfile: 'standard',
});
expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source');
invoke.mockClear();
const steerRuntime = {
...runtimeResult.state,
runId: 'steer-run',
runProfile: 'autonomous-game-build',
status: 'running',
phase: 'planning',
} satisfies AgentRuntimeState;
invoke.mockResolvedValueOnce({
runtime: runtimeResult,
steerId: 'steer-1',
sequence: 1,
status: 'applied',
providerInterrupted: false,
});
await submitProjectSupervisorRuntimeTask({
invoke,
projectPath: '/tmp/game-chat',
sessionId: 'session-provider-retry',
prompt: 'continue this run',
runtime: steerRuntime,
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
});
expect(invoke).toHaveBeenCalledWith(
'steer_game_creator_agent_runtime_task',
expect.objectContaining({ source: 'project-supervisor-game-chat' }),
);
invoke.mockClear();
invoke.mockResolvedValueOnce({
runtime: runtimeResult,
steerId: 'steer-2',
sequence: 2,
status: 'applied',
providerInterrupted: false,
});
await submitProjectSupervisorRuntimeTask({
invoke,
projectPath: '/tmp/game-chat',
sessionId: 'session-provider-retry',
prompt: 'continue this run',
runtime: steerRuntime,
runProfile: 'autonomous-game-build',
});
expect(invoke.mock.calls[0]?.[1]).not.toHaveProperty('source');
});
test('game-chat hydration keeps identical text from a different runtime response identity', () => {
const history = [
{
role: 'assistant' as const,
text: 'same text',
messageId: 'persisted-message-1',
updatedAt: 20,
},
];
const pending = [
{
role: 'assistant' as const,
text: 'same text',
messageId: 'runtime-response:run-2\u001fslot-1\u001f0',
updatedAt: 10,
runtimeOwned: true,
},
];
expect(mergeGameChatRuntimeResponseMessagesIntoHistory(history, pending)).toEqual([
pending[0],
history[0],
]);
expect(
mergeGameChatRuntimeResponseMessagesIntoHistory(history, [
{ ...pending[0], messageId: 'persisted-message-1' },
]),
).toEqual(history);
});
test('ready response survives either ordering of runtime commit and conversation hydration', () => {
const history = [
{
role: 'user' as const,
text: 'build a game',
messageId: 'persisted-message-1',
updatedAt: 1,
},
];
const ready = {
role: 'assistant' as const,
text: 'ready response',
messageId: 'runtime-response:run-1\u001fslot-1\u001f0',
updatedAt: 2,
runtimeOwned: true,
};
const commitReady = (current: ChatMessage[]) =>
current.some((message) => message.messageId === ready.messageId)
? current
: [...current, ready];
const hydrateConversation = (current: ChatMessage[]) =>
mergeGameChatRuntimeResponseMessagesIntoHistory(history, current);
expect(hydrateConversation(commitReady([]))).toContainEqual(ready);
expect(commitReady(hydrateConversation([]))).toContainEqual(ready);
});
});
function providerRetryRuntime(): AgentRuntimeState {
return {
schemaVersion: 'game-creator-agent-runtime.v1',
@@ -3916,6 +3916,7 @@ export function registerProjectSupervisorSurfaceTests() {
task: '生成第一版可玩原型',
runId: expect.stringMatching(/^project-supervisor-task-/),
runProfile: 'autonomous-game-build',
source: 'project-supervisor-game-chat',
},
);
});
@@ -5868,3 +5868,11 @@
- 生产接入:AGC Agent interaction 的 stream、普通请求及原 stream-unavailable/empty/deserialize fallback 已改由 `ProviderRegistry` 执行;对外 `LlmStreamDelta`、function name/schema、错误文案、Runner IPC 和 Provider retry/handoff/finalization 持久协议不变。
- 扩展边界:新 Provider 可直接实现 core `ProviderAdapter` 并注册,不修改 core enum/match。`platform-llm` 当前 DTO 不支持的 tool role/result、toolChoice none/specific 和 reasoning minimal/x-high 在 adapter 转换层零网络失败关闭;工具调用仍以最终 response 为权威。
- 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.50。
## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用
- 背景:game-chat 的 ready response 之前只作为 transient stream 展示,刷新或事件 / 轮询重放时可能丢失;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。
- 决策:game-chat 为每条 Supervisor ready 输出分配由 `runId + requestSlot + responseRevision` 组成的稳定 `runtime-response:*` 消息 ID,并在事件、轮询、StrictMode 和 hydration 中按 ID 幂等固化到聊天框;普通 `supervisor-chat` 不改变。可信 source `project-supervisor-game-chat` 的 seed task 截断在 `preview-playtest`,试玩成功后完成门只验收保留任务、最新 revision、`game.static_smoke``preview.validate`,不再调度 `publish-strategy` / `publish-package`GUI / CLI 仍执行完整 DAG。
- 美术资源门禁:External Editor API 有效时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 真实引用该文件;manifest、文件或 HTML 引用任一缺失均拒绝完成。确定性 Provider fixture 也必须带该引用,不能用占位内容绕过门禁。
- 验证:`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`
@@ -81,6 +81,15 @@ 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 不清新 generationRunner registry 单独 running、失败 / 相同 / 更低 revision 均不能触发用户预览或重复刷新,停止后顶部显示“预览未启动”。独立包退出时必须通过 `runner.shutdown_for_client_exit` 先进入 draining 再结束本 boot,保留 durable sidecar 供下次 reconciliation,不把中断任务写成 completedWindows 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 输出都以 `runId + requestSlot + responseRevision` 组成的 durable message ID 固化在聊天框,事件 / 轮询 / hydration 重放不重复;可信 `project-supervisor-game-chat` 一轮完成 `preview-playtest` 后直接收束,不调度 `publish-strategy` / `publish-package`;配置 External Editor API 时,`code-prototype``game/index.html` 实际引用已由 Canvas 登记的 `assets/art-spritesheet.png`,缺少登记、文件或引用必须失败关闭。对应定向测试至少包括:
```bash
npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run
cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml autonomous_completion_contract -- --nocapture --test-threads=1
```
source allowlist、game-chat 单轮完成门和 Canvas spritesheet 引用门禁均须命中实际测试;若完整 Rust suite 受 Windows `os error 32` 既有文件锁竞态影响,应单独复跑新增 filter 并如实记录,不能把锁竞态失败改报为本次改动通过。
Windows release 的非交互后台命令统一使用 `CREATE_NO_WINDOW`,包括 `command.exec / project.verify`、STDIO MCP、Repository Context Git、`git.inspect / project.git_commit``taskkill` 清理命令;需要进程组终止时再叠加 `CREATE_NEW_PROCESS_GROUP`,不要使用 `DETACHED_PROCESS`。smoke 时应在实际任务运行期间观察无额外控制台窗口,并在关闭客户端后核对整棵后台进程树为零,再重启确认 reconciliation 可继续。
Provider 失败回归必须同时检查等待态和耗尽态:用本地 mock 503 证明 Runtime 从 durable retry record 显示精确 HTTP 状态、真实 `nextAttempt/maxRetries` 和退避秒数;最后一次重试仍失败时,状态卡与持久 conversation 只显示安全中文摘要。测试正文应包含诱饵 Provider URL/query、API Key 和 Windows / Unix 绝对路径,并断言这些正文、内部 fingerprint / chars 与 `[redacted ...]` 占位符均未进入用户可见消息;不能只验证状态卡而漏掉 `SupervisorChatOnlyView` 直接渲染的 conversation。
@@ -50,6 +50,13 @@
- 系统边界:该页面是既有 AI 游戏创作工作台的独立构建例外,不新增平台玩法入口、后端 API、会话库、Runner 或预览服务,也不把入口并回普通正式客户端。原 `supervisor-chat` 继续固定使用 `standard` profile 并保持纯聊天行为,不继承本例外的自主构建、事件聚合或自动预览授权。
- 验证要求:每次修改该 flavor 至少执行壳配置门禁、AppSurface game-chat 定向测试、前端类型检查、AppData / 诊断日志 / release flavor 相关 Rust 定向测试、`npm run check:encoding``git diff --check`;正式打包必须执行 `npm run agc:build:game-chat-release`,安装或启动产物后确认首屏只能进入本地 game-chat、断开或停止 `api-server` 仍可进入工作台、普通/debug 认证未变化、AppData 使用独立 identifier,并分别 smoke“新目录 owner / DACL 正确”“历史 foreign owner 原目录被同级备份且配置不覆盖”“foreign-owner stale `agent-runner.lock` 在独占句柄下立即修复并启动”“活锁不被截断或接管”“reparse point / hardlink 失败关闭”“AppData 不可写时日志回退 TEMP”“`.setup()` 初始化失败显示日志位置对话框”“503 等待态显示真实 HTTP 状态、重试次数和退避秒数”“503 耗尽后状态卡与 conversation 只显示安全摘要”“后台命令执行期间不出现控制台窗口”“启动活跃任务后关闭主窗口,Runner、MCP、command、ConPTY 及孙进程均消失”“再次启动后 durable 状态进入正确 reconciliation”。日志检查必须同时验证 256 KiB 轮转、仅一份 previous、凭据和绝对路径零泄漏。
## 2026-07-31 game-chat 输出、单轮预览与平台美术资源
- 对话输出:game-chat 的 Supervisor `ready` response stream 以 `runId + requestSlot + responseRevision` 形成稳定 durable message ID,最终每条输出都追加到聊天框;事件、轮询、React StrictMode 重放和 hydration 只按该 ID 去重,不以正文或时间戳合并不同 Run 的相同回复。普通 `supervisor-chat` 保持原有 transient response 行为。
- 单轮收束:game-chat source 只生成至 `preview-playtest` 的 manifest seed task,试玩完成后父 Run 直接进入完成门,不再调度 `publish-strategy` / `publish-package`;普通 GUI / CLI 仍执行完整发布 DAG。完成门仍要求当前 revision、`game.static_smoke``preview.validate` 结构化证据。
- 平台美术资源:配置 External Editor API 时,`code-prototype` 必须通过 `asset.list` 核对 Canvas 登记的 `assets/art-spritesheet.png`,并在 `game/index.html` 实际引用该路径;缺少登记、文件或引用均 fail-closed。确定性 Provider fixture 同步输出图集引用,避免测试绕过该门禁。
- 验证:前端运行时模型定向测试、Rust completion/source/asset 合同测试、`cargo fmt --check``npm run check:encoding``git diff --check` 必须全部执行;Windows 文件锁竞态只可作为既有测试失败单独记录,不得将其改写为本次改动的通过证据。
## Runtime 边界
V1.11 的受保护仓库控制目录同时包含 `.git / .agent / .agents / .codex / .hermes`;其中 `.agent` 对项目命令隐藏,其余控制目录只读。