前端:失败说明按回合身份归位,不再落进上一轮、气泡也不再自成假回合

- 回合归属改成按身份(开口用户条目的 canonical itemId):失败说明条目带 turnUserItemId(reducer 写,缺身份时保持原顺序语义),buildDirectChatTurns 按身份分组,同一身份的条目永远同一轮
- 本轮开口条目还没到(回合在宿主下发条目之前就失败、或历史切片还没读回)时,本地乐观气泡按身份挂回自己那一轮,不再另开一轮:界面不再出现「错误显示在用户消息上面」+「气泡底下 0.0 秒」+「上一轮借走本轮终点(15.6 秒)」这一组现象
- 收口早退不再吞掉还没写进界面的失败说明(订阅重建后的 bootstrap 只回放生命周期锚点):只补说明、终点时间与「回合完成」计数,不重开回合、不动本轮起点 / 终点 / 身份
- 用例:directTurnPresentation 复现现场(两个回合、说明与气泡同段、耗时不再借上一轮的终点);directThreadChat 补身份字段与早退不吞说明两条
This commit is contained in:
2026-09-24 18:02:43 +08:00
parent 2487a2c8e6
commit 4a75de1c31
4 changed files with 231 additions and 20 deletions
@@ -50,6 +50,18 @@ export type DirectChatEntry = {
* 缺失时不写,不能拿最后一条工具 / 正文的时间顶替。
*/
turnEndedAt?: number;
/**
* 这条条目**属于哪一轮**(本轮开口用户条目的 canonical 身份)。
*
* 只有失败说明带它:它不是原生条目,而是宿主终态载荷派生出来的说明(见
* `directTurnFailureItemId`)。一旦本轮的开口用户条目晚到或压根没到(回合在宿主下发用户条目
* 之前就失败、历史切片还没读回),说明条目在数组里的位置就会落在**上一轮**里,界面会渲染成
* "错误显示在用户消息上面",上一轮还会把它那一轮的耗时显示成本轮的。
*
* 有了身份,投影层就能按身份归位(`buildDirectChatTurns` 的回合判据),不再靠位置猜。
* 缺失表示归属不可证明(旧事件 / 旧历史切片),那时保持原有的顺序语义。
*/
turnUserItemId?: string;
};
export type DirectThreadChatState = {
@@ -223,6 +235,7 @@ export function mergeDirectChatEntry(
// 展示元数据先到先用:后到的重放 / 历史切片不得覆盖已经确定的边界。
turnStartedAt: existing.turnStartedAt || incoming.turnStartedAt,
turnEndedAt: existing.turnEndedAt || incoming.turnEndedAt,
turnUserItemId: existing.turnUserItemId || incoming.turnUserItemId,
};
}
@@ -337,10 +350,6 @@ export function reduceDirectThreadEvent(
) {
return state;
}
// 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
if (!state.turnRunning && state.live.length === 0) {
return state;
}
// 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口
// 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。
const failure = event.failure;
@@ -351,14 +360,40 @@ export function reduceDirectThreadEvent(
failure && typeof failure.message === 'string'
? failure.message.trim()
: '';
const noticeItemId = directTurnFailureItemId(eventUserItemId, eventAt);
const noticeOf = (): DirectChatEntry => ({
itemId: noticeItemId,
kind: 'message',
role: 'assistant',
text: directTurnFailureNoticeText(failureText),
at: eventAt,
// 归属带上身份:本轮的开口用户条目可能还没到过界面(回合在宿主下发用户条目之前就失败、
// 或历史切片还没读回),那时只有身份能把这条说明归回自己那一轮,而不是按位置留给上一轮。
...(eventUserItemId ? { turnUserItemId: eventUserItemId } : {}),
});
// 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
//
// 唯一例外:这条终态带着**还没写进界面的失败说明**。订阅重建后的 bootstrap 只回放一条
// 生命周期锚点(`direct_thread_manager.rs` 的 `lifecycle_anchor`),那一条可能正是某个已经
// 收口的回合的失败——照原样早退就会把这一轮唯一的解释静默吞掉。这里只补说明与它的终点时间
// 和"回合完成"这个计数(忙态放行靠它),不重开回合、不动本轮的起点 / 终点 / 身份。
if (!state.turnRunning && state.live.length === 0) {
const alreadyRecorded = state.history.some(
(entry) => entry.itemId === noticeItemId,
);
if (!failureText || alreadyRecorded) {
return state;
}
return {
...state,
completedTurnCount: state.completedTurnCount + 1,
history: mergeHistoryEntries(state.history, [
withTurnBoundary(noticeOf(), { turnEndedAt: eventAt }),
]),
};
}
const withNotice = failureText
? upsertLiveEntry(state, {
itemId: directTurnFailureItemId(eventUserItemId, eventAt),
kind: 'message',
role: 'assistant',
text: directTurnFailureNoticeText(failureText),
at: eventAt,
})
? upsertLiveEntry(state, noticeOf())
: state;
return finishDirectThreadTurn(withNotice, eventAt);
}
@@ -238,14 +238,31 @@ function newTurn(key: string): DirectChatTurnEntries {
};
}
/**
* 条目自带的**回合身份**:用户条目就是自己的身份;失败说明带的是它所属回合的开口条目身份。
*
* 只有这两种条目能开 / 认领一个回合:其余条目(工具、思考、正文)没有身份,只能跟着当前回合走。
* 返回空串 = 归属不可证明,按原有顺序语义处理。
*/
function directEntryTurnKey(entry: DirectChatEntry): string {
if (entry.kind === 'message' && entry.role === 'user') return entry.itemId;
return entry.turnUserItemId ?? '';
}
/**
* 条目 + 运行期本地消息 → 回合列表。
*
* 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息
* (终止说明、壳层 `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
* **回合身份是开口用户条目的 canonical `itemId`**(`direct-codex:{clientTurnId}:user`),不是
* 数组位置:用户条目、本地乐观气泡、以及这一轮的失败说明都按同一个身份归进同一轮。三种来源谁先到
* 都行——回合在宿主下发用户条目之前就失败时,只有失败说明会到,那时也必须靠身份归位,否则说明会
* 按位置落进上一轮(界面表现:错误显示在用户消息上面、上一轮顶替本轮显示耗时),用户气泡再自成
* 一轮(多出一个 0.0 秒的假回合)。
*
* 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与
* 顺序都归 reducer。
* 其余条目(工具、思考、正文)不带身份,跟着当前回合走;本地 assistant 消息(终止说明、壳层
* `announce`)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。
*
* 失败说明不在这条本地通道里:它是宿主 `turn.completed.failure` 载荷落成的普通条目,来源与顺序
* 都归 reducer(身份字段 `turnUserItemId` 也由 reducer 写)。
*/
export function buildDirectChatTurns({
entries,
@@ -272,19 +289,36 @@ export function buildDirectChatTurns({
pendingUserItemId?: string;
}): DirectChatTurn[] {
const turns: DirectChatTurnEntries[] = [];
const turnsByIdentity = new Map<string, DirectChatTurnEntries>();
let current: DirectChatTurnEntries | null = null;
const localSentAt = localSentTimes(localMessages);
// 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着,
// 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。
const leadingEntries: DirectChatEntry[] = [];
const openTurn = (key: string) => {
const turn = newTurn(key);
turns.push(turn);
turnsByIdentity.set(key, turn);
return turn;
};
for (const entry of entries) {
if (entry.kind === 'message' && entry.role === 'user') {
current = newTurn(entry.itemId);
turns.push(current);
const identity = directEntryTurnKey(entry);
if (identity) {
// 同一身份的条目永远属于同一轮。用户条目与这一轮的失败说明可能分头到达(订阅重放、历史
// 切片晚到、或本轮压根没有下发用户条目),靠位置分组会把说明留给上一轮 —— 界面就成了
// "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。
const existing = turnsByIdentity.get(identity);
if (existing) {
existing.entries.push(entry);
continue;
}
current = openTurn(identity);
if (leadingEntries.length > 0) {
current.entries.push(...leadingEntries);
leadingEntries.length = 0;
}
current.entries.push(entry);
continue;
}
if (!current) {
leadingEntries.push(entry);
@@ -304,8 +338,18 @@ export function buildDirectChatTurns({
if (message.messageId && entryIds.has(message.messageId)) return;
if (message.role === 'user') {
const block = blockFromLocalMessage(message, index);
current = newTurn(message.messageId ?? `local:${index}`);
turns.push(current);
// 身份已经开过回合(本轮的开口用户条目还没到,但它的失败说明到了):挂回自己那一轮。
// 另开一轮就会多出一个"用户气泡 + 0.0 秒终态"的假回合,而这一轮的说明还留在上面那一段。
const claimed = message.messageId
? turnsByIdentity.get(message.messageId)
: undefined;
if (claimed) {
if (block && claimed.localUsers.length === 0) {
claimed.localUsers.push(block);
}
return;
}
current = openTurn(message.messageId ?? `local:${index}`);
if (block) current.localUsers.push(block);
return;
}
@@ -442,6 +442,90 @@ describe('DirectProject 聊天 reducer', () => {
}
});
it('失败说明带上它所属回合的身份,开口用户条目没到时投影层也能归位', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000 }),
'direct-codex:turn-1:user',
),
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'transport-failed', message: '连接失败' },
at: 2_000,
}),
'direct-codex:turn-1:user',
),
]);
// 身份是投影层"这条说明属于哪一轮"的唯一判据:本轮的开口用户条目可能还没到过界面
// (回合在宿主下发用户条目之前就失败、或历史切片还没读回),那时只有它能把说明挂回自己的回合。
expect(failed.history.at(-1)?.itemId).toBe(
'direct-codex:turn-1:user:failure',
);
expect(failed.history.at(-1)?.turnUserItemId).toBe(
'direct-codex:turn-1:user',
);
// 没有身份(旧事件)时不写这个字段,保持原有的顺序语义。
const anonymous = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000 }),
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '第一轮失败' },
at: 2_000,
}),
]);
expect(anonymous.history.at(-1)?.turnUserItemId).toBeUndefined();
});
it('收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)', () => {
const finished = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000 }),
'direct-codex:turn-1:user',
),
withUserItemId(
event({ type: 'turn.completed', status: 'completed', at: 2_000 }),
'direct-codex:turn-1:user',
),
]);
expect(finished.history).toHaveLength(0);
// 订阅重建后的 bootstrap 只回放最新一条生命周期事件:它就是某个已收口回合的失败,界面上
// 没有任何东西能解释这一轮,必须补上这条说明(而不是按"重复终态"早退)。
const replayed = reduceDirectThreadEvents(finished, [
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'transport-failed', message: '连接失败' },
at: 3_000,
}),
'direct-codex:turn-1:user',
),
]);
expect(replayed.history.map((entry) => entry.itemId)).toEqual([
'direct-codex:turn-1:user:failure',
]);
expect(replayed.history[0]?.turnEndedAt).toBe(3_000);
expect(replayed.completedTurnCount).toBe(finished.completedTurnCount + 1);
// 重复回放同一条锚点:说明已经写进去了,计数不再涨,也不追加第二条。
const replayAgain = reduceDirectThreadEvents(replayed, [
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'transport-failed', message: '连接失败' },
at: 3_000,
}),
'direct-codex:turn-1:user',
),
]);
expect(replayAgain.history).toHaveLength(1);
expect(replayAgain.completedTurnCount).toBe(replayed.completedTurnCount);
});
it('没有身份时用事件时间派生说明身份,两轮失败不会合并成一条', () => {
const first = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000 }),
@@ -285,6 +285,54 @@ describe('DirectProject 聊天分区', () => {
});
});
it('本轮开口条目没到时,失败说明按身份挂回自己那一轮,气泡不再自成假回合', () => {
// 现场:回合在宿主下发开口用户条目之前就失败,于是界面上既没有正式用户条目、也没有历史
// 切片,只有这一轮的失败说明和本地乐观气泡(这条消息的开口条目还没到)。
const turns = buildDirectChatTurns({
entries: [
userEntry('direct-codex:turn-1:user'),
{
...assistantEntry(
'direct-codex:turn-1:user:failure',
'陶泥儿智能创作 连接失败,请重试',
),
turnUserItemId: 'direct-codex:turn-1:user',
turnStartedAt: 1_800_000_010_000,
turnEndedAt: 1_800_000_010_400,
},
{
...assistantEntry(
'direct-codex:turn-2:user:failure',
'陶泥儿智能创作 连接失败,请重试',
),
turnUserItemId: 'direct-codex:turn-2:user',
turnStartedAt: 1_800_000_020_000,
turnEndedAt: 1_800_000_035_600,
},
],
localMessages: [localUser('hello', 'direct-codex:turn-2:user')],
turnRunning: false,
});
// 两个回合:第二个回合的用户气泡与它自己的失败说明同段 —— 说明不再落进上一轮(否则界面
// 表现就是"错误显示在用户消息上面"),气泡也不再自成"0.0 秒"的假回合。
expect(turns.map((turn) => turn.key)).toEqual([
'direct-codex:turn-1:user',
'direct-codex:turn-2:user',
]);
expect(turns[0]?.finals.map((block) => block.key)).toEqual([
'direct-codex:turn-1:user:direct-codex:turn-1:user:failure',
]);
expect(turns[1]?.users.map((block) => block.text)).toEqual(['hello']);
expect(turns[1]?.finals.map((block) => block.key)).toEqual([
'direct-codex:turn-2:user:direct-codex:turn-2:user:failure',
]);
// 耗时按用户气泡自己的发送时间起算,不再把上一轮的终点借过来。
expect(turns[0]?.endedAt).toBe(1_800_000_010_400);
expect(turns[1]?.startedAt).toBe(1_800_000_002_000);
expect(turns[1]?.endedAt).toBe(1_800_000_035_600);
});
it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => {
const turns = buildDirectChatTurns({
entries: [userEntry('u1'), assistantEntry('a1', '答复')],