宿主与前端:接单被拒返回 typed 错误,界面按变体分流

宿主侧把拒单收成结构化载荷,界面不再解析任何文案前缀。
- `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` / 终态时刻的注释跟着改。
This commit is contained in:
2026-09-23 19:56:26 +08:00
parent ea4fbc66ff
commit d31a758c9c
16 changed files with 374 additions and 156 deletions
@@ -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),
);
@@ -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!(
@@ -45,7 +45,7 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
creation_type: Option<String>,
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
) -> 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。顺序固定,**每一步失败都还是拒单**:
@@ -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(),
@@ -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);
}
}
@@ -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`;
}
@@ -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';
@@ -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 };
@@ -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 };
@@ -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;
/**
@@ -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';
@@ -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 };
@@ -9,7 +9,7 @@
export type DirectTurnFailure = {
/**
* 稳定失败分类:`timeout` / `model-failed` / `transport-failed` / `request-rejected` /
* `host-dropped`。
* `environment-not-ready` / `host-dropped`。
*/
kind: string;
/**
@@ -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;
};
@@ -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<string>((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 () => {
@@ -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', () => {