diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 2ce5d4655..5ff5518db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -34,6 +34,7 @@ mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; +mod direct_turn_error; mod direct_turn_failure; mod direct_turn_metrics; mod direct_turn_stream; @@ -73,6 +74,7 @@ pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; +pub(crate) use direct_turn_error::*; pub(crate) use direct_turn_failure::*; pub(crate) use direct_turn_metrics::*; pub(crate) use direct_turn_stream::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs new file mode 100644 index 000000000..23af1cd1e --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -0,0 +1,908 @@ +//! Direct 回合链路的 typed error:从命令入口到出口只传这一种错误。 +//! +//! 为什么不是 `struct { kind, message }`:两层错误(**调用级拒绝**与**回合级失败**)根本不共享 +//! 字段——并发拒绝要带两个 invocation id、模型自报失败要带原生分类、等待超时要带是哪条上限、 +//! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 +//! 伪结构化,也不靠对错误文本做子串匹配。 +//! +//! 分类的用途只有一条:**决定这件事该走哪条通道**。 +//! - 调用级拒绝([`DirectTurnError::is_turn_failure`] 为 `false`):这一轮没有开始。只出提示 / +//! 横幅,不做失败载荷、不写失败诊断、不上报成"智能创作失败"。 +//! - 回合级失败:这一轮已经开始并被判失败。事件载荷、横幅、应用日志、错误上报池四处一致。 +//! +//! 事件载荷(`direct_thread_wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: +//! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 +//! 另一种状态模型。跨进程边界(`#[tauri::command]`)同样只给前端一个字符串:那是**序列化**, +//! 由 `Display` 一处生成;Rust 侧任何地方都不再解析这个字符串。 +//! +//! 谁负责产生哪个变体: +//! - 命令入口与回合编排(`direct_runtime`):调用级拒绝、阶段失败; +//! - app-server 投影([`DirectTurnError::from_model_call`]):模型 / 上游 / 通道类失败; +//! - 执行适配器(`codex_app_server::execution`):宿主亲眼看到的收场事实(通道断开 / 超时 / 中断)。 + +use std::fmt; + +use platform_llm::LlmError; + +/// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 +/// +/// 这是**协议常量**,不是给人读的文案:`codex-app-server-error:`,`` 之后可选跟 +/// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。 +const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; + +/// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DirectCodexFailureStage { + ArtPreparation, + CodeGeneration, + BrowserValidation, + VersionRegistration, +} + +impl DirectCodexFailureStage { + pub(crate) fn id(self) -> &'static str { + match self { + Self::ArtPreparation => "art-preparation", + Self::CodeGeneration => "code-generation", + Self::BrowserValidation => "browser-validation", + Self::VersionRegistration => "version-registration", + } + } +} + +/// 宿主等不到模型回执时,撞的是哪一条上限。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DirectTurnDeadline { + /// 空闲上限:一段时间没有新事件。 + ResponseIdle, + /// 回合硬上限:整轮的总时间。 + TurnHardLimit, +} + +impl DirectTurnDeadline { + /// 宿主写进失败事实与交付报告的那句原因:两边共用同一份文本,用户看到的现象与交付状态对得上。 + fn message(self) -> &'static str { + match self { + Self::ResponseIdle => "等待模型执行回执超时,不能自动重放未确认操作。", + Self::TurnHardLimit => "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。", + } + } +} + +/// Codex app-server 自报的原生失败分类(`turn.error.codexErrorInfo` 的归类结果)。 +/// +/// 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), +/// 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 +/// 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum DirectCodexNativeKind { + ContextWindowExceeded, + SessionBudgetExceeded, + UsageLimitExceeded, + RequestTooLarge, + StreamRequired, + CyberPolicy, + SandboxError, + ThreadRollbackFailed, + BadRequest, + Unauthorized, + ActiveTurnNotSteerable, + /// 原生分类为 `other`,或本版本还不认识的新取值。 + Other { + kind: String, + }, +} + +impl DirectCodexNativeKind { + fn from_id(kind: &str) -> Self { + match kind { + "context-window-exceeded" => Self::ContextWindowExceeded, + "session-budget-exceeded" => Self::SessionBudgetExceeded, + "usage-limit-exceeded" => Self::UsageLimitExceeded, + "request-too-large" => Self::RequestTooLarge, + "stream-required" => Self::StreamRequired, + "cyber-policy" => Self::CyberPolicy, + "sandbox-error" => Self::SandboxError, + "thread-rollback-failed" => Self::ThreadRollbackFailed, + "bad-request" => Self::BadRequest, + "unauthorized" => Self::Unauthorized, + "active-turn-not-steerable" => Self::ActiveTurnNotSteerable, + kind => Self::Other { + kind: kind.to_string(), + }, + } + } + + /// 这一条分类是不是"再让模型跑一次也不会变好"。 + /// + /// 与旧行为逐条对齐:旧口径是"原因文本里出现这些分类名就不再反馈给模型",其余(含 `other` + /// 与未知分类)继续反馈。分类现在是 typed 的,判据不再依赖文本里出现过什么。 + fn is_terminal(&self) -> bool { + match self { + Self::ContextWindowExceeded + | Self::SessionBudgetExceeded + | Self::UsageLimitExceeded + | Self::RequestTooLarge + | Self::StreamRequired + | Self::CyberPolicy + | Self::SandboxError + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::Unauthorized => true, + Self::ActiveTurnNotSteerable | Self::Other { .. } => false, + } + } + + /// 给用户看的稳定摘要:只有"哪一类原因值得单独说一句"的分类才有。 + fn public_summary(&self) -> Option<&'static str> { + match self { + Self::ContextWindowExceeded => Some("模型上下文已超限"), + Self::SessionBudgetExceeded => Some("本次会话预算已耗尽"), + Self::UsageLimitExceeded => Some("用量已达上限"), + Self::RequestTooLarge => Some("模型请求体过大"), + Self::CyberPolicy => Some("安全策略拒绝了本次请求"), + Self::SandboxError => Some("工作区隔离启动失败"), + _ => None, + } + } + + /// 恢复建议:分类决定动作;没把握的分类不给建议,交给阶段兜底。 + fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::ContextWindowExceeded | Self::RequestTooLarge => { + Some("请缩小本次需求范围或减少参考图后重试") + } + Self::SessionBudgetExceeded | Self::UsageLimitExceeded => { + Some("请在账户页面确认可用额度后重试,或新建项目继续") + } + Self::Unauthorized => Some("登录态可能已失效,请重新登录陶泥儿后重试"), + Self::CyberPolicy => Some("请调整本次需求的内容后重试"), + Self::SandboxError => Some("请检查项目目录权限后重试"), + Self::StreamRequired + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::ActiveTurnNotSteerable + | Self::Other { .. } => None, + } + } + + /// 这一条分类值不值得当作"再试一次可能修好":与 [`Self::is_terminal`] 互为反义,但语义不同—— + /// 这里问的是"用户重试有没有意义",用于失败诊断的 `retryable` 字段。 + fn is_retryable(&self) -> bool { + match self { + Self::ContextWindowExceeded | Self::RequestTooLarge => true, + Self::SessionBudgetExceeded + | Self::UsageLimitExceeded + | Self::StreamRequired + | Self::CyberPolicy + | Self::SandboxError + | Self::ThreadRollbackFailed + | Self::BadRequest + | Self::Unauthorized + | Self::ActiveTurnNotSteerable + | Self::Other { .. } => false, + } + } +} + +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类。 +/// +/// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 +/// 完全一致(载荷 `kind` 只影响界面语气)。`native` 字段是原因文本里带出来的原生分类:有它时 +/// 决策看原生分类,没有时看这个变体本身。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum DirectModelCallKind { + /// `LlmError::Timeout`。 + ResponseTimedOut { attempts: u32 }, + /// `LlmError::Connectivity`。 + ConnectionFailed { attempts: u32 }, + /// `LlmError::Transport`:通道关闭 / 收尾历史落盘失败等宿主侧收场。 + TransportBroken, + /// `LlmError::StreamUnavailable`。 + StreamUnavailable, + /// `LlmError::InvalidConfig` / `LlmError::InvalidRequest`。 + RequestRejected { + native: Option, + }, + /// `LlmError::Upstream`(409 除外,那条是 [`Self::PaidCreditsInsufficient`])。 + UpstreamFailed { + status_code: u16, + native: Option, + }, + /// `LlmError::Upstream { status_code: 409 }`:平台约定这一条就是泥点余额不足。 + /// + /// 约定由 app-server 保证并有测试盯着: + /// `codex_app_server_failed_turn_maps_insufficient_mud_points_to_stable_upstream_error`。 + PaidCreditsInsufficient, + /// `LlmError::EmptyResponse`。 + EmptyResponse, + /// `LlmError::Deserialize`。 + PayloadInvalid { + native: Option, + }, +} + +impl DirectModelCallKind { + /// 事件失败载荷里的稳定分类(只影响界面语气,前端不得拿它做流程分支)。 + fn wire_kind(&self) -> &'static str { + match self { + Self::ResponseTimedOut { .. } => "timeout", + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + "transport-failed" + } + Self::RequestRejected { .. } => "request-rejected", + Self::UpstreamFailed { .. } + | Self::PaidCreditsInsufficient + | Self::EmptyResponse + | Self::PayloadInvalid { .. } => "model-failed", + } + } + + /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得。 + fn is_model_repairable(&self) -> bool { + match self { + Self::ResponseTimedOut { .. } => false, + Self::ConnectionFailed { .. } => true, + // 通道关闭与收尾落盘失败:宿主自己写着"不能自动重放未确认操作",重试没有安全路径。 + Self::TransportBroken => false, + Self::StreamUnavailable => false, + Self::RequestRejected { native } => match native { + Some(native) => !native.is_terminal(), + None => true, + }, + Self::UpstreamFailed { + status_code, + native, + } => match native { + Some(native) => !native.is_terminal(), + None => *status_code >= 500, + }, + Self::PaidCreditsInsufficient => false, + Self::EmptyResponse => false, + Self::PayloadInvalid { .. } => false, + } + } + + /// 用户重试这一轮有没有意义。 + fn is_retryable(&self) -> bool { + match self { + Self::ResponseTimedOut { .. } | Self::ConnectionFailed { .. } => true, + Self::TransportBroken | Self::StreamUnavailable => false, + Self::RequestRejected { native } => match native { + Some(native) => native.is_retryable(), + None => true, + }, + Self::UpstreamFailed { + status_code, + native, + } => match native { + Some(native) => native.is_retryable(), + None => *status_code >= 500, + }, + Self::PaidCreditsInsufficient => false, + Self::EmptyResponse => false, + Self::PayloadInvalid { .. } => false, + } + } + + /// 给用户看的稳定摘要:能一句话说清的才有,其余交给阶段兜底。 + fn public_summary(&self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足"), + Self::ResponseTimedOut { .. } => Some("等待模型回执超时"), + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + Some("执行通道未能建立或已断开") + } + Self::EmptyResponse => Some("模型未返回内容"), + Self::PayloadInvalid { .. } => Some("模型回执无法解析"), + Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native + .as_ref() + .and_then(DirectCodexNativeKind::public_summary), + } + } + + /// 恢复建议:分类能直接给出动作的就给,给不出就交给阶段兜底。 + fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::PaidCreditsInsufficient => Some("泥点余额不足,请充值后发送“继续”"), + Self::ResponseTimedOut { .. } => { + Some("上游响应超时,请稍后重试;如持续失败请检查项目诊断") + } + Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { + Some("执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断") + } + Self::EmptyResponse | Self::PayloadInvalid { .. } => { + Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断") + } + Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native + .as_ref() + .and_then(DirectCodexNativeKind::recovery_hint), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum DirectTurnError { + // ───────── 调用级拒绝:这一轮没有开始 ───────── + /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 + ClientTurnIdMissing, + /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 + ClientTurnIdMalformed { min_chars: usize, max_chars: usize }, + /// 同一项目已有另一条回合在跑(或同一 `clientTurnId` 并发复用)。 + /// + /// 两个身份都要带上,因为"撞的是哪一轮"决定界面该不该动当前回合。 + TurnAlreadyRunning { + existing_invocation_id: String, + incoming_invocation_id: String, + }, + /// 项目目录锚不定(符号链接 / 权限 / 目录被删)。 + ProjectRootUnanchored { cause: String }, + /// 项目目录不存在或不是绝对路径。 + ProjectRootUnusable, + /// 项目权限策略拒绝了这次调用;`policy_detail` 是策略层的原文(带被拒的点位)。 + PermissionRejected { policy_detail: String }, + /// 用户条目 / 创建类型本身不合法。 + InputRejected { detail: String }, + /// 结构化消息既没有正文也没有任何引用。 + ContentEmpty, + /// 环境 / 凭据 / 脚手架预检未就绪。 + EnvironmentNotReady { detail: String }, + /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 + HostStateUnavailable { detail: String }, + + // ───────── 回合级:这一轮已经开始 ───────── + /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 + ModelCallFailed { + kind: DirectModelCallKind, + detail: String, + }, + /// 执行通道断开;`diagnostic` 是连接终止时那份诊断(进程退出 / stderr 摘要)。 + TransportClosed { diagnostic: String }, + /// 等待模型回执撞上限。 + TimedOut { deadline: DirectTurnDeadline }, + /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 + TurnInterrupted { detail: String }, + /// 宿主复核要求继续本轮的返修批次 —— **控制流,不是失败**: + /// 交付模块用它把"还缺证据"交给下一步,界面不应该看到失败。 + ReviewRequired { detail: String }, + /// 已经写成诊断记录的回合失败:`detail` 是诊断正文(阶段 / 分类 / 建议 / 详情引用)。 + TurnFailed { + stage: DirectCodexFailureStage, + detail: String, + }, + /// 桥:深层只拿得到字符串的错误。只允许出现在"这一轮已经开始"的层里, + /// 且新分类必须先加 typed 变体,别借这个变体蒙混过关。 + TurnFailedUnclassified { detail: String }, +} + +impl DirectTurnError { + /// 这一轮是不是**已经开始并被判失败**。分流只认这一个判据。 + pub(crate) fn is_turn_failure(&self) -> bool { + match self { + Self::ModelCallFailed { .. } + | Self::TransportClosed { .. } + | Self::TimedOut { .. } + | Self::TurnInterrupted { .. } + | Self::TurnFailed { .. } + | Self::TurnFailedUnclassified { .. } => true, + Self::ClientTurnIdMissing + | Self::ClientTurnIdMalformed { .. } + | Self::TurnAlreadyRunning { .. } + | Self::ProjectRootUnanchored { .. } + | Self::ProjectRootUnusable + | Self::PermissionRejected { .. } + | Self::InputRejected { .. } + | Self::ContentEmpty + | Self::EnvironmentNotReady { .. } + | Self::HostStateUnavailable { .. } + | Self::ReviewRequired { .. } => false, + } + } + + /// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。 + pub(crate) fn wire_kind(&self) -> Option<&'static str> { + match self { + Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()), + Self::TransportClosed { .. } => Some("transport-failed"), + Self::TimedOut { .. } => Some("timeout"), + Self::TurnInterrupted { .. } => Some("turn-interrupted"), + Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => Some("model-failed"), + _ => None, + } + } + + /// 已记录失败的诊断阶段;拿不到阶段的错误归到回合主体的代码生成段。 + pub(crate) fn turn_failure_stage(&self) -> DirectCodexFailureStage { + match self { + Self::TurnFailed { stage, .. } => *stage, + _ => DirectCodexFailureStage::CodeGeneration, + } + } + + /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得(与旧的字面量判据逐条对齐)。 + pub(crate) fn is_model_repairable(&self) -> bool { + match self { + Self::ModelCallFailed { kind, .. } => kind.is_model_repairable(), + Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { + direct_code_failure_invites_repair(detail) + } + _ => false, + } + } + + /// 用户重试这一轮有没有意义。 + pub(crate) fn is_retryable(&self) -> bool { + match self { + Self::ModelCallFailed { kind, .. } => kind.is_retryable(), + Self::TransportClosed { .. } => false, + // 超时/中断后重试是常规动作:宿主已经把这一轮收干净了。 + Self::TimedOut { .. } | Self::TurnInterrupted { .. } => true, + Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { + !direct_code_failure_is_content_frozen(detail) + } + _ => false, + } + } + + /// 给用户看的稳定摘要:能一句话说清的才有,其余按阶段兜底。 + pub(crate) fn public_summary(&self) -> Option<&'static str> { + match self { + Self::ModelCallFailed { kind, .. } => kind.public_summary(), + _ => None, + } + } + + /// 恢复建议:分类 → 阶段 → 深层文案,逐级退让,最后一条由调用方兜底。 + pub(crate) fn recovery_hint(&self) -> Option<&'static str> { + match self { + Self::ModelCallFailed { kind, .. } => kind.recovery_hint(), + Self::TransportClosed { .. } => { + Some("执行通道已断开,本轮未完成;请重试,若持续失败请检查项目诊断") + } + Self::TimedOut { .. } => { + Some("上游响应超时,本轮未完成;请稍后重试,若持续失败请检查项目诊断") + } + Self::TurnInterrupted { .. } => { + Some("本轮执行被上游中断,请重试;如持续失败请检查项目诊断") + } + Self::TurnFailed { stage, detail } => direct_code_failure_recovery_hint(*stage, detail), + // 桥变体没有阶段可依,按回合主体的默认段给建议。 + Self::TurnFailedUnclassified { detail } => { + direct_code_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, detail) + } + _ => None, + } + } + + /// 把平台层 `LlmError` 投影成 Direct 回合错误。**分类只在这一个地方做一次。** + /// + /// 原生分类(`context-window-exceeded` 之类)不再变成"原因文本里的一段字",而是解析成 + /// [`DirectCodexNativeKind`];解析只读 app-server 写下的结构化前缀,不认任何文案。 + pub(crate) fn from_model_call(error: &LlmError) -> Self { + let detail = error.to_string(); + let kind = match error { + LlmError::Timeout { attempts } => DirectModelCallKind::ResponseTimedOut { + attempts: *attempts, + }, + LlmError::Connectivity { attempts, .. } => DirectModelCallKind::ConnectionFailed { + attempts: *attempts, + }, + // Transport / StreamUnavailable 的原因文本由宿主自己写,没有原生分类。 + LlmError::Transport(_) => DirectModelCallKind::TransportBroken, + LlmError::StreamUnavailable => DirectModelCallKind::StreamUnavailable, + LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => { + DirectModelCallKind::RequestRejected { + native: direct_codex_native_kind(&detail), + } + } + LlmError::Upstream { status_code, .. } if *status_code == 409 => { + DirectModelCallKind::PaidCreditsInsufficient + } + LlmError::Upstream { status_code, .. } => DirectModelCallKind::UpstreamFailed { + status_code: *status_code, + native: direct_codex_native_kind(&detail), + }, + LlmError::EmptyResponse => DirectModelCallKind::EmptyResponse, + LlmError::Deserialize(_) => DirectModelCallKind::PayloadInvalid { + native: direct_codex_native_kind(&detail), + }, + }; + Self::ModelCallFailed { kind, detail } + } + + /// 阶段失败:把深层错误挂到交付的某一段上。深层还没 typed 的口子由这里进桥变体。 + pub(crate) fn turn_failed(stage: DirectCodexFailureStage, detail: impl Into) -> Self { + Self::TurnFailed { + stage, + detail: detail.into(), + } + } +} + +impl fmt::Display for DirectTurnError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ClientTurnIdMissing => formatter.write_str( + "Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份", + ), + Self::ClientTurnIdMalformed { + min_chars, + max_chars, + } => write!( + formatter, + "clientTurnId 必须为 {min_chars} 到 {max_chars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" + ), + Self::TurnAlreadyRunning { + existing_invocation_id, + incoming_invocation_id, + } => { + if existing_invocation_id == incoming_invocation_id { + write!( + formatter, + "direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" + ) + } else { + write!( + formatter, + "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" + ) + } + } + Self::ProjectRootUnanchored { cause } => { + write!(formatter, "无法锚定 Direct 调用项目目录:{cause}") + } + Self::ProjectRootUnusable => { + formatter.write_str("当前项目目录不存在或不是绝对路径") + } + Self::PermissionRejected { policy_detail } => formatter.write_str(policy_detail), + Self::InputRejected { detail } + | Self::EnvironmentNotReady { detail } + | Self::HostStateUnavailable { detail } + | Self::ModelCallFailed { detail, .. } + | Self::TurnInterrupted { detail } + | Self::TurnFailed { detail, .. } + | Self::TurnFailedUnclassified { detail } => formatter.write_str(detail), + Self::ContentEmpty => formatter.write_str("聊天内容不能为空"), + Self::TransportClosed { diagnostic } => write!( + formatter, + "执行通道已断开,不能自动重放未确认操作:{diagnostic}" + ), + Self::TimedOut { deadline } => formatter.write_str(deadline.message()), + Self::ReviewRequired { detail } => formatter.write_str(detail), + } + } +} + +/// 跨进程边界(`#[tauri::command]`)的序列化:字符串只在这里生成一次。 +impl From for String { + fn from(error: DirectTurnError) -> Self { + error.to_string() + } +} + +/// 桥:深层尚未 typed 的字符串错误落进 [`DirectTurnError::TurnFailedUnclassified`]。 +/// +/// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 +impl From for DirectTurnError { + fn from(detail: String) -> Self { + Self::TurnFailedUnclassified { detail } + } +} + +impl From<&str> for DirectTurnError { + fn from(detail: &str) -> Self { + Self::TurnFailedUnclassified { + detail: detail.to_string(), + } + } +} + +/// 从原因文本里读出 app-server 写下的原生失败分类。 +/// +/// 只认结构化前缀与紧跟其后的分类 id;读不到就是"没有分类",不猜。 +fn direct_codex_native_kind(detail: &str) -> Option { + let rest = detail.split_once(DIRECT_CODEX_NATIVE_KIND_PREFIX)?.1; + let id = rest + .split(|character: char| character.is_whitespace()) + .next() + .unwrap_or_default(); + if id.is_empty() { + return None; + } + Some(DirectCodexNativeKind::from_id(id)) +} + +/// 深层(还没 typed 的)代码生成失败:值不值得反馈给模型继续修。 +/// +/// 这一层只剩**产生层还没给 typed 事实**的判据:交付模块的预算/验证状态、项目历史形状、凭据 +/// 存储。它们都还没有 typed 出口,所以这里仍在读文本;**新增分类必须先在产生层加 typed 变体**, +/// 别往这个列表里加词。 +fn direct_code_failure_invites_repair(detail: &str) -> bool { + const NOT_REPAIRABLE: &[&str] = &[ + "validation-budget-exhausted", + "validation-already-running", + "playtest-attempt-limit-exceeded", + "private-external-editor-credential-storage-preparation-failed", + "private-external-editor-credential-persistence-failed", + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + "合同发生变化", + "历史记录类型无效", + "历史记录缺少 payload", + "历史注入载荷超过单行上限", + "工具参数", + ]; + !NOT_REPAIRABLE.iter().any(|marker| detail.contains(marker)) +} + +/// 深层失败的"重试没有意义"判据:同一份输入每次都会得到同一结论的事实。 +/// +/// 与 [`direct_code_failure_invites_repair`] 同一类,都是等产生层给 typed 事实的临时判据。 +fn direct_code_failure_is_content_frozen(detail: &str) -> bool { + const FROZEN: &[&str] = &[ + "validation-budget-exhausted", + "validation-already-running", + "playtest-attempt-limit-exceeded", + "private-external-editor-credential-storage-preparation-failed", + "private-external-editor-credential-persistence-failed", + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + "合同发生变化", + "历史记录类型无效", + "历史记录缺少 payload", + "历史注入载荷超过单行上限", + ]; + FROZEN.iter().any(|marker| detail.contains(marker)) +} + +/// 阶段兜底的恢复建议:typed 分类给不出动作时,由阶段给一句与交付状态对得上的话。 +fn direct_code_failure_recovery_hint( + stage: DirectCodexFailureStage, + detail: &str, +) -> Option<&'static str> { + if detail.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) { + return Some("当前项目仍有写入正在结束,请稍后再次发送该需求"); + } + Some(match stage { + DirectCodexFailureStage::ArtPreparation => { + "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" + } + DirectCodexFailureStage::CodeGeneration => { + "Codex 未完成本轮代码修改,请检查运行时配置后重试" + } + DirectCodexFailureStage::BrowserValidation => { + "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" + } + DirectCodexFailureStage::VersionRegistration => { + "产物尚未安全登记为版本,请检查项目目录后重试" + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_turn_failures_report_as_turn_failures() { + assert!(!DirectTurnError::ContentEmpty.is_turn_failure()); + assert!(!DirectTurnError::PermissionRejected { + policy_detail: "项目权限策略拒绝执行:conversation.write".into(), + } + .is_turn_failure()); + assert!(!DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: "turn-1".into(), + incoming_invocation_id: "turn-1".into(), + } + .is_turn_failure()); + assert!(!DirectTurnError::ReviewRequired { + detail: "delivery-review-required: {}".into(), + } + .is_turn_failure()); + for error in [ + DirectTurnError::TransportClosed { + diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), + }, + DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::ResponseIdle, + }, + DirectTurnError::TurnInterrupted { + detail: "本轮模型执行被中断".into(), + }, + DirectTurnError::ModelCallFailed { + kind: DirectModelCallKind::PaidCreditsInsufficient, + detail: "LLM 上游返回 409:泥点余额不足".into(), + }, + DirectTurnError::TurnFailed { + stage: DirectCodexFailureStage::CodeGeneration, + detail: "direct-codex-failure:v2 ...".into(), + }, + DirectTurnError::TurnFailedUnclassified { + detail: "未知".into(), + }, + ] { + assert!(error.is_turn_failure(), "{error:?} 应该算回合级失败"); + } + } + + /// 并发拒绝的两条文案按身份是否相同分岔,身份必须原样带出来。 + #[test] + fn concurrent_rejection_keeps_both_invocations_and_splits_the_copy() { + let same = DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: "turn-1".into(), + incoming_invocation_id: "turn-1".into(), + }; + assert!(same + .to_string() + .starts_with("direct-codex-turn-already-running: ")); + let different = DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: "turn-1".into(), + incoming_invocation_id: "turn-2".into(), + }; + assert!(different + .to_string() + .contains("另一条 Direct 客户端回合正在运行")); + assert!(!different + .to_string() + .starts_with("direct-codex-turn-already-running:")); + } + + /// 边界序列化:字符串只由 Display 生成,且与改造前的可见文本一致。 + #[test] + fn boundary_serialization_uses_display() { + let wire: String = DirectTurnError::ContentEmpty.into(); + assert_eq!(wire, "聊天内容不能为空"); + let wire: String = DirectTurnError::TimedOut { + deadline: DirectTurnDeadline::TurnHardLimit, + } + .into(); + assert_eq!( + wire, + "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。" + ); + let wire: String = DirectTurnError::TransportClosed { + diagnostic: "执行通道已断开".into(), + } + .into(); + assert_eq!( + wire, + "执行通道已断开,不能自动重放未确认操作:执行通道已断开" + ); + } + + /// 载荷 kind 与改造前的 `direct_turn_failure_kind(&LlmError)` 逐条对齐。 + #[test] + fn wire_kind_matches_the_previous_llm_error_classification() { + let cases = [ + (LlmError::Timeout { attempts: 3 }, "timeout"), + ( + LlmError::InvalidConfig("missing key".into()), + "request-rejected", + ), + ( + LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()), + "request-rejected", + ), + ( + LlmError::Connectivity { + attempts: 2, + message: "Codex app-server 连接失败".into(), + }, + "transport-failed", + ), + ( + LlmError::Transport("DirectProject 收尾历史失败".into()), + "transport-failed", + ), + (LlmError::StreamUnavailable, "transport-failed"), + ( + LlmError::Upstream { + status_code: 502, + message: "上游 502".into(), + }, + "model-failed", + ), + (LlmError::EmptyResponse, "model-failed"), + (LlmError::Deserialize("bad payload".into()), "model-failed"), + ]; + for (error, expected) in cases { + let projected = DirectTurnError::from_model_call(&error); + assert_eq!(projected.wire_kind(), Some(expected), "{error:?}"); + assert_eq!(projected.to_string(), error.to_string(), "{error:?}"); + } + } + + /// 原生分类被读成 typed 值:未知分类不吞掉,落 `Other`。 + #[test] + fn native_kind_is_read_from_the_structured_prefix_only() { + let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded detail=fields=codexErrorInfo".into(), + )); + match projected { + DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( + kind, + DirectModelCallKind::RequestRejected { + native: Some(DirectCodexNativeKind::ContextWindowExceeded) + } + ), + other => panic!("expected a model call failure, got {other:?}"), + } + let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:some-future-kind".into(), + )); + match projected { + DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( + kind, + DirectModelCallKind::RequestRejected { + native: Some(DirectCodexNativeKind::Other { + kind: "some-future-kind".into() + }) + } + ), + other => panic!("expected a model call failure, got {other:?}"), + } + // 文本里没有结构化前缀就是不分类,不靠"像不像"猜。 + let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "Codex app-server turn 已中断".into(), + )); + match projected { + DirectTurnError::ModelCallFailed { kind, .. } => { + assert_eq!(kind, DirectModelCallKind::RequestRejected { native: None }) + } + other => panic!("expected a model call failure, got {other:?}"), + } + } + + /// 上游 409 是平台约定的泥点余额不足,进专属分类。 + #[test] + fn upstream_payment_refusal_is_its_own_kind() { + let projected = DirectTurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "泥点余额不足".into(), + }); + match &projected { + DirectTurnError::ModelCallFailed { kind, .. } => { + assert_eq!(kind, &DirectModelCallKind::PaidCreditsInsufficient) + } + other => panic!("expected a model call failure, got {other:?}"), + } + assert_eq!(projected.public_summary(), Some("泥点余额不足")); + assert_eq!( + projected.recovery_hint(), + Some("泥点余额不足,请充值后发送“继续”") + ); + assert!(!projected.is_retryable()); + assert!(!projected.is_model_repairable()); + } + + /// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。 + #[test] + fn terminal_native_kinds_are_not_fed_back_to_the_model() { + let terminal = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:context-window-exceeded".into(), + )); + assert!(!terminal.is_model_repairable()); + let repairable = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + "codex-app-server-error:other detail=fields=codexErrorInfo".into(), + )); + assert!(repairable.is_model_repairable()); + // 通道类失败里只有"连接层反复失败"值得让模型再跑一次。 + assert!(DirectTurnError::from_model_call(&LlmError::Connectivity { + attempts: 2, + message: "连接失败".into(), + }) + .is_model_repairable()); + assert!(!DirectTurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败".into() + )) + .is_model_repairable()); + assert!( + !DirectTurnError::from_model_call(&LlmError::Timeout { attempts: 1 }) + .is_model_repairable() + ); + } +}