前端失败说明改由事件驱动:turn.completed.failure 落成本轮说明条目,命令返回只留横幅

- 新增 conversation/directTurnFailure.ts:失败说明条目的展示身份(本轮开口身份 + :failure)与可见文案(复用 projectRuntimeVisibleError)两条口径集中一处
- directThreadChat 的 turn.completed 分支读 failure 载荷:非空原因先落成本轮最后一条说明条目,再走同一个收口函数;失败不再是第二套生命周期
- useDirectProjectChatController 的失败分支不再写聊天气泡:聊天文案唯一来源是事件,命令返回只保留运行错误横幅(含详情 long detail)与诊断留痕
- 数据流、时序与投影注释同步:标注失败说明来自事件、本地通道只剩终止说明与壳层 announce
This commit is contained in:
2026-09-22 17:14:42 +08:00
parent 5cf4a018b3
commit 258645f182
4 changed files with 82 additions and 12 deletions
@@ -103,7 +103,7 @@ export type DirectProjectChatControllerProps = {
* 传输层(三条通道,前端各拉各的)
* A 运行态:notify → invoke consume_direct_project_thread → events[](实时)
* B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描)
* C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明)
* C 本地: 前端自己造(乐观用户气泡、忙态、终止说明)
* ▼
* 前端
* useDirectThreadChatSubscription reducerA + B 进同一份 stateturnRunning / history / live
@@ -120,6 +120,8 @@ export type DirectProjectChatControllerProps = {
* 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。
* - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合
* 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。
* 失败也走这条流:`turn.completed.failure` 自己带脱敏后的原因,reducer 把它落成本轮说明条目;
* 命令返回那条通道只提供横幅与诊断,不再写聊天文案。
* - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份
* `direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。
*
@@ -130,7 +132,8 @@ export type DirectProjectChatControllerProps = {
* 3. notify → consume → `turn.started`reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。
* 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。
* 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。
* 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上
* 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上
* 带 `failure` 载荷时,说明条目已经在上一步由 reducer 落进 `live`,随本轮一起并入历史。
* 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。
* **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队;
* 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。
@@ -577,14 +580,10 @@ export function useDirectProjectChatController({
true,
);
if (projectPathRef.current !== nextProjectPath) return;
// 聊天里的失败说明不再由这里写:宿主已经把脱敏后的原因放进了
// `turn.completed.failure`reducer 会把它落成本轮最后一条条目(唯一来源)。这里只保留
// 运行错误横幅(含 `详情:` 那份长 detail)与诊断留痕,两条通道不再各写一份文案。
onRuntimeError(visibleMessage);
appendLocalMessage({
role: 'assistant',
text: visibleMessage,
runtimeOwned: true,
messageId: `direct-codex:${input.clientTurnId}:failure`,
updatedAt: Date.now(),
});
}
}
@@ -5,6 +5,10 @@
* 运行态独有条目。这里不做可见性判断(那是投影的事):DirectProject 同一时刻只有一个回合在跑,
* `turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔;回合身份只用原生生命周期
* 事件自带的 canonical user identity`userItemId`)做展示边界关联,不新建回合注册表。
*
* 失败(`turn.completed.status === "failed"`)不是第二套生命周期:终态还是同一个事件,只是带了
* `failure` 载荷。这里把载荷落成本轮最后一条说明条目再走同一个收口函数——失败文案的唯一来源
* 就是事件流,命令返回只服务运行错误横幅与诊断。
*/
import type { GameCreatorDirectToolCall } from '../../../../app/types';
@@ -14,6 +18,10 @@ import type { DirectThreadHistorySlice } from '../generated/DirectThreadHistoryS
import type { DirectThreadItem } from '../generated/DirectThreadItem';
import type { DirectThreadSubscriptionBootstrap } from '../generated/DirectThreadSubscriptionBootstrap';
import { projectDirectThreadItem } from './directThreadItemProjection';
import {
directTurnFailureItemId,
directTurnFailureNoticeText,
} from './directTurnFailure';
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
@@ -326,7 +334,20 @@ export function reduceDirectThreadEvent(
if (!state.turnRunning && state.live.length === 0) {
return state;
}
return finishDirectThreadTurn(state, eventAt);
// 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口
// 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。
const failure = event.failure;
const withNotice =
failure && failure.message.trim()
? upsertLiveEntry(state, {
itemId: directTurnFailureItemId(eventUserItemId, eventAt),
kind: 'message',
role: 'assistant',
text: directTurnFailureNoticeText(failure.message),
at: eventAt,
})
: state;
return finishDirectThreadTurn(withNotice, eventAt);
}
case 'item.delta':
return appendLiveText(state, event);
@@ -369,7 +390,8 @@ export function reduceDirectThreadEvent(
/**
* 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。
*
* `endedAt` 只接受明确的终态时间`turn.completed.at`,或宿主终止收口时观测到的时刻):
* `endedAt` 只接受明确的终态时间`turn.completed.at`(正常与失败同源),或宿主终止收口时
* 观测到的时刻。
* 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。
* 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级
* 由投影层决定(条目上的 `at` 才是气泡时间)。
@@ -0,0 +1,46 @@
/**
* 失败终态的展示口径:`turn.completed.failure` 载荷怎么变成聊天里那条说明。
*
* 只放两条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事):
* 1. 说明条目的展示身份怎么派生;
* 2. 事件里的原始原因怎么变成用户可见文案。
*
* 为什么值得单独一个文件:这两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce)
* 区分开又保持可预期,文案映射要和运行错误横幅用同一份规则。把它们散在 reducer 里,读代码的人
* 只能靠猜"这条失败说明是从哪冒出来的"。
*/
import { projectRuntimeVisibleError } from '../../../../features/agent-runtime';
/** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */
const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure';
/**
* 失败说明条目的**展示身份**:本轮开口用户条目的 canonical identity + `:failure` 后缀。
*
* 它是派生身份,不是原生条目身份——宿主只在 `turn.completed.failure` 里给原因,不额外造条目。
* 用本轮身份派生可以保证"同一轮只有一条说明、跨轮不会合并",也不会和原生 itemId 撞车。
* 身份不可证明(本轮没有落盘用户条目)时退化成与事件时间绑定的固定形状:它随事件固定、
* 重放不变,又不会让两轮失败互相覆盖。
*/
export function directTurnFailureItemId(
userItemId: string | null | undefined,
at: number,
): string {
const identity = typeof userItemId === 'string' ? userItemId.trim() : '';
if (identity) return `${identity}:failure`;
return at > 0
? `${DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID}:${at}`
: DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID;
}
/**
* 失败原因的可见文案:与运行错误横幅共用同一份映射(`projectRuntimeVisibleError`)。
*
* 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案
* 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。
*/
export function directTurnFailureNoticeText(message: string): string {
const raw = typeof message === 'string' ? message.trim() : '';
return projectRuntimeVisibleError(raw, '陶泥儿智能创作', true);
}
@@ -239,7 +239,10 @@ function newTurn(key: string): DirectChatTurnEntries {
* 条目 + 运行期本地消息 → 回合列表。
*
* 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息
* 失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
* (终止说明、壳层 `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
*
* 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与
* 顺序都归 reducer。
*/
export function buildDirectChatTurns({
entries,