修复 AGC 长回合鉴权与素材验收循环
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 4m39s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 4m50s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 4m18s
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled

修复 DirectProject 长回合平台会话保活与 401 分类

允许已登记普通平台图片通过运行时素材完成门禁

兼容预览 UUID 图片路径并补充技术方案说明
This commit is contained in:
kdletters
2026-09-15 22:16:12 +08:00
parent 361daf374d
commit c7ba007463
5 changed files with 123 additions and 5 deletions
@@ -2353,6 +2353,12 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
.map(|asset| asset.local_path),
);
}
// Ordinary platform images generated by `agc_generate_image` are valid
// runtime art even when the project does not contain the canonical
// art-spec/background/spritesheet package. The old check only admitted
// those canonical paths, so a game using registered images such as
// `assets/neon-mine.png` was forced through repeated repair turns forever.
available_paths.extend(direct_registered_taonier_runtime_image_paths(root));
available_paths.sort();
available_paths.dedup();
available_paths
@@ -2361,6 +2367,34 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
.collect()
}
fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec<String> {
let Ok(manifest) = read_manifest_for_project(root) else {
return Vec::new();
};
manifest
.assets
.iter()
.filter(|asset| {
asset.media_type.starts_with("image/")
&& asset.kind != "icon-spec"
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
&& asset
.source
.generation_route
.as_deref()
.is_some_and(|route| route.starts_with("/api/external/v1/editor/"))
})
.filter_map(|asset| {
let path = direct_normalized_project_asset_path(&asset.local_path)?;
let bytes = std::fs::read(root.join(&path)).ok()?;
let validated =
validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {path}"))
.ok()?;
validated.has_visible_pixels.then_some(path)
})
.collect()
}
fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool {
!direct_game_sources_referenced_taonier_assets(root).is_empty()
}
@@ -2416,11 +2450,21 @@ fn direct_browser_evidence_needs_art_repair(
let Some(evidence) = evidence else {
return false;
};
let referenced_count = direct_game_sources_referenced_taonier_assets(root).len();
evidence.passed
&& evidence
.viewport_results
.iter()
.any(|viewport| direct_rendered_taonier_assets_in_viewport(root, viewport).is_empty())
&& evidence.viewport_results.iter().any(|viewport| {
let exact_matches = direct_rendered_taonier_assets_in_viewport(root, viewport);
if !exact_matches.is_empty() {
return false;
}
// The preview server rewrites local asset URLs to opaque UUID
// routes. When the browser probe cannot map those routes back to
// project-relative paths, use its count as a bounded observation:
// every referenced platform image must have a corresponding
// rendered local-image route in this viewport.
let rendered_count = direct_browser_rendered_image_paths(viewport).len();
referenced_count == 0 || rendered_count < referenced_count
})
}
/// The only output-shape proof the direct Runtime owns. It deliberately does
@@ -3790,7 +3834,16 @@ fn render_direct_browser_acceptance_report(
};
let assets = direct_rendered_taonier_assets_in_viewport(root, viewport);
if assets.is_empty() {
format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染")
let rendered_count = direct_browser_rendered_image_paths(viewport).len();
if rendered_count > 0
&& !direct_game_sources_referenced_taonier_assets(root).is_empty()
{
format!(
"{name}: 已观察到 {rendered_count} 个本地图片资源进入 Canvas/WebGL 渲染"
)
} else {
format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染")
}
} else {
format!("{name}: {}", assets.join(""))
}
@@ -7854,6 +7907,28 @@ mod tests {
.is_some_and(|error| error.contains("未在源码中引用任何已登记的陶泥儿平台图片")));
}
#[test]
fn direct_completion_accepts_an_independent_registered_platform_image() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-independent-image", "独立平台图片")
.expect("init project");
std::fs::create_dir_all(root.path().join("assets")).expect("assets dir");
register_direct_taonier_art_asset_fixture(root.path(), "assets/neon-mine.png", "image");
std::fs::write(root.path().join("game/index.html"), "<!doctype html>").expect("index");
std::fs::write(root.path().join("game/style.css"), "body {} ").expect("style");
std::fs::write(
root.path().join("game/game.js"),
"const mine = new Image(); mine.src = '/assets/neon-mine.png';",
)
.expect("script");
assert_eq!(
direct_game_sources_referenced_taonier_assets(root.path()),
vec!["assets/neon-mine.png".to_string()]
);
assert!(direct_game_output_completion_error(root.path()).is_none());
}
#[test]
fn direct_completion_accepts_a_registered_independent_slice_reference() {
let root = tempfile::tempdir().expect("temp dir");
@@ -1914,6 +1914,9 @@ async fn bridge_remove_background(state: &DirectToolBridgeState, arguments: &Val
.await
.map_err(|error| format!("抠图服务响应无法解析:{error}"))?;
if !status.is_success() {
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err("authentication-required: 抠图服务提交失败:HTTP 401".to_string());
}
return Err(format!("抠图服务提交失败:HTTP {}", status.as_u16()));
}
let queue_state = external_editor_response_data(&payload).clone();
@@ -868,6 +868,12 @@ pub(crate) async fn external_editor_json_request(
/// entire response body (which may contain URLs, ids, paths or credentials).
fn format_external_http_error(action: &str, status: reqwest::StatusCode, body: &str) -> String {
let detail = summarize_external_http_error_body(body);
if status == reqwest::StatusCode::UNAUTHORIZED {
return match detail {
Some(detail) => format!("authentication-required: {action}失败:HTTP 401{detail}"),
None => format!("authentication-required: {action}失败:HTTP 401"),
};
}
match detail {
Some(detail) => format!("{action}失败:HTTP {}{detail}", status.as_u16()),
None => format!("{action}失败:HTTP {}", status.as_u16()),
@@ -7930,6 +7936,16 @@ mod canvas_generation_tests {
assert!(!summary.contains("C:\\Users\\private"), "{summary}");
}
#[test]
fn external_http_401_is_classified_as_authentication_required() {
let error =
format_external_http_error("提交平台图片生成", reqwest::StatusCode::UNAUTHORIZED, "");
assert_eq!(
error,
"authentication-required: 提交平台图片生成失败:HTTP 401"
);
}
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_nonblocking(false)
+18
View File
@@ -286,6 +286,11 @@ const DIRECT_CODEX_PRODUCT_RUNTIME = true;
const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:';
const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX =
'direct-codex-turn-already-running:';
// Platform access tokens are short lived. DirectProject can spend several
// minutes in image generation, build and browser validation, so keep the
// client-owned native session current while a turn is running. The singleflight
// refresh in platformSession.ts coalesces this with any 401-triggered refresh.
const DIRECT_CODEX_SESSION_KEEPALIVE_MS = 5 * 60 * 1000;
function isDirectCodexAuthenticationRequired(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
@@ -1881,6 +1886,19 @@ export function App({
};
}, [directCodexProductRuntime]);
useEffect(() => {
if (!directCodexProductRuntime || !chatAgentBusy) {
return;
}
const timer = window.setInterval(() => {
void requestPlatformSessionRefresh().catch(() => {
// The active DirectProject turn will surface the original auth error;
// keepalive must not replace it with an unrelated background error.
});
}, DIRECT_CODEX_SESSION_KEEPALIVE_MS);
return () => window.clearInterval(timer);
}, [chatAgentBusy, directCodexProductRuntime]);
useEffect(() => {
if (projectSupervisorOnly && !directCodexProductRuntime) {
return;
@@ -1,5 +1,11 @@
# AI 游戏创作智能体 App 实施计划
## 2026-09-15 DirectProject 长回合平台会话保活
DirectProject 的生图、素材处理、构建和试玩可能跨越短生命周期 access token 的有效期。普通 `/api/*` 请求和 Codex app-server 已有 401 刷新路径,但 AGC 工具由 Rust 工具桥直接使用客户端当前会话,工具内部的 401 不会自动触发前端刷新。客户端在 DirectProject 回合处于 busy 状态时每 5 分钟调用现有 `requestPlatformSessionRefresh()`;刷新仍复用单飞请求、generation 校验和 native session 安装,不改变凭据来源,也不把 401 降级为成功。刷新失败保持静默,由原始 AGC 工具错误按现有鉴权失败合同返回,避免后台保活覆盖真实错误。
完成门禁同时允许已登记的普通平台图片作为运行时素材。此前只把 canonical art-spec、背景、图集和图集切片加入来源白名单;`agc_generate_image` 生成的 `assets/neon-*.png` 即使已经登记并被源码引用,也会被判成“未引用平台图片”,触发同一回合的重复修复。浏览器预览把本地图片 URL 改写成 UUID 路径时,验收按每个视口的已渲染本地图片数量与源码引用数量做有界匹配;仍要求两个视口都有对应观察,空视口继续进入修复。
## 2026-09-12 已有项目打开响应性
DirectProject 工作区只恢复自身对话,不按专业 Agent 默认任务占位行批量读取旧会话或生成专业 Agent 文本回执。专业 Agent 结果加载 effect 必须以当前 Runtime 模式为边界,并在模式切换时清空旧结果。仍供开发入口使用的 `read_local_conversation` 在 blocking worker 内完整执行权限校验、会话目录解析和历史读取,避免文件访问或锁等待阻塞 Tauri 窗口线程。