From d31a758c9c4a6ef86f2780a1bb3c9774aeedc00f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 23 Sep 2026 19:56:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E4=B8=8E=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=EF=BC=9A=E6=8E=A5=E5=8D=95=E8=A2=AB=E6=8B=92=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=20typed=20=E9=94=99=E8=AF=AF=EF=BC=8C=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E6=8C=89=E5=8F=98=E4=BD=93=E5=88=86=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主侧把拒单收成结构化载荷,界面不再解析任何文案前缀。 - `DirectTurnError` 及其嵌套枚举补 `Serialize + TS`,导出到 `chat/generated/` - 新增 `DirectTurnRejection`(结构化变体 + `Display` 生成的唯一一份文案),命令返回类型改为它 - `EnvironmentNotReady` 补 `environment-not-ready` 失败分类,避免回合失败被写成 `model-failed` - `TurnAlreadyRunning` 去掉机器前缀,两条文案按身份是否相同分岔 - 删掉「按文案前缀判定」的协议约定与 `is_turn_failure`,通道改由**发生位置**决定 - 兜底终止路径改走 `complete_direct_thread_turn`:写终态的同时解除占用,不再只裸追加事件 前端按 `error.type` 分流,删掉三个按文案判断的旧函数。 - 新增 `readDirectTurnRejection` / `directTurnRejectionNotice` / `directTurnRejectionNoticeMessageId` - 认得的参数 / 前置条件类(空内容、并发、参数非法、工程根等)写成与用户消息同级的提示, 不占状态行、不写运行错误、不上报 - 认不得的宿主 / 环境事实与其它非结构化错误原样抛出,走既有捕获链路(上报 + 横幅) - `chat_with_game_creator_direct_codex` 的 catch 从此只剩「拒单」一种输入 测试与绑定同步更新:appSurface 两条用例按新语义重写,`userItemId` / 终态时刻的注释跟着改。 --- .../src/agent/codex_app_server/mod.rs | 4 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 37 +++++--- .../src/agent/direct_runtime/user_input.rs | 4 +- .../src-tauri/src/agent/direct_turn_error.rs | 89 ++++++++++++++----- .../useDirectProjectChatController.ts | 66 +++++++------- .../conversation/directCodexConversation.ts | 68 +++++++++----- .../chat/generated/DirectCodexFailureStage.ts | 12 +++ .../chat/generated/DirectCodexNativeKind.ts | 24 +++++ .../chat/generated/DirectModelCallKind.ts | 24 +++++ .../chat/generated/DirectThreadEvent.ts | 8 +- .../chat/generated/DirectTurnDeadline.ts | 8 ++ .../chat/generated/DirectTurnError.ts | 30 +++++++ .../chat/generated/DirectTurnFailure.ts | 2 +- .../chat/generated/DirectTurnRejection.ts | 20 +++++ .../tests/appSurface/chat-composer.suite.ts | 76 +++++++--------- .../appSurface/project-conversation.suite.ts | 58 ++++++++---- 16 files changed, 374 insertions(+), 156 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts 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 a07ca3f43..e2dbb6da2 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 @@ -4382,7 +4382,9 @@ pub(crate) fn cancel_direct_codex_turn_at( release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 - append_direct_thread_event( + // 走 Thread Manager 的深层出口而不是裸 append:这是**为这一轮写的终态**,占用必须 + // 同时解除,否则这个 thread 会一直被认为是"还有没收口的回合",挡住后面的接单。 + complete_direct_thread_turn( &direct_thread_id_for_project(root), direct_stale_cancel_turn_completed_event(&released), ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index e4fb711ab..4ffebc77e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2257,10 +2257,26 @@ pub(crate) fn direct_turn_error_boundary_text( client_turn_id: Option<&str>, failure: DirectTurnError, ) -> String { + direct_turn_rejection(root, client_turn_id, failure).message +} + +/// 拒单边界:可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 +/// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 +/// +/// GUI 命令与 CLI 边界共用这一份判据(CLI 只要文本,走上面的 `..._text`),禁止在各自边界再写一套。 +pub(crate) fn direct_turn_rejection( + root: &Path, + client_turn_id: Option<&str>, + failure: DirectTurnError, +) -> DirectTurnRejection { if !failure.is_reportable() { - return failure.to_string(); + return DirectTurnRejection::new(failure); + } + let message = record_direct_codex_failure(root, &failure, client_turn_id); + DirectTurnRejection { + error: failure, + message, } - record_direct_codex_failure(root, &failure, client_turn_id) } fn direct_taonier_art_generation_runtime_context( @@ -5810,18 +5826,17 @@ mod tests { ), "{duplicate:?}" ); - assert!( - duplicate - .to_string() - .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), - "{duplicate}" + // 界面按 typed 变体的两个身份字段分流,不解析文案:同一条身份 != 另一条身份。 + assert_eq!( + duplicate.to_string(), + "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送" ); let different = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-0002") .expect_err("different turn cannot take over the project"); assert!( - !different + different .to_string() - .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX), + .contains("已有另一条 Direct 客户端回合正在运行"), "{different}" ); drop(first); @@ -5856,9 +5871,9 @@ mod tests { let duplicate = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") .expect_err("read-only probe must not take over the project"); - assert!(!duplicate + assert!(duplicate .to_string() - .starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX)); + .contains("已有另一条 Direct 客户端回合正在运行")); drop(first); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index d78abbb83..695d3186a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -45,7 +45,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, -) -> Result<(), String> { +) -> Result<(), DirectTurnRejection> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); chat_with_game_creator_direct_codex_typed( @@ -56,7 +56,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex( analytics_attempt_id, ) .await - .map_err(|failure| direct_turn_error_boundary_text(root, boundary_turn_id.as_deref(), failure)) + .map_err(|failure| direct_turn_rejection(root, boundary_turn_id.as_deref(), failure)) } /// 命令主体:全程 typed。顺序固定,**每一步失败都还是拒单**: 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 95363e73e..1afcb32ea 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,6 +27,8 @@ use std::fmt; use platform_llm::LlmError; +use serde::Serialize; +use ts_rs::TS; /// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 /// @@ -34,15 +36,12 @@ use platform_llm::LlmError; /// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。 const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; -/// 并发复用同一 `clientTurnId` 时的稳定前缀。 -/// -/// 前端按前缀识别这一条(`directCodexConversation.ts` 里有一份同样的字面量):它让界面把"同一轮 -/// 重复发送"与"另一轮正在跑"分开处理,所以它同时是文案约定和协议约定,改这里要一起改前端。 -pub(crate) const DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX: &str = - "direct-codex-turn-already-running:"; - /// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// +/// 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexFailureStage { ArtPreparation, CodeGeneration, @@ -62,7 +61,11 @@ impl DirectCodexFailureStage { } /// 宿主等不到模型回执时,撞的是哪一条上限。 -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// +/// 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectTurnDeadline { /// 空闲上限:一段时间没有新事件。 ResponseIdle, @@ -85,7 +88,15 @@ impl DirectTurnDeadline { /// 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), /// 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 /// 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 -#[derive(Clone, Debug, PartialEq, Eq)] +/// +/// 线上取值只给界面选语气用,前端不得拿它做流程分支。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectCodexNativeKind { ContextWindowExceeded, SessionBudgetExceeded, @@ -196,12 +207,18 @@ impl DirectCodexNativeKind { } } -/// 模型调用失败(app-server 一次 `turn` 的结果)的分类。 +/// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 /// /// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 -/// 完全一致(载荷 `kind` 只影响界面语气)。`native` 字段是原因文本里带出来的原生分类:有它时 -/// 决策看原生分类,没有时看这个变体本身。 -#[derive(Clone, Debug, PartialEq, Eq)] +/// 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 +/// `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectModelCallKind { /// `LlmError::Timeout`。 ResponseTimedOut { attempts: u32 }, @@ -331,7 +348,14 @@ impl DirectModelCallKind { } } -#[derive(Clone, Debug, PartialEq, Eq)] +/// 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] pub(crate) enum DirectTurnError { // ───────── 拒单:接单之前发生,这一轮没有开始 ───────── /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 @@ -386,6 +410,30 @@ pub(crate) enum DirectTurnError { TurnFailedUnclassified { detail: String }, } +/// 拒单载荷:命令边界交给前端的**结构化拒绝**。 +/// +/// 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 +/// 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 +/// `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct DirectTurnRejection { + /// 结构化变体:界面按 `error.type` 分流,不解析文案。 + pub(crate) error: DirectTurnError, + /// 可展示文案(`Display` 的唯一出口)。 + pub(crate) message: String, +} + +impl DirectTurnRejection { + pub(crate) fn new(error: DirectTurnError) -> Self { + Self { + message: error.to_string(), + error, + } + } +} + impl DirectTurnError { /// 命令边界要不要为这条**拒单**补一份运行错误诊断。 /// @@ -561,10 +609,11 @@ impl fmt::Display for DirectTurnError { existing_invocation_id, incoming_invocation_id, } => { + // 两条文案按身份是否相同分岔,但**不再有机器前缀**:界面按 `error.type` 与其 + // 两个身份字段分流,不解析文案(前缀曾经是协议约定,现在只是噪声)。 if existing_invocation_id == incoming_invocation_id { - write!( - formatter, - "{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" + formatter.write_str( + "同一轮消息仍在处理中,已拒绝并发复用同一 clientTurnId;请等它结束或点「终止」后再发送", ) } else { write!( @@ -965,9 +1014,7 @@ mod tests { existing_invocation_id: "turn-1".into(), incoming_invocation_id: "turn-1".into(), }; - assert!(same - .to_string() - .starts_with("direct-codex-turn-already-running: ")); + assert!(same.to_string().contains("同一轮消息仍在处理中")); let different = DirectTurnError::TurnAlreadyRunning { existing_invocation_id: "turn-1".into(), incoming_invocation_id: "turn-2".into(), diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 2fb54cb81..851515762 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -35,9 +35,9 @@ import { directCodexConversationMessageId, directCodexPolicyRetryInput, type DirectProjectTurnInput, - isDirectCodexAnotherTurnRunningError, - isDirectCodexTurnAlreadyRunningError, - isDirectCodexTurnInterruptedError, + directTurnRejectionNotice, + directTurnRejectionNoticeMessageId, + readDirectTurnRejection, } from '../conversation/directCodexConversation'; import { DIRECT_CODEX_SESSION_KEEPALIVE_MS } from '../conversation/directCodexSessionKeepalive'; import { @@ -577,39 +577,38 @@ export function useDirectProjectChatController({ runAnalytics.settle(); } // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 - // TODO(Direct 命令接单化,未实施):下面这个 catch 现在兼职"接单被拒"与"回合失败"两种回执。 - // 计划(`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`)是让命令只负责接单:整轮结果 - // 只由订阅事件的载荷回答,拒单则返回 typed 错误——届时这里只剩"拒单",认得的前置 / 参数类按变体 - // 给与用户消息同级的提示且不上报,认不得的错误原样抛出交给既有捕获链路,状态行改由 reducer 供数。 - // invoke 拒绝驱动的认证重试已按该 ADR 删除(`directCodexSessionKeepalive.ts` 只保留会话保活)。 } catch (error) { - if ( - isDirectCodexTurnAlreadyRunningError(error) || - isDirectCodexAnotherTurnRunningError(error) - ) { - if (projectPathRef.current === nextProjectPath) { - onRuntimeError( - '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', - ); + // 命令的拒单是**结构化的**:命令返回 `Ok` 只说明接单成立,所以这条 catch 从接单化之后 + // 只剩"拒单"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 + const rejection = readDirectTurnRejection(error); + if (rejection) { + const notice = directTurnRejectionNotice(rejection); + if (notice) { + // 认得的前置条件 / 参数类拒单:写成与用户消息同级的提示,不占状态行、不写运行错误、 + // 也不上报(用户自己就能改,上报只会变成噪声)。 + if (projectPathRef.current === nextProjectPath) { + onRuntimeError(''); + appendLocalMessage({ + role: 'assistant', + text: notice, + runtimeOwned: true, + messageId: directTurnRejectionNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), + updatedAt: Date.now(), + }); + } + return; } - return; } if (projectPathRef.current !== nextProjectPath) return; - if (isDirectCodexTurnInterruptedError(error)) { - // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 - onRuntimeError(''); - setComposerNotice('已终止本次回合'); - appendLocalMessage({ - role: 'assistant', - text: '已终止本次回合。', - runtimeOwned: true, - messageId: `direct-codex:${input.clientTurnId}:failure`, - updatedAt: Date.now(), - }); - return; - } + // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - const message = error instanceof Error ? error.message : String(error); + const message = rejection + ? rejection.message + : error instanceof Error + ? error.message + : String(error); // 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在 // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 const visibleMessage = projectRuntimeVisibleError( @@ -618,9 +617,8 @@ export function useDirectProjectChatController({ true, ); if (projectPathRef.current !== nextProjectPath) return; - // 聊天里的失败说明不再由这里写:宿主已经把脱敏后的原因放进了 - // `turn.completed.failure`,reducer 会把它落成本轮最后一条条目(唯一来源)。这里只保留 - // 运行错误横幅(含 `详情:` 那份长 detail)与诊断留痕,两条通道不再各写一份文案。 + // 聊天里的失败说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它 + // 落成本轮最后一条条目(唯一来源)。这里只保留运行错误横幅与诊断留痕。 onRuntimeError(visibleMessage); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 54ed697f3..2d662afe9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -1,14 +1,10 @@ import type { ChatMessage } from '../../../../app/types'; import type { HomeCreationType } from '../../../home'; import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; +import type { DirectTurnRejection } from '../generated/DirectTurnRejection'; export const DIRECT_CODEX_AGENT_ID = 'direct-codex'; export const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; -const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = - 'direct-codex-turn-already-running:'; -/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ -const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = - '当前项目已有另一条 Direct 客户端回合正在运行'; /** * 一轮 DirectProject 回合的完整入参。 @@ -67,29 +63,57 @@ export function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } -export function isDirectCodexTurnAlreadyRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message - .trimStart() - .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); +/** + * 命令的**拒单**载荷(Rust 侧 `DirectTurnRejection`):结构化变体 + 宿主生成的文案。 + * + * `invoke` 拒绝时拿到的就是这份值(不是 `Error`)。这里只做一次形状读取,分流一律看 + * `error.type`——文案是给人看的,不参与任何判断。 + */ +export function readDirectTurnRejection( + error: unknown, +): DirectTurnRejection | null { + if (!error || typeof error !== 'object') return null; + const candidate = error as { error?: unknown; message?: unknown }; + const variant = candidate.error; + if (!variant || typeof variant !== 'object') return null; + const type = (variant as { type?: unknown }).type; + if (typeof type !== 'string' || !type) return null; + if (typeof candidate.message !== 'string') return null; + return candidate as DirectTurnRejection; } /** - * 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同 - * 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的 - * 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。 + * 认得的拒单(前置条件不满足 / 用户参数无效)→ 与用户消息同级的提示文案;认不得的返回 `null`, + * 由调用方原样抛出交给既有捕获链路(上报 + 横幅)。 + * + * 文案是宿主 `Display` 生成的**唯一一份**,界面原样显示:不套运行错误映射,也不在界面另写一份 + * ——那一套会把"聊天内容不能为空"这类前置条件压成"执行失败,请稍后重试"。 + * + * 名单只放"用户自己就能改、且不需要宿主诊断"的变体:`environmentNotReady` / + * `hostStateUnavailable` 这类是宿主 / 环境事实,必须走上报通道,所以不在这里。 */ -export function isDirectCodexAnotherTurnRunningError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER); +export function directTurnRejectionNotice( + rejection: DirectTurnRejection, +): string | null { + switch (rejection.error.type) { + case 'clientTurnIdMissing': + case 'clientTurnIdMalformed': + case 'turnAlreadyRunning': + case 'projectRootUnanchored': + case 'projectRootUnusable': + case 'permissionRejected': + case 'inputRejected': + case 'contentEmpty': + return rejection.message.trim(); + default: + return null; + } } /** - * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 - * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 - * "已终止本次回合"而不是把中断当作异常写进运行错误与诊断。 + * 拒绝提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, + * 两条通道永远不会合并成一条。 */ -export function isDirectCodexTurnInterruptedError(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return message.includes('turn 已中断') || message.includes('已终止本次回合'); +export function directTurnRejectionNoticeMessageId(userItemId: string) { + return `${userItemId}:rejected`; } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts new file mode 100644 index 000000000..e9b8f2355 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 + * + * 线上取值跟着拒单 / 失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 + */ +export type DirectCodexFailureStage = + | 'art-preparation' + | 'code-generation' + | 'browser-validation' + | 'version-registration'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts new file mode 100644 index 000000000..1347200b1 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Codex app-server 自报的原生失败分类(`turn.error.codexErrorInfo` 的归类结果)。 + * + * 取值由 app-server 侧投影决定(`codex_app_server::game_creator_codex_app_server_failed_turn_error`), + * 宿主只在这里还原,不再逐条对文本做子串匹配。未知取值落 [`Self::Other`]——新增原生分类必须先 + * 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 + * + * 线上取值只给界面选语气用,前端不得拿它做流程分支。 + */ +export type DirectCodexNativeKind = + | { type: 'context-window-exceeded' } + | { type: 'session-budget-exceeded' } + | { type: 'usage-limit-exceeded' } + | { type: 'request-too-large' } + | { type: 'stream-required' } + | { type: 'cyber-policy' } + | { type: 'sandbox-error' } + | { type: 'thread-rollback-failed' } + | { type: 'bad-request' } + | { type: 'unauthorized' } + | { type: 'active-turn-not-steerable' } + | { type: 'other'; kind: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts new file mode 100644 index 000000000..b76ba3800 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts @@ -0,0 +1,24 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexNativeKind } from './DirectCodexNativeKind'; + +/** + * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着拒单 / 失败载荷一起给前端。 + * + * 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 + * 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 + * `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 + */ +export type DirectModelCallKind = + | { type: 'responseTimedOut'; attempts: number } + | { type: 'connectionFailed'; attempts: number } + | { type: 'transportBroken' } + | { type: 'streamUnavailable' } + | { type: 'requestRejected'; native: DirectCodexNativeKind | null } + | { + type: 'upstreamFailed'; + statusCode: number; + native: DirectCodexNativeKind | null; + } + | { type: 'paidCreditsInsufficient' } + | { type: 'emptyResponse' } + | { type: 'payloadInvalid'; native: DirectCodexNativeKind | null }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts index 293e2a6dc..da0136295 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectThreadEvent.ts @@ -23,7 +23,8 @@ import type { DirectTurnFailure } from './DirectTurnFailure'; * 不能在前端收到或重放时重新取当前时间。 * * `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical - * itemId**(与同轮那条用户条目事件同源,由原生从已落盘条目上读取,不另造身份)。回合事件本身 + * itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; + * **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 * 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 * + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 * (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 @@ -32,7 +33,8 @@ export type DirectThreadEvent = | { type: 'turn.started'; /** - * 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。 + * 本轮开始的阶段时间(毫秒):**接单**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + * `turn/start` 的时刻)。 */ at?: number; /** @@ -52,7 +54,7 @@ export type DirectThreadEvent = */ failure?: DirectTurnFailure; /** - * 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + * 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 */ at?: number; /** diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts new file mode 100644 index 000000000..b0ad58f44 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 宿主等不到模型回执时,撞的是哪一条上限。 + * + * 跟着拒单 / 失败载荷一起给前端,界面不靠文案区分这两条。 + */ +export type DirectTurnDeadline = 'response-idle' | 'turn-hard-limit'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts new file mode 100644 index 000000000..c14aec237 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexFailureStage } from './DirectCodexFailureStage'; +import type { DirectModelCallKind } from './DirectModelCallKind'; +import type { DirectTurnDeadline } from './DirectTurnDeadline'; + +/** + * 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 + */ +export type DirectTurnError = + | { type: 'clientTurnIdMissing' } + | { type: 'clientTurnIdMalformed'; minChars: number; maxChars: number } + | { + type: 'turnAlreadyRunning'; + existingInvocationId: string; + incomingInvocationId: string; + } + | { type: 'projectRootUnanchored'; cause: string } + | { type: 'projectRootUnusable' } + | { type: 'permissionRejected'; policyDetail: string } + | { type: 'inputRejected'; detail: string } + | { type: 'contentEmpty' } + | { type: 'environmentNotReady'; detail: string } + | { type: 'hostStateUnavailable'; detail: string } + | { type: 'modelCallFailed'; kind: DirectModelCallKind; detail: string } + | { type: 'transportClosed'; diagnostic: string } + | { type: 'timedOut'; deadline: DirectTurnDeadline } + | { type: 'turnInterrupted'; detail: string } + | { type: 'reviewRequired'; detail: string } + | { type: 'turnFailed'; stage: DirectCodexFailureStage; detail: string } + | { type: 'turnFailedUnclassified'; detail: string }; 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 0ff2e80ae..4b7bde1c9 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 @@ -9,7 +9,7 @@ export type DirectTurnFailure = { /** * 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` / - * `host-dropped`。 + * `environment-not-ready` / `host-dropped`。 */ kind: string; /** diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts new file mode 100644 index 000000000..ae3ce8aa9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnRejection.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectTurnError } from './DirectTurnError'; + +/** + * 拒单载荷:命令边界交给前端的**结构化拒绝**。 + * + * 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 + * 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 + * `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 + */ +export type DirectTurnRejection = { + /** + * 结构化变体:界面按 `error.type` 分流,不解析文案。 + */ + error: DirectTurnError; + /** + * 可展示文案(`Display` 的唯一出口)。 + */ + message: string; +}; diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 840c8d2ff..de50995bb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -32,7 +32,6 @@ import { renderLauncherProjectsAt, screen, setComposerText, - testAuthUser, vi, waitFor, within, @@ -397,23 +396,15 @@ export function registerChatComposerControlTests() { expect(speechRecognitionErrorMessage('no-speech')).toContain('重试'); }); - it('settles only the final analytics attempt after a DirectProject authentication retry', async () => { + it('does not re-run the whole DirectProject turn when authentication fails', async () => { + // 登录态失效不再"刷新 + 重跑整轮"(重跑会重复落盘同一条用户消息):它按普通回合结果呈现, + // 整轮只 invoke 一次、埋点只结算一次。 const { invoke, surface } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: (() => { - let attempts = 0; - return () => { - if (++attempts === 1) throw new Error('authentication-required'); - return '完成'; - }; - })(), + chat_with_game_creator_direct_codex: () => { + throw new Error('authentication-required'); + }, }); - const refresh = vi - .spyOn(platformSession, 'requestPlatformSessionRefresh') - .mockResolvedValue({ - status: 'refreshed', - user: testAuthUser, - generation: platformSession.currentPlatformSessionGeneration(), - }); + const refresh = vi.spyOn(platformSession, 'requestPlatformSessionRefresh'); try { const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '继续制作'); @@ -424,23 +415,17 @@ export function registerChatComposerControlTests() { ), ).toHaveLength(1); }); - const attempts = invoke.mock.calls - .filter( - ([command]) => command === 'chat_with_game_creator_direct_codex', - ) - .map(([, args]) => args); - expect(attempts).toHaveLength(2); - expect(attempts[0]?.clientTurnId).toBe(attempts[1]?.clientTurnId); - expect(attempts[0]?.analyticsAttemptId).toEqual(expect.any(String)); - expect(attempts[1]?.analyticsAttemptId).toEqual(expect.any(String)); - expect(attempts[0]?.analyticsAttemptId).not.toBe( - attempts[1]?.analyticsAttemptId, + const attempts = invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', ); - expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { - attemptId: attempts[1]?.analyticsAttemptId, - discard: false, + expect(attempts).toHaveLength(1); + expect(refresh).not.toHaveBeenCalled(); + // 认不出的拒单 / 非结构化错误仍走既有捕获链路:横幅给用户一句可读的话。 + await waitFor(() => { + expect( + within(surface).getByText('陶泥儿智能创作 执行失败,请稍后重试'), + ).not.toBeNull(); }); - expect(refresh).toHaveBeenCalledTimes(1); } finally { refresh.mockRestore(); } @@ -722,18 +707,18 @@ export function registerChatComposerControlTests() { }); it('terminates the running turn and returns the composer to the idle state', async () => { - const pending: Array<{ - resolve: (value: string) => void; - reject: (error: Error) => void; - }> = []; const { invoke, path, surface, harness } = await openDirectCodexSurface({ - chat_with_game_creator_direct_codex: () => - new Promise((resolve, reject) => { - // 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。 - harness.emitDirectThreadEvents({ type: 'turn.started' }); - pending.push({ resolve, reject }); - }), - cancel_direct_codex_turn: async () => undefined, + // 命令只接单:接单成立(开始事件已由 Thread Manager 下发)后它立刻返回,"这一轮还在跑" + // 由订阅事件回答——所以忙碌态与「终止」入口都不再依赖命令 promise 还悬着。 + chat_with_game_creator_direct_codex: () => { + harness.emitDirectThreadEvents({ type: 'turn.started' }); + return Promise.resolve(null); + }, + cancel_direct_codex_turn: async () => ({ + outcome: 'interrupted', + message: '已向正在运行的回合发出终止', + clientTurnId: 'direct-turn-cancel', + }), }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '做一个小游戏'); @@ -754,9 +739,8 @@ export function registerChatComposerControlTests() { }); }); - // app-server 的中断原因回到前端:不是失败,UI 必须回到可用态。 + // 用户点「终止」的可读反馈走 composer 提示;这一轮怎么收场只由终态事件回答。 act(() => { - pending[0]?.reject(new Error('Codex app-server turn 已中断')); harness.emitDirectThreadEvents({ type: 'turn.completed', status: 'interrupted', @@ -766,7 +750,9 @@ export function registerChatComposerControlTests() { const send = within(surface).getByRole('button', { name: '发送' }); expect(send).toHaveProperty('disabled', false); }); - expect(within(surface).getByText('已终止本次回合。')).not.toBeNull(); + expect( + within(surface).getByText('已向正在运行的回合发出终止'), + ).not.toBeNull(); }); it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index bc3191d9a..6b1864bbb 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,7 +1,8 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; import { directCodexPolicyRetryInput, - isDirectCodexTurnAlreadyRunningError, + directTurnRejectionNotice, + readDirectTurnRejection, } from '../../src/view/project-development/chat/conversation/directCodexConversation'; import { createGameCreationAppManifest, @@ -20,24 +21,49 @@ import { } from './harness'; export function registerProjectConversationTests() { - it('filters only the stable same-turn in-progress rejection from terminal Direct Codex failures', () => { + it('splits structured rejections by variant and never by copy', () => { + // 拒单是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 + const concurrent = { + error: { + type: 'turnAlreadyRunning' as const, + existingInvocationId: 'turn-1', + incomingInvocationId: 'turn-1', + }, + message: '同一轮消息仍在处理中', + }; + expect(readDirectTurnRejection(concurrent)?.error.type).toBe( + 'turnAlreadyRunning', + ); + expect(directTurnRejectionNotice(concurrent)).toBe('同一轮消息仍在处理中'); + // 认得的参数 / 前置条件类都给同级提示。 expect( - isDirectCodexTurnAlreadyRunningError( - new Error( - 'direct-codex-turn-already-running: 当前 Direct 客户端回合仍在运行', - ), - ), - ).toBe(true); + directTurnRejectionNotice({ + error: { type: 'contentEmpty' }, + message: '聊天内容不能为空', + }), + ).toBe('聊天内容不能为空'); + // 宿主 / 环境事实不在这里认领:它们必须走上报通道(原样抛出)。 expect( - isDirectCodexTurnAlreadyRunningError( - 'codex-app-server-error:unauthorized', - ), - ).toBe(false); + directTurnRejectionNotice({ + error: { type: 'environmentNotReady', detail: '连不上 app-server' }, + message: '环境未就绪', + }), + ).toBeNull(); expect( - isDirectCodexTurnAlreadyRunningError( - 'direct-codex-turn-already-running 当前回合失败', - ), - ).toBe(false); + directTurnRejectionNotice({ + error: { type: 'hostStateUnavailable', detail: '账本损坏' }, + message: '宿主状态取不到', + }), + ).toBeNull(); + // 不是这份结构(旧字符串、Error、裸对象)一律不认。 + expect( + readDirectTurnRejection('codex-app-server-error:unauthorized'), + ).toBeNull(); + expect(readDirectTurnRejection(new Error('boom'))).toBeNull(); + expect(readDirectTurnRejection({ error: {}, message: 'x' })).toBeNull(); + expect( + readDirectTurnRejection({ error: { type: 'contentEmpty' } }), + ).toBeNull(); }); it('carries the whole direct turn input, including @ references, into the policy-confirmation retry', () => {