diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 2bf6360b3..b089884e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -1137,8 +1137,6 @@ pub(super) async fn wait_outcome( mod tests { use super::super::DirectTurnDeadline; - /// 通道断开在事件载荷里的稳定分类(`DirectTurnError::wire_kind` 的取值之一)。 - const EXPECTED_TRANSPORT_KIND: &str = "transport-failed"; use super::*; fn fixture() -> (tempfile::TempDir, Arc) { @@ -1200,7 +1198,10 @@ mod tests { // 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。 let failure = adapter.turn_failure().expect("host fact must be recorded"); - assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND)); + assert_eq!( + failure.wire_kind(), + Some(super::super::DirectTurnFailureKind::TransportFailed) + ); assert!(failure.to_string().contains("SIGKILL")); // 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。 assert!(adapter.report().contains("SIGKILL")); @@ -1212,7 +1213,10 @@ mod tests { }) .await; let failure = adapter.turn_failure().expect("first reason is kept"); - assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND)); + assert_eq!( + failure.wire_kind(), + Some(super::super::DirectTurnFailureKind::TransportFailed) + ); assert!(failure.to_string().contains("SIGKILL")); assert!(!failure.to_string().contains("超时")); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 67a405b3c..8ec3eed2a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -6122,7 +6122,10 @@ mod tests { )); let payload = direct_turn_terminal_write(None, Some(&failed)).expect("真失败必须写终态"); let error = payload.expect_err("失败终态必须带载荷"); - assert_eq!(error.wire_kind(), Some("transport-failed")); + assert_eq!( + error.wire_kind(), + Some(crate::agent::DirectTurnFailureKind::TransportFailed) + ); // 解析失败(structured output 非法)也走这条投影:以前解析排在终态**之后**, // 于是这条 Err 谁都不接——终态已经是 `completed`,兜底的 `finish_if_unfinished` @@ -6134,7 +6137,7 @@ mod tests { direct_turn_terminal_write(None, Some(&parse_failed)).expect("解析失败必须写终态"); assert_eq!( payload.expect_err("解析失败必须带载荷").wire_kind(), - Some("model-failed") + Some(crate::agent::DirectTurnFailureKind::ModelFailed) ); let payload = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 2ef0a326d..681399347 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -874,7 +874,10 @@ mod tests { manager.append( "thread-1", DirectThreadEvent::turn_completed_failed( - crate::agent::DirectTurnFailure::new("host-dropped", "回合宿主任务提前结束"), + crate::agent::DirectTurnFailure::new( + crate::agent::DirectTurnFailureKind::HostDropped, + "回合宿主任务提前结束", + ), FIXED_AT_MS, ), ); @@ -884,7 +887,8 @@ mod tests { bootstrap.events.as_slice(), [DirectThreadEvent::TurnCompleted { status, failure, at, .. }] if status == "failed" - && failure.as_ref().is_some_and(|failure| failure.kind == "host-dropped") + && failure.as_ref().is_some_and(|failure| failure.kind + == crate::agent::DirectTurnFailureKind::HostDropped) && *at == Some(FIXED_AT_MS) )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs index eb616a698..952dd3a24 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -13,6 +13,7 @@ use crate::agent::redact_secret_tokens; use crate::agent::sanitize_error_context; +use crate::agent::DirectTurnFailureKind; use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -219,17 +220,17 @@ impl DirectThreadRequestKind { #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) struct DirectTurnFailure { - /// 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / - /// `environment-not-ready` / `host-dropped`。 - pub(crate) kind: String, + /// 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 + /// [`DirectTurnError::wire_kind`]。 + pub(crate) kind: DirectTurnFailureKind, /// 脱敏 + 截断后的失败原因。 pub(crate) message: String, } impl DirectTurnFailure { - pub(crate) fn new(kind: impl Into, message: impl Into) -> Self { + pub(crate) fn new(kind: DirectTurnFailureKind, message: impl Into) -> Self { Self { - kind: kind.into(), + kind, message: message.into(), } } @@ -1416,14 +1417,17 @@ mod tests { #[test] fn turn_completed_carries_failure_payload_only_when_failed() { let failed = DirectThreadEvent::turn_completed_failed( - DirectTurnFailure::new("model-failed", "上游返回 500:模型服务暂不可用"), + DirectTurnFailure::new( + crate::agent::DirectTurnFailureKind::ModelFailed, + "上游返回 500:模型服务暂不可用", + ), 4_000, ) .with_user_item_id(Some("direct-codex:turn-1:user")); assert_eq!( failed.failure(), Some(&DirectTurnFailure::new( - "model-failed", + crate::agent::DirectTurnFailureKind::ModelFailed, "上游返回 500:模型服务暂不可用" )) ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs index 98d0db08c..b7d0c4ca9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_accept.rs @@ -85,7 +85,7 @@ mod tests { use super::*; use crate::agent::{ consume_direct_thread, direct_thread_turn_is_active, subscribe_direct_thread, - DirectThreadEvent, DirectTurnFailure, + DirectThreadEvent, DirectTurnFailure, DirectTurnFailureKind, }; /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 @@ -173,7 +173,7 @@ mod tests { failure: Some(failure), .. } => { - assert_eq!(failure.kind, "host-dropped"); + assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); assert_eq!(user_item_id.as_deref(), Some("u-1")); } other => panic!("expected a failed terminal, got {other:?}"), @@ -189,7 +189,10 @@ mod tests { // 深层收口:真正跑完这一轮的代码算出来的终态。 let deep = DirectThreadEvent::turn_completed_failed( - DirectTurnFailure::new("timeout".to_string(), "等待模型回执超时".to_string()), + DirectTurnFailure::new( + DirectTurnFailureKind::Timeout, + "等待模型回执超时".to_string(), + ), 2_000, ) .with_user_item_id(Some("u-1")); @@ -206,7 +209,10 @@ mod tests { assert_eq!(completed.len(), 1, "一轮只许有一条终态:{events:?}"); match completed[0] { DirectThreadEvent::TurnCompleted { failure, .. } => { - assert_eq!(failure.as_ref().map(|f| f.kind.as_str()), Some("timeout")); + assert_eq!( + failure.as_ref().map(|f| f.kind), + Some(DirectTurnFailureKind::Timeout) + ); } other => panic!("expected a terminal, got {other:?}"), } 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 index 913a99644..537dbb819 100644 --- 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 @@ -27,7 +27,7 @@ use std::fmt; use platform_llm::LlmError; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use ts_rs::TS; /// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 @@ -210,6 +210,31 @@ impl DirectCodexNativeKind { } } +/// 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 +/// +/// 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 +/// 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, +/// 枚举成员名与线上取值一一对应,改名即改协议。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum DirectTurnFailureKind { + /// 等待模型回执撞上上限。 + Timeout, + /// 模型 / 上游 / 交付阶段的失败(说不出更细分类的也归这里)。 + ModelFailed, + /// 执行通道断开。 + TransportFailed, + /// app-server 或上游明确拒绝了这次请求。 + RequestRejected, + /// 接单之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 + EnvironmentNotReady, + /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 + TurnInterrupted, + /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 + HostDropped, +} + /// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 /// /// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 @@ -255,17 +280,17 @@ pub(crate) enum DirectModelCallKind { impl DirectModelCallKind { /// 事件失败载荷里的稳定分类(只影响界面语气,前端不得拿它做流程分支)。 - fn wire_kind(&self) -> &'static str { + fn wire_kind(&self) -> DirectTurnFailureKind { match self { - Self::ResponseTimedOut { .. } => "timeout", + Self::ResponseTimedOut { .. } => DirectTurnFailureKind::Timeout, Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { - "transport-failed" + DirectTurnFailureKind::TransportFailed } - Self::RequestRejected { .. } => "request-rejected", + Self::RequestRejected { .. } => DirectTurnFailureKind::RequestRejected, Self::UpstreamFailed { .. } | Self::PaidCreditsInsufficient | Self::EmptyResponse - | Self::PayloadInvalid { .. } => "model-failed", + | Self::PayloadInvalid { .. } => DirectTurnFailureKind::ModelFailed, } } @@ -475,15 +500,17 @@ impl DirectTurnError { } /// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。 - pub(crate) fn wire_kind(&self) -> Option<&'static str> { + pub(crate) fn wire_kind(&self) -> Option { match self { Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()), - Self::TransportClosed { .. } => Some("transport-failed"), + Self::TransportClosed { .. } => Some(DirectTurnFailureKind::TransportFailed), // 接了单才失败的连接 / 配置 / 凭据类原因:它们不是模型的问题,界面语气也不一样。 - Self::EnvironmentNotReady { .. } => Some("environment-not-ready"), - Self::TimedOut { .. } => Some("timeout"), - Self::TurnInterrupted { .. } => Some("turn-interrupted"), - Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => Some("model-failed"), + Self::EnvironmentNotReady { .. } => Some(DirectTurnFailureKind::EnvironmentNotReady), + Self::TimedOut { .. } => Some(DirectTurnFailureKind::Timeout), + Self::TurnInterrupted { .. } => Some(DirectTurnFailureKind::TurnInterrupted), + Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => { + Some(DirectTurnFailureKind::ModelFailed) + } _ => None, } } @@ -1043,36 +1070,45 @@ mod tests { #[test] fn wire_kind_matches_the_previous_llm_error_classification() { let cases = [ - (LlmError::Timeout { attempts: 3 }, "timeout"), + ( + LlmError::Timeout { attempts: 3 }, + DirectTurnFailureKind::Timeout, + ), ( LlmError::InvalidConfig("missing key".into()), - "request-rejected", + DirectTurnFailureKind::RequestRejected, ), ( LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()), - "request-rejected", + DirectTurnFailureKind::RequestRejected, ), ( LlmError::Connectivity { attempts: 2, message: "Codex app-server 连接失败".into(), }, - "transport-failed", + DirectTurnFailureKind::TransportFailed, ), ( LlmError::Transport("DirectProject 收尾历史失败".into()), - "transport-failed", + DirectTurnFailureKind::TransportFailed, + ), + ( + LlmError::StreamUnavailable, + DirectTurnFailureKind::TransportFailed, ), - (LlmError::StreamUnavailable, "transport-failed"), ( LlmError::Upstream { status_code: 502, message: "上游 502".into(), }, - "model-failed", + DirectTurnFailureKind::ModelFailed, + ), + (LlmError::EmptyResponse, DirectTurnFailureKind::ModelFailed), + ( + LlmError::Deserialize("bad payload".into()), + DirectTurnFailureKind::ModelFailed, ), - (LlmError::EmptyResponse, "model-failed"), - (LlmError::Deserialize("bad payload".into()), "model-failed"), ]; for (error, expected) in cases { let projected = DirectTurnError::from_model_call(&error); @@ -1081,6 +1117,32 @@ mod tests { } } + /// 载荷 `kind` 的线上取值只有这一份:改枚举成员名就是改协议,必须在这一条用例上先失败。 + /// 新增变体时在这里补一行(生成绑定 `DirectTurnFailureKind.ts` 会同步出现新取值)。 + #[test] + fn failure_kind_wire_values_are_stable() { + let cases = [ + (DirectTurnFailureKind::Timeout, "timeout"), + (DirectTurnFailureKind::ModelFailed, "model-failed"), + (DirectTurnFailureKind::TransportFailed, "transport-failed"), + (DirectTurnFailureKind::RequestRejected, "request-rejected"), + ( + DirectTurnFailureKind::EnvironmentNotReady, + "environment-not-ready", + ), + (DirectTurnFailureKind::TurnInterrupted, "turn-interrupted"), + (DirectTurnFailureKind::HostDropped, "host-dropped"), + ]; + for (kind, wire) in cases { + assert_eq!(serde_json::to_value(kind).expect("serialize"), wire); + assert_eq!( + serde_json::from_str::(&format!("\"{wire}\"")) + .expect("deserialize"), + kind + ); + } + } + /// 原生分类被读成 typed 值:未知分类不吞掉,落 `Other`。 #[test] fn native_kind_is_read_from_the_structured_prefix_only() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs index 64ea343da..7c78d7215 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -15,14 +15,16 @@ use std::path::Path; -use super::{redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure}; +use super::{ + redact_agent_runtime_error, DirectThreadEvent, DirectTurnError, DirectTurnFailure, + DirectTurnFailureKind, +}; /// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 /// 把整段上游报文塞进事件队列。 const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600; /// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。 -const DIRECT_TURN_FAILURE_HOST_DROPPED_KIND: &str = "host-dropped"; const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; @@ -91,7 +93,9 @@ impl DirectTurnTerminal { Self { status: "failed".to_string(), failure: Some(DirectTurnFailure::new( - failure.wire_kind().unwrap_or("model-failed").to_string(), + failure + .wire_kind() + .unwrap_or(DirectTurnFailureKind::ModelFailed), redact_agent_runtime_error( history_root, &failure.to_string(), @@ -108,7 +112,7 @@ impl DirectTurnTerminal { Self { status: "failed".to_string(), failure: Some(DirectTurnFailure::new( - DIRECT_TURN_FAILURE_HOST_DROPPED_KIND.to_string(), + DirectTurnFailureKind::HostDropped, DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(), )), } @@ -146,7 +150,7 @@ mod tests { .failure .expect("transport error must fail the turn"); assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, "transport-failed"); + assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); assert!(failure.message.contains("收尾历史失败")); } @@ -162,7 +166,7 @@ mod tests { let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root()); let failure = terminal.failure.expect("native failure must fail the turn"); assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, "request-rejected"); + assert_eq!(failure.kind, DirectTurnFailureKind::RequestRejected); assert_eq!( failure.message, "codex-app-server-error:context-window-exceeded" @@ -178,7 +182,7 @@ mod tests { let failure = terminal .failure .expect("unreadable ledger must fail the turn"); - assert_eq!(failure.kind, "model-failed"); + assert_eq!(failure.kind, DirectTurnFailureKind::ModelFailed); assert_eq!(failure.message, "报告"); } @@ -198,7 +202,7 @@ stderrClass=nonempty;stderrBytes=1000"; ); let failure = terminal.failure.expect("host fact must fail the turn"); assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, "transport-failed"); + assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); assert!(failure.message.contains("SIGKILL")); assert!(!failure.message.contains("正在核对自有子进程")); @@ -216,7 +220,7 @@ stderrClass=nonempty;stderrBytes=1000"; &history_root(), ); let failure = terminal.failure.expect("host fact must fail the turn"); - assert_eq!(failure.kind, "turn-interrupted"); + assert_eq!(failure.kind, DirectTurnFailureKind::TurnInterrupted); assert!(failure.message.contains("本轮模型执行被中断")); } @@ -230,8 +234,8 @@ stderrClass=nonempty;stderrBytes=1000"; let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root()); let event = failing.event(2_000, Some("direct-codex:turn-1:user")); assert_eq!( - event.failure().map(|failure| failure.kind.as_str()), - Some("model-failed") + event.failure().map(|failure| failure.kind), + Some(DirectTurnFailureKind::ModelFailed) ); assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user")); assert_eq!(event.at(), Some(2_000)); @@ -251,7 +255,7 @@ stderrClass=nonempty;stderrBytes=1000"; let terminal = DirectTurnTerminal::host_dropped(); assert_eq!(terminal.status, "failed"); let failure = terminal.failure.expect("host-dropped must fail the turn"); - assert_eq!(failure.kind, "host-dropped"); + assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); assert!(!failure.message.trim().is_empty()); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts index 4b7bde1c9..5b343123c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectTurnFailureKind } from './DirectTurnFailureKind'; /** * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 @@ -8,10 +9,10 @@ */ export type DirectTurnFailure = { /** - * 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / - * `environment-not-ready` / `host-dropped`。 + * 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 + * [`DirectTurnError::wire_kind`]。 */ - kind: string; + kind: DirectTurnFailureKind; /** * 脱敏 + 截断后的失败原因。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts new file mode 100644 index 000000000..11275386d --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 + * + * 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 + * 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, + * 枚举成员名与线上取值一一对应,改名即改协议。 + */ +export type DirectTurnFailureKind = + | 'timeout' + | 'model-failed' + | 'transport-failed' + | 'request-rejected' + | 'environment-not-ready' + | 'turn-interrupted' + | 'host-dropped';