From d2a5bceabae337a40af77c482fceabf0ba25ccfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 18:43:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=BC=96=E8=BE=91=E8=BF=9C?= =?UTF-8?q?=E7=AB=AF=E5=A4=B1=E8=B4=A5=E5=8E=9F=E6=96=87=E9=80=8F=E4=BC=A0?= =?UTF-8?q?=E5=88=B0=E5=B7=A5=E5=85=B7=E9=94=99=E8=AF=AF=E4=B8=8E=E8=AF=8A?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent/tool/error.rs 新增共用载体 RemoteResourceEditFailure { serverMessage },两个工具的平台失败文案只写一份 - CreateOrDeriveResourceError 与 RemoveBackgroundError 各加 RemoteGenerationFailed(RemoteResourceEditFailure) 变体,远端终态失败不再压回字符串 - 两个工具各加显式 from_resource_edit_error 翻译,不用 impl From;Other 仍落回各工具原有的「失败:<文案>」变体并留 TODO - 桥里四个资源编辑调用点改用 typed 入口,并 Box::pin 后再 await:资源编辑 future 内联会顶穿 handle_direct_tool_bridge 状态机的调试测试线程栈 - assets.rs 新增 with_direct_editor_api_credentials_as,保留调用方 error 类型,凭据解析失败由调用方显式翻译成 ResourceEditError::Other,旧名与其余调用点零改动 - 工具错误模块补单测:平台原文进 message、进序列化的 typed 错误,无原文与未分类失败各自的文案 - decision-log 补工具层承载、凭据作用域错误类型、future 装箱三条决策与真实验证结果;pitfalls 补工具层原样透传口径 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 51 ++++--- .../tool/create_or_derive_resource/error.rs | 65 ++++++++- .../src-tauri/src/agent/tool/error.rs | 20 +++ .../src/agent/tool/remove_background/error.rs | 131 +++++++++++++++--- .../src-tauri/src/assets.rs | 14 +- .../shared-memory/decision-log.md | 7 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- 7 files changed, 250 insertions(+), 40 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 2e2d583e9..1755adff8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -39,6 +39,7 @@ use crate::agent::tool::run_validation::error::RunValidationError; use crate::agent::tool::update_plan::error::UpdatePlanError; use crate::agent::tool::web_search::error::WebSearchError; use crate::agent::tool::write_file::error::WriteFileError; +use crate::project::ResourceEditError; #[cfg(test)] use axum::extract::Query; use axum::extract::{DefaultBodyLimit, State}; @@ -2272,15 +2273,18 @@ async fn bridge_create_or_derive_resource( return Err(CreateOrDeriveResourceError::DuplicatePendingOperations); } let completed = if let Some(pending) = matching_pending.into_iter().next() { - with_direct_editor_api_credentials(resume_local_project_resource_edit_at( - ResumeLocalProjectResourceEditInput { + // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, + // 避免把调试测试线程的默认栈顶穿。 + Box::pin(with_direct_editor_api_credentials_as( + resume_local_project_resource_edit_typed(ResumeLocalProjectResourceEditInput { project_path: state.root.to_string_lossy().into_owned(), expected_project_id: manifest.project_id, operation_id: pending.operation_id, - }, + }), + ResourceEditError::Other, )) .await - .map_err(|cause| CreateOrDeriveResourceError::ResourceGenerationFailed { cause })? + .map_err(CreateOrDeriveResourceError::from_resource_edit_error)? } else { let turn_id = state .active_resource_turn_id() @@ -2318,9 +2322,14 @@ async fn bridge_create_or_derive_resource( background_mode: None, screen_color: None, }; - with_direct_editor_api_credentials(derive_local_project_resource_at(request)) - .await - .map_err(|cause| CreateOrDeriveResourceError::ResourceGenerationFailed { cause })? + // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, + // 避免把调试测试线程的默认栈顶穿。 + Box::pin(with_direct_editor_api_credentials_as( + derive_local_project_resource_typed(request), + ResourceEditError::Other, + )) + .await + .map_err(CreateOrDeriveResourceError::from_resource_edit_error)? }; let result = bridge_completed_resource_result( &state.root, @@ -2559,13 +2568,18 @@ async fn remove_background_payload( return Err(RemoveBackgroundError::DuplicatePendingOperations); } let completed = if let Some(pending) = matching_pending.into_iter().next() { - resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { - project_path: state.root.to_string_lossy().into_owned(), - expected_project_id: manifest.project_id.clone(), - operation_id: pending.operation_id, - }) + // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, + // 避免把调试测试线程的默认栈顶穿。 + Box::pin(with_direct_editor_api_credentials_as( + resume_local_project_resource_edit_typed(ResumeLocalProjectResourceEditInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + operation_id: pending.operation_id, + }), + ResourceEditError::Other, + )) .await - .map_err(|cause| RemoveBackgroundError::BackgroundRemovalFailed { cause })? + .map_err(RemoveBackgroundError::from_resource_edit_error)? } else { // id 按回合身份与请求指纹确定性派生,同指纹重试与 pending 对账语义不变。 let turn_id = state @@ -2596,9 +2610,14 @@ async fn remove_background_payload( background_mode: Some(input.background_mode.clone()), screen_color: input.screen_color.clone(), }; - derive_local_project_resource_at(request) - .await - .map_err(|cause| RemoveBackgroundError::BackgroundRemovalFailed { cause })? + // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, + // 避免把调试测试线程的默认栈顶穿。 + Box::pin(with_direct_editor_api_credentials_as( + derive_local_project_resource_typed(request), + ResourceEditError::Other, + )) + .await + .map_err(RemoveBackgroundError::from_resource_edit_error)? }; emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal"); bridge_completed_resource_result(&state.root, "background-removal", "derive", completed) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs index 8b4e27c87..b735eb645 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs @@ -4,9 +4,10 @@ use crate::agent::direct_tool_bridge::DirectResourceGenerationKind; use crate::agent::tool::error::{ - ProjectManifestRejection, ProjectPermissionRejection, ResourceCompletionRejection, - ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT, + ProjectManifestRejection, ProjectPermissionRejection, RemoteResourceEditFailure, + ResourceCompletionRejection, ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT, }; +use crate::project::ResourceEditError; /// `agc_create_or_derive_resource` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 @@ -94,6 +95,9 @@ pub(crate) enum CreateOrDeriveResourceError { ProjectRevisionUnavailable { cause: String, }, + RemoteGenerationFailed(RemoteResourceEditFailure), + /// 尚未分类的本地失败(对应 `ResourceEditError::Other`)。 + // TODO refactor string-typed ResourceGenerationFailed { cause: String, }, @@ -250,9 +254,66 @@ impl ToolFailure for CreateOrDeriveResourceError { Self::ProjectRevisionUnavailable { cause } => { format!("生成或派生资源失败:读取项目 revision 失败:{cause}") } + Self::RemoteGenerationFailed(failure) => failure.to_user_msg(), Self::ResourceGenerationFailed { cause } => { format!("生成或派生资源失败:{cause}") } } } } + +impl CreateOrDeriveResourceError { + /// 把资源编辑层的 typed 失败翻成这个工具自己的失败:远端终态失败保留平台原文, + /// 其余尚未 typed 化的本地失败仍落到 `ResourceGenerationFailed`。 + pub(crate) fn from_resource_edit_error(error: ResourceEditError) -> Self { + match error { + ResourceEditError::RemoteGenerationFailed { server_message } => { + Self::RemoteGenerationFailed(RemoteResourceEditFailure { server_message }) + } + error => Self::ResourceGenerationFailed { + cause: error.to_user_msg(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_generation_failure_carries_platform_message() { + let error = CreateOrDeriveResourceError::from_resource_edit_error( + ResourceEditError::RemoteGenerationFailed { + server_message: Some("provider detail".to_string()), + }, + ); + + assert_eq!(error.to_user_msg(), "资源编辑生成失败:provider detail"); + assert_eq!( + serde_json::to_value(&error).expect("serialize typed failure") + ["RemoteGenerationFailed"]["server_message"], + serde_json::json!("provider detail") + ); + } + + #[test] + fn remote_generation_failure_without_message_falls_back() { + let error = CreateOrDeriveResourceError::from_resource_edit_error( + ResourceEditError::RemoteGenerationFailed { + server_message: None, + }, + ); + + assert_eq!(error.to_user_msg(), "资源编辑生成失败"); + } + + #[test] + fn unclassified_resource_edit_failure_keeps_tool_prefix() { + let error = CreateOrDeriveResourceError::from_resource_edit_error( + ResourceEditError::Other("local-boom".to_string()), + ); + + assert_eq!(error.to_user_msg(), "生成或派生资源失败:local-boom"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index 53b8ad613..f6eab4fc6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -179,6 +179,26 @@ impl ToolFailure for ResourceCompletionRejection { } } +/// 平台把远端资源编辑判成终态 `failed` 时给出的原因。 +/// +/// `agc_create_or_derive_resource` 与 `agc_remove_background` 走的是同一条远端资源编辑 +/// 通道,平台 `error` 原文必须作为数据原样带出(不是在产生点拼进前缀),两个工具共用 +/// 这一层文案。 +#[derive(serde::Serialize, Debug)] +pub(crate) struct RemoteResourceEditFailure { + /// 平台 `error` 字段原文;平台没给时为空。 + pub(crate) server_message: Option, +} + +impl ToolFailure for RemoteResourceEditFailure { + fn to_user_msg(&self) -> String { + match &self.server_message { + Some(server_message) => format!("资源编辑生成失败:{server_message}"), + None => "资源编辑生成失败".to_string(), + } + } +} + /// 只有客户端 Direct 回合才会放行的工具被独立客户端 MCP 调用。 /// /// 独立客户端 MCP 没有 Direct 回合的执行账本,不能借用其它回合的预算,所以这里直接拒绝, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs index 1c903e51a..1dd849593 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs @@ -3,9 +3,10 @@ //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 use crate::agent::tool::error::{ - ProjectManifestRejection, ProjectPermissionRejection, ResourceCompletionRejection, - ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT, + ProjectManifestRejection, ProjectPermissionRejection, RemoteResourceEditFailure, + ResourceCompletionRejection, ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT, }; +use crate::project::ResourceEditError; /// `agc_remove_background` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 @@ -16,28 +17,65 @@ pub(crate) enum RemoveBackgroundError { Manifest(ProjectManifestRejection), Completion(ResourceCompletionRejection), SourceLocalAssetIdEmpty, - SourceLocalAssetIdTooLong { got_chars: usize, max_chars: usize }, + SourceLocalAssetIdTooLong { + got_chars: usize, + max_chars: usize, + }, SourceLocalAssetIdHasControlCharacters, - SourceLocalAssetIdNotText { got: String }, + SourceLocalAssetIdNotText { + got: String, + }, AssetNameEmpty, - AssetNameTooLong { got_chars: usize, max_chars: usize }, + AssetNameTooLong { + got_chars: usize, + max_chars: usize, + }, AssetNameHasControlCharacters, - AssetNameNotText { got: String }, - BackgroundModeNotText { got: String }, - BackgroundModeUnsupported { got: String }, - ScreenColorNotText { got: String }, - ScreenColorMalformed { got: String }, + AssetNameNotText { + got: String, + }, + BackgroundModeNotText { + got: String, + }, + BackgroundModeUnsupported { + got: String, + }, + ScreenColorNotText { + got: String, + }, + ScreenColorMalformed { + got: String, + }, ScreenColorRequiresFlatMode, SourceAssetNotRegistered, - SourceNotImage { media_type: String }, - CredentialsUnavailable { cause: String }, - GenerationQueueUnavailable { cause: String }, - PaidDispatchRefused { cause: String }, - PendingEditsUnreadable { cause: String }, + SourceNotImage { + media_type: String, + }, + CredentialsUnavailable { + cause: String, + }, + GenerationQueueUnavailable { + cause: String, + }, + PaidDispatchRefused { + cause: String, + }, + PendingEditsUnreadable { + cause: String, + }, DuplicatePendingOperations, - TurnIdUnavailable { cause: String }, - ProjectRevisionUnavailable { cause: String }, - BackgroundRemovalFailed { cause: String }, + TurnIdUnavailable { + cause: String, + }, + ProjectRevisionUnavailable { + cause: String, + }, + RemoteGenerationFailed(RemoteResourceEditFailure), + /// 尚未分类的本地失败(对应 `ResourceEditError::Other`)。 + // TODO refactor string-typed + BackgroundRemovalFailed { + cause: String, + }, } impl From for RemoveBackgroundError { @@ -137,7 +175,64 @@ impl ToolFailure for RemoveBackgroundError { Self::ProjectRevisionUnavailable { cause } => { format!("抠图失败:读取项目 revision 失败:{cause}") } + Self::RemoteGenerationFailed(failure) => failure.to_user_msg(), Self::BackgroundRemovalFailed { cause } => format!("抠图失败:{cause}"), } } } + +impl RemoveBackgroundError { + /// 把资源编辑层的 typed 失败翻成这个工具自己的失败:远端终态失败保留平台原文, + /// 其余尚未 typed 化的本地失败仍落到 `BackgroundRemovalFailed`。 + pub(crate) fn from_resource_edit_error(error: ResourceEditError) -> Self { + match error { + ResourceEditError::RemoteGenerationFailed { server_message } => { + Self::RemoteGenerationFailed(RemoteResourceEditFailure { server_message }) + } + error => Self::BackgroundRemovalFailed { + cause: error.to_user_msg(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_generation_failure_carries_platform_message() { + let error = RemoveBackgroundError::from_resource_edit_error( + ResourceEditError::RemoteGenerationFailed { + server_message: Some("provider detail".to_string()), + }, + ); + + assert_eq!(error.to_user_msg(), "资源编辑生成失败:provider detail"); + assert_eq!( + serde_json::to_value(&error).expect("serialize typed failure") + ["RemoteGenerationFailed"]["server_message"], + serde_json::json!("provider detail") + ); + } + + #[test] + fn remote_generation_failure_without_message_falls_back() { + let error = RemoveBackgroundError::from_resource_edit_error( + ResourceEditError::RemoteGenerationFailed { + server_message: None, + }, + ); + + assert_eq!(error.to_user_msg(), "资源编辑生成失败"); + } + + #[test] + fn unclassified_resource_edit_failure_keeps_tool_prefix() { + let error = RemoveBackgroundError::from_resource_edit_error(ResourceEditError::Other( + "local-boom".to_string(), + )); + + assert_eq!(error.to_user_msg(), "抠图失败:local-boom"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 01e556e37..214a360e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -56,10 +56,22 @@ pub(crate) async fn with_external_editor_api_credentials( pub(crate) async fn with_direct_editor_api_credentials( operation: impl Future>, ) -> Result { + with_direct_editor_api_credentials_as(operation, std::convert::identity).await +} + +/// 与 [`with_direct_editor_api_credentials`] 同一段作用域语义,但保留调用方自己的错误类型: +/// 凭据解析失败由调用方用 `credentials_error` 显式翻译,不引入 `From` 这种隐式转换。 +pub(crate) async fn with_direct_editor_api_credentials_as( + operation: impl Future>, + credentials_error: impl FnOnce(String) -> E, +) -> Result { if editor_api_mode() == EditorApiMode::PlatformAccount { return operation.await; } - let credentials = ensure_private_external_editor_api_credentials().await?; + let credentials = match ensure_private_external_editor_api_credentials().await { + Ok(credentials) => credentials, + Err(cause) => return Err(credentials_error(cause)), + }; with_external_editor_api_credentials(credentials, operation).await } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 558cf4e12..35df7a8bd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8,8 +8,11 @@ - 决策(前缀去留):删掉第一句失败文案里的 `remote-terminal-failed:`(轮询与提交期 400 两处)。`ensure_resource_edit_phase_resumable` 里那三个 token 保留:它们与三个 phase 一一对应,是那句重试文案里区分「确定失败 / 已归档 / 待对账」的唯一手段。 - 决策(原文边界):平台原文只进当次错误文案,仍不进账本(`terminal_failure_code` 的写入边界与既有断言不变)。平台 `user_visible_external_generation_error` 已对四种 kind 做 sanitize,图片/视频两种原样透出——与同 wire 的 `canvas_generation.rs` 口径一致,要收边界应改服务端。 - 决策(命名与落点):尚未 typed 化的变体叫 `Other`,不叫 `Message`(后者分不清是「已渲染文案」还是「原始消息」);新错误单独放 `project/resource_editor/error.rs`,不再往主文件里塞类型定义。 -- 改动范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`project/resource_editor/error.rs`(新)、`agent/tool/create_or_derive_resource/error.rs`、`agent/tool/remove_background/error.rs`、`agent/direct_tool_bridge.rs`、`docs/project-memory/shared-memory/pitfalls.md`。 -- 验证:待补(`cargo test --bin genarrative-ai-game-creator-shell -- project::resource_editor` 与 `-- agent::direct_tool_bridge`;`npm run check:encoding`;`git diff --check`)。 +- 决策(工具层承载):`RemoteGenerationFailed` 不能到工具层又被压回一句字符串。共用载体放 `agent/tool/error.rs` 的 `RemoteResourceEditFailure { serverMessage }`(两个工具共用的文案只写一份),`CreateOrDeriveResourceError` / `RemoveBackgroundError` 各加 `RemoteGenerationFailed(RemoteResourceEditFailure)` 变体;翻译用显式 `from_resource_edit_error`,不用 `impl From`,远端终态进 `RemoteGenerationFailed`、其余 `Other` 仍落回各工具原有的「失败:<文案>」变体。这样诊断 sidecar 的 `error` 字段(typed enum 整体序列化)天然带上平台原文,LLM 侧拿到的 `message` 也带上。 +- 决策(凭据作用域的 error 类型):`with_direct_editor_api_credentials` 原本把操作限定成 `Result<_, String>`,会把 typed 错误提前压掉。新增 `with_direct_editor_api_credentials_as(operation, credentials_error)` 保留调用方 error 类型,凭据解析失败由调用方显式翻译(这里传 `ResourceEditError::Other`),旧名保持 `String` 语义、零改动。 +- 决策(future 装箱):`handle_direct_tool_bridge` 的状态机在调试测试线程的默认栈上已经贴着上限,资源编辑 arm 直接内联会顶穿(`bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker` 栈溢出)。桥里四个资源编辑 await 点用 `Box::pin` 只留指针进外层状态机;这是体积问题,不是错误用 `Box`。 +- 改动范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`project/resource_editor/error.rs`(新)、`agent/tool/error.rs`、`agent/tool/create_or_derive_resource/error.rs`、`agent/tool/remove_background/error.rs`、`agent/direct_tool_bridge.rs`、`assets.rs`、`docs/project-memory/shared-memory/pitfalls.md`。 +- 验证:`cargo check --bin genarrative-ai-game-creator-shell --tests` 通过;`cargo test --bin genarrative-ai-game-creator-shell -- project::resource_editor --test-threads=1` 66 passed(并行跑会有一批 TCP fixture 用例因争用超时,串行全绿,与本次改动无关);`-- agent::tool:: agent::direct_tool_bridge` 47 passed;`npm run check:encoding` 5111 files;`git diff --check` 干净;`cargo fmt` 已跑。 - 关联:`pitfalls.md`「远端资源编辑终态必须指出唯一出口」。 ## 2026-10-01 Web、后台与 AGC 一键联调 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8bc2f2cc3..8ff81704d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -340,7 +340,7 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只 - 现象:「生成背景音乐」再次提交 0.1 秒就失败,卡片只有 `remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求`,既没有原因也没有下一步。 - 原因:上一次同 `operationId` 的请求被平台确定性拒绝(HTTP 400 或任务 `failed`)后,账本落到 `remote-failed`,之后所有重试都在 `ensure_resource_edit_phase_resumable` 失败关闭;唯一出口是「待恢复资源编辑」里的移出恢复队列,但终态文案没有指向它。 -- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 `error` 原文装进 `ResourceEditError::RemoteGenerationFailed` 原样带出(2026-10-02),不再压成「资源编辑生成失败」一句;第一句失败文案不再带 `remote-terminal-failed:` 前缀。 +- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 `error` 原文装进 `ResourceEditError::RemoteGenerationFailed` 原样带出(2026-10-02),工具层再用 `RemoteResourceEditFailure` 原样传给模型与用户卡片,不再压成「资源编辑生成失败」一句;第一句失败文案不再带 `remote-terminal-failed:` 前缀。 - 验证:`remote_failed_status_is_terminal_and_can_only_be_archived` 断言失败文案带出平台 `error` 原文、同时账本序列化不含原文;`background_removal_remote_failure_keeps_manifest_without_result` 覆盖平台没给 `error` 时的兜底文案;`submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation` 等资源编辑用例继续通过。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。