Merge branch 'master' into opt/design-simplify
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m30s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m46s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 6m7s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m21s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m9s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m33s
Project CI / Repository checks (pull_request) Successful in 4m2s
Project CI / Frontend tests (pull_request) Successful in 4m49s
Project CI / Native shell tests (pull_request) Successful in 7m32s
Project CI / Backend tests (pull_request) Successful in 7m58s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m21s

This commit is contained in:
2026-09-15 21:50:47 +08:00
14 changed files with 561 additions and 36 deletions
@@ -28,6 +28,7 @@ mod prompt;
mod runtime_actions;
mod runtime_adapter;
mod runtime_driver;
mod runtime_error;
mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
@@ -56,6 +57,7 @@ pub(crate) use prompt::*;
pub(crate) use runtime_actions::*;
pub(crate) use runtime_adapter::*;
pub(crate) use runtime_driver::*;
pub(crate) use runtime_error::*;
pub(crate) use runtime_protocol::*;
pub(crate) use runtime_state::*;
pub(crate) use runtime_tools::*;
@@ -271,6 +271,51 @@ fn game_creator_codex_app_server_error_kind(kind: &str) -> platform_llm::LlmErro
))
}
fn game_creator_codex_app_server_error_kind_with_machine_detail(
kind: &str,
error: &serde_json::Value,
) -> platform_llm::LlmError {
let mut fields = Vec::new();
if let Some(object) = error.as_object() {
if let Some(code) = object.get("code").and_then(serde_json::Value::as_str) {
if !code.is_empty()
&& code.len() <= 80
&& code
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
{
fields.push(format!("code={code}"));
}
}
let keys = object
.keys()
.filter(|key| {
matches!(
key.as_str(),
"httpConnectionFailed"
| "responseStreamConnectionFailed"
| "responseStreamDisconnected"
| "responseTooManyFailedAttempts"
| "activeTurnNotSteerable"
| "codexErrorInfo"
)
})
.cloned()
.collect::<Vec<_>>();
if !keys.is_empty() {
fields.push(format!("fields={}", keys.join(",")));
}
}
let suffix = if fields.is_empty() {
String::new()
} else {
format!(" detail={}", fields.join(" "))
};
platform_llm::LlmError::InvalidRequest(format!(
"{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}{kind}{suffix}"
))
}
fn game_creator_codex_app_server_error_http_status(
info: &serde_json::Value,
field: &str,
@@ -425,7 +470,7 @@ fn game_creator_codex_app_server_failed_turn_error(
return game_creator_codex_app_server_error_kind("unauthorized");
}
let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else {
return game_creator_codex_app_server_error_kind("other");
return game_creator_codex_app_server_error_kind_with_machine_detail("other", error);
};
if let Some(kind) = info.as_str() {
return match kind {
@@ -449,8 +494,8 @@ fn game_creator_codex_app_server_failed_turn_error(
game_creator_codex_app_server_error_kind("thread-rollback-failed")
}
"sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"),
"other" => game_creator_codex_app_server_error_kind("other"),
_ => game_creator_codex_app_server_error_kind("other"),
"other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error),
_ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error),
};
}
for field in [
@@ -466,7 +511,7 @@ fn game_creator_codex_app_server_failed_turn_error(
if info.get("activeTurnNotSteerable").is_some() {
return game_creator_codex_app_server_error_kind("active-turn-not-steerable");
}
game_creator_codex_app_server_error_kind("other")
game_creator_codex_app_server_error_kind_with_machine_detail("other", error)
}
async fn isolate_game_creator_codex_app_server_terminal_unknown(
@@ -1905,7 +1905,11 @@ fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool {
|| normalized.contains("insufficient-mud-points")
}
fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String {
fn record_direct_codex_turn_failure(
root: &Path,
failure: DirectCodexTurnFailure,
client_turn_id: Option<&str>,
) -> String {
let summary = direct_codex_failure_public_summary(&failure.error)
.map(str::to_string)
.unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320));
@@ -1942,18 +1946,53 @@ fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure
} else {
"未能保存项目诊断"
};
format!(
"direct-codex-failure:v1 stage={} retryable={} summary={};建议:{}{}",
let error_code = classify_direct_codex_error(&failure.error);
let unified_detail_ref = persist_agent_runtime_error(
root,
client_turn_id,
"direct-codex",
failure.stage.id(),
error_code,
retryable,
&summary,
recovery_hint,
&failure.error,
None,
serde_json::json!({
"legacyDiagnosticWritten": diagnostic_written,
}),
)
.ok()
.map(|event| event.detail_ref);
format!(
"direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{}{}{}",
failure.stage.id(),
error_code,
retryable,
diagnostic["summary"]
.as_str()
.unwrap_or("未提供可安全展示的详细原因"),
recovery_hint,
diagnostics_suffix,
unified_detail_ref
.map(|path| format!(";详情:{path}"))
.unwrap_or_default(),
)
}
fn persist_direct_codex_failure_context(
root: &Path,
client_turn_id: &str,
error: &str,
) -> Result<(), String> {
let item = direct_project_local_message_item(
"assistant",
error,
Some(&format!("direct-codex:{client_turn_id}:failure")),
)?;
append_direct_project_history_item_at(root, &item)
}
fn direct_taonier_art_generation_runtime_context(
root: &Path,
output_path: &str,
@@ -2269,9 +2308,19 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
}
fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
let sources = direct_codex_game_outputs(root)
let mut source_paths = direct_codex_game_outputs(root)
.into_iter()
.filter_map(|(relative_path, _, _)| std::fs::read_to_string(root.join(relative_path)).ok())
.map(|(relative_path, _, _)| relative_path)
.collect::<Vec<_>>();
// npm/Phaser projects put the actual scene and loader code below `game/src`.
// Keep the canonical output list for manifest projection, but scan the
// complete bounded source list for the asset reference contract.
source_paths.extend(direct_npm_source_paths(root));
source_paths.sort();
source_paths.dedup();
let sources = source_paths
.into_iter()
.filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok())
.collect::<Vec<_>>();
let mut available_paths = Vec::new();
if direct_taonier_art_base_is_valid(root) {
@@ -2284,6 +2333,28 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<String> {
available_paths.push(DIRECT_CODEX_SPRITESHEET_ASSET_PATH.to_string());
}
available_paths.extend(direct_registered_taonier_slice_paths(root));
// A project may have a valid, client-registered art-spritesheet at a
// project-specific path (for example a generated building sheet). The
// fixed canonical package paths above are compatibility candidates only;
// the manifest is the authority for additional runtime image identities.
if let Ok(manifest) = read_manifest_for_project(root) {
available_paths.extend(
manifest
.assets
.into_iter()
.filter(|asset| {
matches!(
asset.kind.as_str(),
"art-spritesheet" | "art-spritesheet-slice" | "game-background"
) && asset.media_type == "image/png"
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
&& asset.local_path.starts_with("assets/")
})
.map(|asset| asset.local_path),
);
}
available_paths.sort();
available_paths.dedup();
available_paths
.into_iter()
.filter(|path| sources.iter().any(|source| source.contains(path.as_str())))
@@ -4087,7 +4158,17 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
{
Ok(reply) => Ok(reply),
Err(failure) => {
let error = record_direct_codex_turn_failure(root, failure);
let error = record_direct_codex_turn_failure(
root,
failure,
turn_emitter.map(|emitter| emitter.turn_id()),
);
if let Some(emitter) = turn_emitter {
// Persist the safe terminal projection so the next DirectProject
// turn can answer a diagnostic question from evidence instead of
// guessing or starting another playtest.
let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error);
}
if let Some(emitter) = turn_emitter {
emitter.emit("failed", Some("none"), None);
}
@@ -6620,26 +6701,27 @@ mod tests {
#[test]
fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::ArtPreparation,
"读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret",
),
None,
);
assert!(error
.starts_with("direct-codex-failure:v1 stage=art-preparation retryable=true summary="));
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary="));
assert!(error.contains("<redacted-url>"), "{error}");
assert!(error.contains("<absolute-path>"), "{error}");
assert!(!error.contains("authorization=Bearer secret"), "{error}");
assert!(!error.contains("?token=secret"), "{error}");
assert!(!error.contains("provider.example"), "{error}");
let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics");
let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics");
let entries = std::fs::read_dir(&diagnostics)
.expect("diagnostic directory")
.filter_map(Result::ok)
@@ -6658,25 +6740,25 @@ mod tests {
#[test]
fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let history_path = root
.path()
.join(".agent/conversations/project.jsonl")
.display()
.to_string();
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::CodeGeneration,
format!("DirectProject 历史记录类型无效:{history_path}"),
),
None,
);
assert!(
error.starts_with(
"direct-codex-failure:v1 stage=code-generation retryable=false summary="
"direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary="
),
"{error}"
);
@@ -6686,9 +6768,9 @@ mod tests {
),
"{error}"
);
assert!(error.ends_with("已保存脱敏项目诊断"), "{error}");
assert!(error.contains("已保存脱敏项目诊断"), "{error}");
let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics");
let diagnostics = root.join(".agent/runtime/direct-codex-diagnostics");
let entries = std::fs::read_dir(&diagnostics)
.expect("diagnostic directory")
.filter_map(Result::ok)
@@ -6702,19 +6784,20 @@ mod tests {
#[test]
fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::ArtPreparation,
"陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复",
),
None,
);
assert!(
error.contains("stage=art-preparation retryable=false"),
error.contains("stage=art-preparation code=runtime-failure retryable=false"),
"{error}"
);
assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
@@ -6722,15 +6805,16 @@ mod tests {
#[test]
fn direct_failure_diagnostic_keeps_private_credential_storage_failure_actionable() {
let root = tempfile::tempdir().expect("temp dir");
init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断")
.expect("init project");
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project");
let error = record_direct_codex_turn_failure(
root.path(),
&root,
DirectCodexTurnFailure::new(
DirectCodexFailureStage::ArtPreparation,
"private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据",
),
None,
);
assert!(
@@ -7830,6 +7914,40 @@ mod tests {
.any(|warning| warning.contains("不得猜测切片")));
}
#[test]
fn direct_completion_scans_npm_scene_modules_for_registered_asset_references() {
let parent = tempfile::tempdir().expect("temp dir");
let root = parent.path().join("project");
init_local_game_project_at(&root, "direct-src-runtime", "源码模块素材引用")
.expect("init project");
register_direct_taonier_art_package_fixture(&root);
register_direct_taonier_art_slice_entries_fixture(&root);
std::fs::write(
root.join("game/package.json"),
"{\"scripts\":{\"build\":\"vite build\"}}",
)
.expect("package");
std::fs::write(root.join("game/index.html"), "<!doctype html>").expect("index");
std::fs::write(root.join("game/style.css"), "body {}").expect("style");
std::fs::write(root.join("game/game.js"), "import './src/scene.js';").expect("entry");
std::fs::create_dir_all(root.join("game/src")).expect("src dir");
std::fs::write(
root.join("game/src/scene.js"),
"const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png';",
)
.expect("scene");
std::fs::write(
root.join("assets/art-spritesheet-slices/player.png"),
tiny_opaque_png(),
)
.expect("slice");
assert_eq!(
direct_game_sources_referenced_taonier_assets(&root),
vec!["assets/art-spritesheet-slices/player.png".to_string()]
);
}
#[test]
fn direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet() {
let root = tempfile::tempdir().expect("temp dir");
@@ -1078,7 +1078,9 @@ fn bridge_attempt(arguments: &Value) -> Result<usize, String> {
.and_then(Value::as_u64)
.ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?;
if !(1..=3).contains(&attempt) {
return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string());
return Err(format!(
"playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果"
));
}
Ok(attempt as usize)
}
@@ -2608,6 +2610,30 @@ async fn handle_direct_tool_bridge(
}
_ => bridge_tool_result("未知或未审核的客户端工具".to_string(), Vec::new(), true),
};
if result.get("isError").and_then(Value::as_bool) == Some(true) {
let message = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.unwrap_or("客户端工具执行失败");
let code = if message.contains("playtest-attempt-limit-exceeded") {
"playtest-attempt-limit-exceeded"
} else {
"tool-error"
};
let _ = persist_agent_runtime_error(
&state.root,
None,
"agc-tools",
"tool-execution",
code,
true,
message,
"查看项目错误诊断后处理",
message,
None,
serde_json::json!({"tool": request.tool}),
);
}
Json(result)
}
@@ -898,7 +898,9 @@ fn tool_attempt(arguments: &Value) -> Result<usize, String> {
.and_then(Value::as_u64)
.ok_or_else(|| "工具参数 attempt 必须是 1 到 3 的整数".to_string())?;
if !(1..=3).contains(&attempt) {
return Err("工具参数 attempt 必须是 1 到 3 的整数".to_string());
return Err(format!(
"playtest-attempt-limit-exceeded: 本轮试玩最多 3 次,收到 attempt={attempt};请结束试玩并基于最近一次浏览器证据报告结果"
));
}
Ok(attempt as usize)
}
@@ -0,0 +1,167 @@
//! Shared, project-bound error events for Agent Runtime and DirectProject.
//!
//! Every caller supplies a safe public summary and a private detail. This
//! module is the only persistence boundary for the latter: it redacts project
//! paths and credentials before writing a bounded diagnostic sidecar.
use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1";
pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024;
static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub(crate) struct AgentRuntimeErrorEvent {
pub schema_version: &'static str,
pub event_id: String,
pub client_turn_id: Option<String>,
pub source: String,
pub stage: String,
pub code: String,
pub retryable: bool,
pub occurred_at_unix_nanos: String,
pub elapsed_ms: Option<u64>,
pub public_text: String,
pub recovery_hint: String,
pub detail_ref: String,
pub persistence_failed: bool,
pub metadata: Value,
}
pub(crate) fn persist_agent_runtime_error(
root: &Path,
client_turn_id: Option<&str>,
source: &str,
stage: &str,
code: &str,
retryable: bool,
public_text: &str,
recovery_hint: &str,
detail: &str,
elapsed_ms: Option<u64>,
metadata: Value,
) -> Result<AgentRuntimeErrorEvent, String> {
let occurred_at_unix_nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| format!("读取错误事件时间失败:{error}"))?
.as_nanos();
let sequence = ERROR_EVENT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let event_id = format!("error-{occurred_at_unix_nanos}-{sequence}");
let detail_ref = format!(".agent/runtime/errors/{event_id}.json");
let safe_detail =
redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS);
let diagnostic = serde_json::json!({
"schemaVersion": AGENT_RUNTIME_ERROR_SCHEMA_VERSION,
"eventId": event_id,
"clientTurnId": client_turn_id,
"source": source,
"stage": stage,
"code": code,
"retryable": retryable,
"occurredAtUnixNanos": occurred_at_unix_nanos.to_string(),
"elapsedMs": elapsed_ms,
"publicText": public_text,
"recoveryHint": recovery_hint,
"detail": safe_detail,
"metadata": metadata,
});
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&detail_ref,
"统一 Agent Runtime 错误诊断",
&diagnostic,
16 * 1024,
)?;
Ok(AgentRuntimeErrorEvent {
schema_version: AGENT_RUNTIME_ERROR_SCHEMA_VERSION,
event_id,
client_turn_id: client_turn_id.map(str::to_string),
source: source.to_string(),
stage: stage.to_string(),
code: code.to_string(),
retryable,
occurred_at_unix_nanos: occurred_at_unix_nanos.to_string(),
elapsed_ms,
public_text: public_text.to_string(),
recovery_hint: recovery_hint.to_string(),
detail_ref,
persistence_failed: false,
metadata,
})
}
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase();
if normalized.contains("等待 turn/completed 超时") {
"turn-idle-timeout"
} else if normalized.contains("达到 directproject 硬上限") {
"turn-hard-timeout"
} else if normalized.contains("transport closed") || normalized.contains("连接已关闭") {
"transport-closed"
} else if normalized.contains("playtest-attempt-limit-exceeded") {
"playtest-attempt-limit-exceeded"
} else if (normalized.contains("tool") || normalized.contains("工具"))
&& normalized.contains("参数")
{
"tool-invalid-arguments"
} else if normalized.contains("codex app-server-error:other") {
"app-server-other"
} else {
"runtime-failure"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_event_is_bounded_and_redacts_private_detail() {
let parent = tempfile::tempdir().expect("temp root");
let root = parent.path().join("project");
crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件")
.expect("init project");
let event = persist_agent_runtime_error(
&root,
Some("turn-123"),
"direct-codex",
"code-generation",
"turn-idle-timeout",
true,
"本轮没有收到完成事件",
"查看诊断后重试",
"C:\\Users\\private\\project https://provider.example/a?token=secret",
Some(1200),
serde_json::json!({"lastEvent":"item/started"}),
)
.expect("persist event");
assert_eq!(event.code, "turn-idle-timeout");
let path = root.join(&event.detail_ref);
let text = std::fs::read_to_string(path).expect("diagnostic");
assert!(text.contains("<absolute-path>"));
assert!(text.contains("<redacted-url>"));
assert!(!text.contains("token=secret"));
}
#[test]
fn timeout_and_tool_errors_have_distinct_codes() {
assert_eq!(
classify_direct_codex_error("等待 turn/completed 超时"),
"turn-idle-timeout"
);
assert_eq!(
classify_direct_codex_error("达到 DirectProject 硬上限"),
"turn-hard-timeout"
);
assert_eq!(
classify_direct_codex_error("工具参数 attempt 必须是 1 到 3 的整数"),
"tool-invalid-arguments"
);
}
}
@@ -101,6 +101,26 @@ pub(crate) fn append_game_creator_agent_runtime_terminal_public_message_at(
error: &str,
) -> Result<(), String> {
let content = game_creator_agent_runtime_failure_conversation_message(&state.agent_id, error);
// Keep the existing conversation projection, but also persist one common
// bounded diagnostic event for every Agent Runtime terminal failure. This
// makes non-DirectProject failures observable through the same detail API.
let _ = persist_agent_runtime_error(
root,
Some(&state.run_id),
"agent-runtime",
&state.phase,
"agent-runtime-terminal",
false,
&content,
"查看项目错误诊断后处理",
error,
None,
serde_json::json!({
"agentId": state.agent_id,
"sessionId": state.session_id,
"runId": state.run_id,
}),
);
let status = if state.phase == "budget-exhausted" {
"budget-exhausted"
} else if state.phase == "needs-reconciliation" {
@@ -5244,6 +5244,38 @@ pub(crate) async fn read_direct_project_conversation(
.map_err(|error| format!("读取 DirectProject 历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_agent_runtime_error_detail(
project_path: String,
detail_ref: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let relative = detail_ref.trim();
let Some(file_name) = relative.strip_prefix(".agent/runtime/errors/") else {
return Err("错误诊断引用不在项目错误目录内".to_string());
};
if file_name.is_empty()
|| file_name.contains(['/', '\\'])
|| file_name.contains("..")
|| !file_name.ends_with(".json")
{
return Err("错误诊断引用格式无效".to_string());
}
let path = root.join(relative);
prepare_game_creator_private_path_for_read(&path, false, "统一错误诊断")?;
let bytes = std::fs::read(&path).map_err(|error| format!("读取错误诊断失败:{error}"))?;
if bytes.len() > 16 * 1024 {
return Err("错误诊断超过读取上限".to_string());
}
let text = String::from_utf8(bytes).map_err(|_| "错误诊断不是 UTF-8 文本".to_string())?;
Ok(redact_agent_runtime_error(root, &text, 16 * 1024))
})
.await
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) fn append_local_conversation_message(
project_path: String,
@@ -2768,6 +2768,7 @@ fn main() {
archive_game_creator_agent_session,
read_local_conversation,
read_direct_project_conversation,
read_agent_runtime_error_detail,
append_local_conversation_message,
append_direct_project_conversation_message,
build_local_project_index,
+18 -1
View File
@@ -6519,8 +6519,25 @@ export function App({
void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID);
const message =
error instanceof Error ? error.message : String(error);
let persistedDetail = '';
const detailRef = message.match(
/详情:(\.agent\/runtime\/errors\/[^\s]+)/,
)?.[1];
if (detailRef && directInvoke) {
try {
persistedDetail = await directInvoke<string>(
'read_agent_runtime_error_detail',
{
projectPath: directProjectPath,
detailRef,
},
);
} catch {
persistedDetail = '';
}
}
const visibleMessage = projectRuntimeVisibleError(
message,
persistedDetail ? `${message}\n\n${persistedDetail}` : message,
'陶泥儿智能创作',
true,
);
@@ -0,0 +1,35 @@
# AGC 统一错误诊断与验收反馈实施计划
Version: 1.0
Status: active
Date: 2026-09-15
Parent Milestone: `【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md`
## 修改边界
1. 新增 `agent/runtime_error.rs`,承载统一事件字段、code/stage 白名单、脱敏后的 public projection、项目错误 JSONL/sidecar 落库和 detail 读取边界。
2. `direct_runtime.rs` 使用统一事件替代仅写 `failure.json` 的路径;失败 assistant 投影带稳定 ID,下一轮 prompt 注入最近失败事件摘要。
3. `codex_app_server.rs` 将 failed turn、idle/hard timeout、transport close、invalid terminal 和 stderr tail 转成稳定事件字段;不公开原始 detail。
4. `direct_tool_bridge.rs``direct_tools_mcp.rs` 让 attempt 由客户端回合状态约束,越界请求返回终态工具错误;不扩展重试预算。
5. `direct_runtime.rs` 的素材扫描递归覆盖可执行源码模块,基于 manifest 身份和浏览器 URL 映射判定;补充模块引用回归测试。
6. 前端读取后端 `publicText/detailRef`,在现有 Runtime 错误面板中加入详情入口;不在 React 侧重新分类错误。
## 实现顺序
先写统一事件模型和 Rust 单测,再接 direct failure/app-server/tool bridge,随后接 prompt/history 与前端详情,最后修素材验收和 attempt 生命周期。每一步保留原有脱敏和失败关闭行为。
## 验证命令
- `cargo fmt --check`
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml runtime_error direct_runtime codex_app_server direct_tool_bridge`
- `npm run --prefix apps/ai-game-creator-shell typecheck`
- `npm run check:encoding`
- `git diff --check`
- 必要时运行 AGC deterministic playable E2E;真实 Provider smoke 与浏览器双视口 smoke 单独报告。
## 风险与回滚
- 统一事件 schema 只新增项目内文件和对话投影,不修改已有 manifest、公开 API 或 SpacetimeDB schema。
- 若前端详情读取失败,仍展示安全 `publicText`,不阻塞错误终态。
- 若素材身份无法映射,继续失败关闭并记录明确 code,不回退为路径字符串通过。
- 回滚可删除新事件写入和详情入口,保留旧 `failure.json` 读取兼容。
@@ -0,0 +1,39 @@
# AGC 统一错误诊断与验收反馈
Version: 1.0
Status: active
Date: 2026-09-15
Parent Spec: `docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”
## 目标
让 DirectProject 和共享 Agent Runtime 对失败使用同一份安全、可追踪、可恢复的错误事件合同;用户追问失败原因时能够读取上一轮证据;构建与浏览器验收只依据真实源码、manifest 身份和运行时证据判断。
## 范围
- 统一错误事件模型与项目内诊断落库。
- DirectProject 失败 assistant 投影、下一轮诊断上下文和前端详情入口。
- app-server 终态/超时、内置 MCP 工具错误和试玩 attempt 上限的分类。
- 游戏源码模块素材扫描、manifest 身份映射与浏览器观察映射。
- 定向 Rust/前端回归和现有 AGC 运行时门禁。
## 不做
- 不改变 Provider、External Editor 或 app-server 的 wire 协议。
- 不放宽项目写锁、凭据隔离、工具白名单或完成门安全边界。
- 不迁移历史项目文件;旧诊断只读兼容,新增事件使用新 schema。
- 不把原始 stderr、请求正文或绝对路径展示给用户。
## 验收标准
1. 任一 DirectProject 失败均生成统一事件、稳定 `eventId` 和有界诊断引用;落库失败不覆盖原始错误。
2. 失败安全投影写入对话历史,下一轮能读取 `publicText / code / stage / detailRef`,不会因追问而自动试玩。
3. 结构化 failed turn、idle/hard timeout、transport close、MCP 参数错误和 `other` 各有稳定 code 与 recoveryHint。
4. `attempt` 由客户端按回合分配并有上限;越界调用不会让回合继续等待。
5. `game/src` 下模块引用已登记素材、Vite dist 稳定映射和浏览器实际观察均能通过;未登记素材仍失败。
6. 脱敏测试证明 Token、Cookie、URL/query、私钥、宿主绝对路径和 stderr 私密内容不会进入用户文本。
## 依赖
- 现有 `direct_project_history``runtime_state``codex_app_server``direct_tool_bridge` 与浏览器 validation 证据。
- 现有 DirectProject 诊断 sidecar 和 manifest 资源身份。
@@ -8741,6 +8741,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`+ `main.rs` 注册)、`src/features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx}`;改动 `ResourceCanvasAssetGenerationPanelView.tsx` / `ResourceCanvasGenerationPanelView.tsx` / `src/view/project-development/index.tsx`;测试改动 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationQueue.test.ts,resourceCanvasAssetGenerationTasksPanel.test.tsx}`(新增)与 `tests/appSurface/project-development.suite.ts`(把「每个入口一次 `generate_local_project_asset`」改成 `start_local_project_asset_generation` + `list_...` 轮询桩,载荷断言逐字不变)。**未动**external v1 / OpenAPI、`packages/`、SpacetimeDB、音频入口的 pending-edit 账本语义、生成参数与 IPC 载荷字段名。
- 关联文档:`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 / §4a / §8)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`S11a / §7.3)。
## 2026-09-15 非 Suno 的 VectorEngine 能力切换到 Tiantoken
- 决策:新增本地私密环境变量 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`(图片 timeout 可独立配置),承载原 VectorEngine 的文本和图片;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐与 Suno 音效。编辑器 SFX V2 继续走 ElevenLabs。
@@ -8751,3 +8752,11 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策:旧版 Vidu `audio1.0` 的 submit / poll / download builder、旧视觉小说与创建音效死代码、对应 platform-audio 请求类型和测试全部删除。历史素材的 `audio1.0` 展示与定价兼容数据保留;新编辑器音效仍只走 ElevenLabs,Suno 音乐链路不变。
- 验证:platform-audio 全量测试 55 条通过,api-server `cargo check` 通过,fmt / 编码 / diff 检查通过;仓库现役源码不再包含 `VIDU_AUDIO_MODEL``AudioTaskKind::SoundEffect` 或 Vidu submit/poll 实现。
## 2026-09-15 AGC 统一错误事件与项目诊断落库
- 背景:DirectProject 的 app-server 超时、MCP 参数错误、浏览器完成门误判和普通 Agent Runtime 失败分别投影为短文案;失败正文没有稳定落库,下一轮模型看不到上一轮失败证据,用户追问原因时可能继续试玩或重复修改。
- 决策:新增 `agent/runtime_error.rs` 作为统一错误事件与有界诊断 sidecar 边界。DirectProject 失败、Agent Runtime terminal failure 均持久化 `.agent/runtime/errors/<eventId>.json`,并将脱敏 assistant 终态写回 `project.jsonl`;前端只通过 `read_agent_runtime_error_detail` 读取脱敏详情。旧 `failure.json` 保留兼容,不把原始 stderr、凭据、URL/query、宿主绝对路径写入用户文本。
- 决策:错误使用稳定 `source / stage / code / retryable / publicText / recoveryHint / detailRef` 字段;试玩 attempt 越界返回终态错误并停止继续等待。素材完成门扫描实际 npm 源码模块,并把 manifest 中合法的自定义 art-spritesheet 路径纳入候选,构建和浏览器观察仍需通过既有完成门。
- 关联规范:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md` 的“2026-09-15 AGC 统一错误事件、诊断落库与验收反馈”;开发期计划见 `docs/project-memory/plans/【里程碑】AGC统一错误诊断与验收反馈-2026-09-15.md` 与对应实施计划。
@@ -1379,3 +1379,15 @@ DirectProject 使用 `approvalPolicy=never`,避免每次原生调用再经过
## 2026-09-14 新游戏策划到真实美术接入的连续交付
DirectProject 在收到完整游戏策划或游戏制作请求后,必须把视觉素材作为同一交付链路处理:先读取当前项目已登记资源;策划案包含角色、对象、背景、特效、界面或其它视觉实体且现有资源不满足时,Codex 必须在同一游戏实现任务中调用审核的 `agc_tools` 生图或编辑工具,读取返回的资源身份与相对路径,把真实产物接入游戏源码,再构建并验证实际渲染。生成了素材但源码仍使用 emoji、CSS 形状或临时占位图替代策划要求的视觉元素,不能报告游戏完成。只有策划明确不需要视觉素材,或现有已登记素材完全满足需求时,才允许跳过生图;图片生成、处理、登记和接入不因用户没有重复输入“生图”而降级为可选建议。
## 2026-09-15 AGC 统一错误事件、诊断落库与验收反馈
DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执行、构建和浏览器试玩的失败必须先转换为统一的 `AgentRuntimeErrorEvent`,再分别投影到用户消息、运行面板和项目诊断文件;业务模块不得自行拼接只有一句“执行失败”的终态文案。统一事件至少包含 `schemaVersion / eventId / clientTurnId / source / stage / code / retryable / occurredAt / elapsedMs / publicText / recoveryHint / detailRef`,其中 `publicText` 是脱敏后的可行动摘要,`detailRef` 指向项目内有界诊断记录;Token、Cookie、URL/query、私钥、宿主绝对路径、原始请求正文和未脱敏 stderr 不得进入对话或用户可见文本。
项目内统一落库目录为 `.agent/runtime/errors/`,事件记录采用幂等 JSONL 或 JSON sidecar;写入失败不能覆盖原始业务错误,但必须在事件中标记 `persistenceFailed`。DirectProject 对话历史必须持久化本轮用户消息、终态错误的安全 assistant 投影和诊断引用,使下一轮能够读取上一轮失败证据。前端只展示 `publicText`,点击详情后按 `detailRef` 读取有界、脱敏的诊断,不直接展示私有 `detail`
`turn/completed` 等待超时必须区分 `idle-timeout``hard-timeout``transport-closed``failed-turn``invalid-terminal``tool-error`;收到内置工具参数错误后必须结束当前工具调用并进入可行动终态,不能继续使用越界的试玩 `attempt` 或无限等待。试玩次数由客户端按当前 `clientTurnId` 持久化分配,模型不能自由递增;超过上限必须返回一次终态并停止回合。
游戏素材完成门必须扫描实际参与构建的 `game/` 源码模块,读取 manifest 的登记身份与相对路径,并把构建后的 URL 映射回登记身份。固定素材路径只能作为兼容候选,不能作为唯一准入。已登记且被真实源码引用、被构建纳入并在浏览器证据中观察到的资源通过;未登记、来源不匹配或只存在于设计规范中的资源继续失败关闭。
验收至少覆盖:普通错误、结构化 app-server failed turn、idle/hard timeout、MCP 参数错误、历史落库失败、脱敏边界、下一轮诊断上下文、源码子模块素材引用、Vite 构建 URL 映射以及试玩次数上限。统一错误事件和诊断落库先于 UI 美化或增加重试预算;不能用延长超时、删除完成门或把失败投影为成功来规避问题。