Files
Genarrative/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
T
k88936 4a75de1c31 前端:失败说明按回合身份归位,不再落进上一轮、气泡也不再自成假回合
- 回合归属改成按身份(开口用户条目的 canonical itemId):失败说明条目带 turnUserItemId(reducer 写,缺身份时保持原顺序语义),buildDirectChatTurns 按身份分组,同一身份的条目永远同一轮
- 本轮开口条目还没到(回合在宿主下发条目之前就失败、或历史切片还没读回)时,本地乐观气泡按身份挂回自己那一轮,不再另开一轮:界面不再出现「错误显示在用户消息上面」+「气泡底下 0.0 秒」+「上一轮借走本轮终点(15.6 秒)」这一组现象
- 收口早退不再吞掉还没写进界面的失败说明(订阅重建后的 bootstrap 只回放生命周期锚点):只补说明、终点时间与「回合完成」计数,不重开回合、不动本轮起点 / 终点 / 身份
- 用例:directTurnPresentation 复现现场(两个回合、说明与气泡同段、耗时不再借上一轮的终点);directThreadChat 补身份字段与早退不吞说明两条
2026-09-24 18:02:43 +08:00

1028 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest';
import type {
DirectChatEntry,
DirectThreadChatState,
} from '../src/features/project-workspace/directThreadChat';
import {
emptyDirectThreadChatState,
finishDirectThreadTurn,
mergeDirectHistoryItems,
reduceDirectThreadEvents,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
} from '../src/view/project-development/chat/conversation/directThreadChat';
import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection';
import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent';
function event(
partial: Pick<DirectThreadEvent, 'type'> & Partial<DirectThreadEvent>,
): DirectThreadEvent {
return partial as DirectThreadEvent;
}
/** 生命周期事件上的 canonical user identity(原生 `userItemId`)。 */
function withUserItemId(
lifecycle: DirectThreadEvent,
userItemId: string,
): DirectThreadEvent {
return { ...lifecycle, userItemId };
}
/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */
function toolStarted(
overrides: Partial<
Extract<DirectThreadItem, { itemType: 'function_call' }>
> = {},
): DirectThreadItem {
return {
itemType: 'function_call',
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
name: 'exec_command',
arguments: '{"cmd": "ls"}',
at: 1000,
...overrides,
};
}
/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */
function toolOutput(): DirectThreadItem {
return {
itemType: 'function_call_output',
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
output: 'assets\ngame',
at: 2000,
};
}
function messageItem(
overrides: Partial<Extract<DirectThreadItem, { itemType: 'message' }>> = {},
): DirectThreadItem {
return {
itemType: 'message',
itemId: 'msg-1',
role: 'assistant',
text: '你好',
at: 3000,
...overrides,
};
}
describe('DirectProject 聊天 reducer', () => {
it('生命周期事件只切换"是否还在跑",不带回合身份', () => {
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started' }),
]);
expect(started.turnRunning).toBe(true);
const completed = reduceDirectThreadEvents(started, [
event({ type: 'turn.completed', status: 'completed' }),
]);
expect(completed.turnRunning).toBe(false);
});
it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => {
const bootstrapped = resolveDirectThreadBootstrap(
emptyDirectThreadChatState(),
{
subscriptionId: 'sub-1',
lastCompletedItemId: 'msg-9',
events: [
event({ type: 'turn.started' }),
event({ type: 'item.started', item: toolStarted() }),
],
},
);
// 订阅身份与首屏锚点由订阅循环自己持有,不写进聊天 reducer 状态。
expect(bootstrapped).not.toHaveProperty('subscriptionId');
expect(bootstrapped).not.toHaveProperty('lastCompletedItemId');
expect(bootstrapped.turnRunning).toBe(true);
expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1);
});
it('增量正文按条目累计,完成快照更长时覆盖同一段', () => {
const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.delta',
itemId: 'msg-1',
kind: 'message',
delta: '你',
}),
event({
type: 'item.delta',
itemId: 'msg-1',
kind: 'message',
delta: '好',
}),
]);
expect(selectDirectChatEntries(streamed)).toHaveLength(1);
expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好');
const done = reduceDirectThreadEvents(streamed, [
event({
type: 'item.completed',
item: messageItem({ text: '你好,我是陶泥儿。' }),
}),
]);
const entries = selectDirectChatEntries(done);
expect(entries).toHaveLength(1);
expect(entries[0]?.text).toBe('你好,我是陶泥儿。');
});
it('思考增量按 reasoning 条目累计,不混进助手文本', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.delta',
itemId: 'reason-1',
kind: 'reasoning',
delta: '先看目录',
}),
]);
const entries = selectDirectChatEntries(state);
expect(entries).toHaveLength(1);
expect(entries[0]?.kind).toBe('reasoning');
expect(entries[0]?.role).toBeNull();
expect(entries[0]?.text).toBe('先看目录');
});
it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'item.started', item: toolStarted() }),
event({ type: 'item.completed', item: toolOutput() }),
]);
const entries = selectDirectChatEntries(state);
expect(entries).toHaveLength(1);
expect(entries[0]?.itemId).toBe('call_00_Gpd0s0Ytm9YgIbwbEXva1473');
expect(entries[0]?.toolCall?.kind).toBe('command');
expect(entries[0]?.toolCall?.title).toBe('执行命令');
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame');
expect(entries[0]?.toolCall?.status).toBe('completed');
});
it('回合结束把运行态并入历史并清空,条目不会消失也不会重复', () => {
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started' }),
event({ type: 'item.completed', item: messageItem() }),
event({ type: 'item.started', item: toolStarted() }),
]);
expect(running.live).toHaveLength(2);
const done = reduceDirectThreadEvents(running, [
event({ type: 'turn.completed', status: 'completed' }),
]);
expect(done.live).toHaveLength(0);
expect(done.history).toHaveLength(2);
expect(selectDirectChatEntries(done)).toHaveLength(2);
});
it('历史切片搬运层不合并,合并发生在前端投影', () => {
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
toolStarted(),
toolOutput(),
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }),
]);
const entries = selectDirectChatEntries(state);
expect(entries).toHaveLength(2);
expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame');
expect(entries[1]?.role).toBe('user');
});
it('系统条目与未识别的 item 类型不进聊天视图', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.completed',
item: messageItem({
itemId: 'sys-1',
role: 'system',
text: '内部指令',
}),
}),
event({
type: 'item.completed',
item: {
itemType: 'other',
itemId: 'plan-1',
rawType: 'plan',
at: 0,
} satisfies DirectThreadItem,
}),
]);
expect(selectDirectChatEntries(state)).toHaveLength(0);
});
it('退出码非零优先于 completed:命令跑完但失败不标成功', () => {
const command = (
status: string | null,
exitCode: number | null,
itemId: string,
) => ({
itemType: 'commandExecution' as const,
itemId,
command: 'npm test',
output: 'boom',
status,
exitCode,
at: 2_000,
});
// status=completed + exitCode=1:失败(不能因为"跑完了"就标成功)。
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.completed',
at: 2_000,
item: command('completed', 1, 'call-fail'),
}),
]);
expect(selectDirectChatEntries(failed)[0]?.toolCall?.status).toBe('failed');
// status=completed + exitCode=0:成功。
const ok = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.completed',
at: 2_000,
item: command('completed', 0, 'call-ok'),
}),
]);
expect(selectDirectChatEntries(ok)[0]?.toolCall?.status).toBe('completed');
// status=inProgress 且没有退出码:仍在跑。
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.started',
at: 2_000,
item: command('inProgress', null, 'call-running'),
}),
]);
expect(selectDirectChatEntries(running)[0]?.toolCall?.status).toBe(
'running',
);
// 上游显式失败终态优先于 completed:即使 exitCode=0 也算失败。
const declined = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.completed',
at: 2_000,
item: command('declined', 0, 'call-declined'),
}),
]);
expect(selectDirectChatEntries(declined)[0]?.toolCall?.status).toBe(
'failed',
);
});
it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => {
const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
toolStarted(),
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }),
]);
const live = reduceDirectThreadEvents(historical, [
event({ type: 'item.completed', item: toolOutput() }),
]);
const entries = selectDirectChatEntries(live);
expect(entries).toHaveLength(2);
expect(entries[0]?.toolCall?.status).toBe('completed');
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
});
describe('失败终态(turn.completed 带 failure 载荷)', () => {
it('失败也是终态:收口本轮、冻结终点,并把原因落成本轮最后一条说明', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: {
kind: 'transport-failed',
message: 'DirectProject 收尾历史失败:未确认历史完整落盘',
},
at: 1_000_900,
}),
'direct-codex:turn-1:user',
),
]);
expect(failed.turnRunning).toBe(false);
expect(failed.turnEndedAt).toBe(1_000_900);
expect(failed.live).toHaveLength(0);
const notice = failed.history.at(-1);
expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure');
expect(notice?.role).toBe('assistant');
expect(notice?.text).toBe(
'陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试',
);
// 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。
expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900);
expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000);
});
it('失败说明的可见文案与运行错误横幅共用同一份映射', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000 }),
'direct-codex:turn-1:user',
),
event({
type: 'turn.completed',
status: 'failed',
failure: {
kind: 'request-rejected',
message: 'codex-app-server-error:context-window-exceeded',
},
at: 2_000,
}),
]);
expect(failed.history.at(-1)?.text).toBe(
'陶泥儿智能创作 模型上下文已超限,请缩小任务范围后重试',
);
});
it('重复 / 迟到的失败终态不追加第二条说明,也不抬高冻结终点或复活运行态', () => {
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '模型服务暂不可用' },
at: 1_000_900,
}),
'direct-codex:turn-1:user',
),
]);
const replayed = reduceDirectThreadEvents(failed, [
withUserItemId(
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '模型服务暂不可用' },
at: 9_900_000,
}),
'direct-codex:turn-1:user',
),
]);
expect(replayed.turnRunning).toBe(false);
expect(replayed.turnEndedAt).toBe(1_000_900);
expect(
replayed.history.filter((entry) => entry.itemId.endsWith(':failure')),
).toHaveLength(1);
});
it('身份不匹配的失败终态不动正在跑的这一轮', () => {
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 2_000_000 }),
'direct-codex:turn-2:user',
),
]);
const untouched = reduceDirectThreadEvents(running, [
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '上一轮的失败' },
at: 2_000_900,
userItemId: 'direct-codex:turn-1:user',
}),
]);
expect(untouched.turnRunning).toBe(true);
expect(untouched.turnEndedAt).toBe(0);
expect(untouched.history).toHaveLength(0);
expect(untouched.live).toHaveLength(0);
});
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: 'host-dropped', message: ' ' },
at: 2_000,
}),
'direct-codex:turn-1:user',
),
]);
expect(failed.turnRunning).toBe(false);
expect(failed.turnEndedAt).toBe(2_000);
expect(failed.history).toHaveLength(0);
});
it('载荷的 message 缺失或为 null 时不抛错,按"没有原因"收口', () => {
// 跨 IPC 的载荷没有运行时校验:字段缺失 / `null` 都到得了 reducer。这里只要求
// "不抛错 + 不补空气泡",终态照样收口——抛错会连带打断这条订阅之后的所有事件。
for (const message of [undefined, null]) {
const malformed = {
type: 'turn.completed',
status: 'failed',
at: 2_000,
failure: { kind: 'host-dropped', message },
} as unknown as DirectThreadEvent;
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000 }),
'direct-codex:turn-1:user',
),
malformed,
]);
expect(failed.turnRunning).toBe(false);
expect(failed.turnEndedAt).toBe(2_000);
expect(failed.history).toHaveLength(0);
}
});
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 }),
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '第一轮失败' },
at: 2_000,
}),
]);
const second = reduceDirectThreadEvents(first, [
event({ type: 'turn.started', at: 3_000 }),
event({
type: 'turn.completed',
status: 'failed',
failure: { kind: 'model-failed', message: '第二轮失败' },
at: 4_000,
}),
]);
expect(second.history.map((entry) => entry.itemId)).toEqual([
'direct-thread-turn-failure:2000',
'direct-thread-turn-failure:4000',
]);
});
});
describe('事件级计时边界', () => {
/** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */
it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({
type: 'item.started',
at: 1_000_100,
item: toolStarted({ at: 999_999 }),
}),
event({
type: 'item.completed',
at: 1_000_700,
item: toolOutput({ at: 1_200_000 }),
}),
]);
const call = selectDirectChatEntries(state)[0]?.toolCall;
expect(call?.startedAt).toBe(1_000_100);
expect(call?.updatedAt).toBe(1_000_700);
});
it('只有完成事件、没有开始事件的工具不猜开始时间', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }),
]);
const call = selectDirectChatEntries(state)[0]?.toolCall;
expect(call?.startedAt).toBe(0);
expect(call?.updatedAt).toBe(1_000_700);
});
it('内部工具的阶段状态:开始事件就是运行中,正式完成事件才结束', () => {
const fileChange = {
itemType: 'fileChange',
itemId: 'file-1',
changes: [{ path: 'game/src/hero.ts', kind: 'update' }],
at: 0,
} as const;
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'item.started', at: 1_000_100, item: fileChange }),
]);
const startedCall = selectDirectChatEntries(started)[0]?.toolCall;
expect(startedCall?.status).toBe('running');
expect(startedCall?.startedAt).toBe(1_000_100);
expect(startedCall?.updatedAt).toBe(0);
const finished = reduceDirectThreadEvents(started, [
event({ type: 'item.completed', at: 1_000_900, item: fileChange }),
]);
const finishedCall = selectDirectChatEntries(finished)[0]?.toolCall;
expect(finishedCall?.status).toBe('completed');
expect(finishedCall?.updatedAt).toBe(1_000_900);
});
it('function_call 完成快照不等于工具结束:一直运行到 output 到达', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
// 同一个 function_call 又以完成快照重放一次:不是工具结束。
event({ type: 'item.completed', at: 1_000_200, item: toolStarted() }),
]);
const running = selectDirectChatEntries(state)[0]?.toolCall;
expect(running?.status).toBe('running');
// 完成快照不改开始边界,也不产生终点。
expect(running?.startedAt).toBe(1_000_100);
expect(running?.updatedAt).toBe(0);
const done = reduceDirectThreadEvents(state, [
event({ type: 'item.completed', at: 1_000_900, item: toolOutput() }),
]);
const finished = selectDirectChatEntries(done)[0]?.toolCall;
expect(finished?.status).toBe('completed');
expect(finished?.updatedAt).toBe(1_000_900);
});
it('收口后重复 / 迟到的终态事件不抬高冻结终点、不复活运行态', () => {
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
event({
type: 'item.completed',
at: 1_000_700,
item: toolOutput(),
}),
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
]);
expect(done.turnRunning).toBe(false);
expect(done.turnEndedAt).toBe(1_000_900);
expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
const replayed = reduceDirectThreadEvents(done, [
// 重复的完成事件 + 迟到的事件不得抬高已经冻结的终点。
event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }),
event({ type: 'item.completed', at: 1_009_900, item: toolOutput() }),
]);
expect(replayed.turnRunning).toBe(false);
expect(replayed.turnEndedAt).toBe(1_000_900);
expect(replayed.history[0]?.toolCall?.updatedAt).toBe(1_000_700);
});
it('回合身份只认事件顺序:同一秒内开始的下一轮也照常接上', () => {
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
// 宿主收口的毫秒时间:原生回合时间是秒级精度,"上一轮结束之后又来一条开始事件"
// 就是新回合,不能因为时间不比终点晚就把它当重放吞掉。
event({ type: 'turn.completed', status: 'cancelled', at: 1_000_900 }),
]);
const next = reduceDirectThreadEvents(done, [
event({ type: 'turn.started', at: 1_000_000 }),
]);
expect(next.turnRunning).toBe(true);
expect(next.turnStartedAt).toBe(1_000_000);
expect(next.turnEndedAt).toBe(0);
});
it('不再为同一轮内的重复开始事件做兼容:起点就是最后一条开始事件', () => {
// 宿主在接单时**只发一次** `turn.started`(Thread Manager 的接单动作),线上不存在"同一轮
// 里又来一条开始事件"。所以这里不再保留第一次的起点:真出现重复那是事件源的问题,reducer
// 按事件顺序照实收下,不替它编一个更早的起点。
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'turn.started', at: 1_000_500 }),
]);
expect(started.turnRunning).toBe(true);
expect(started.turnStartedAt).toBe(1_000_500);
});
it('收口计数是状态:同一批里开始又结束也数得到,重复终态不重复计数', () => {
// 队列放行与埋点结算读这个计数,而不是 `turnRunning` 的下降沿——一轮可能在同一次
// consume 里开始并结束(接单后立刻失败),那时下降沿永远不会出现。
const batched = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }),
]);
expect(batched.completedTurnCount).toBe(1);
const replayed = reduceDirectThreadEvents(batched, [
event({ type: 'turn.completed', status: 'failed', at: 1_000_400 }),
]);
expect(replayed.completedTurnCount).toBe(1);
});
it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => {
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
]);
// 迟到的事件:同身份的完成快照在回合收口之后才到。
const late = reduceDirectThreadEvents(done, [
event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }),
]);
expect(late.live).toHaveLength(0);
expect(late.history).toHaveLength(1);
expect(late.history[0]?.toolCall?.status).toBe('completed');
expect(late.history[0]?.toolCall?.detail.output).toBe('assets\ngame');
// 冻结的终点不被抬高。
expect(late.history[0]?.toolCall?.updatedAt).toBe(1_000_700);
// 新回合开始后,迟到条目仍然回历史,不会挤进这一轮。
const next = reduceDirectThreadEvents(late, [
event({ type: 'turn.started', at: 1_001_000 }),
event({ type: 'item.completed', at: 1_001_700, item: toolOutput() }),
]);
expect(next.live).toHaveLength(0);
expect(next.history).toHaveLength(1);
});
it('bootstrap 已把用户消息当历史锚点给出时,边界仍落在本轮开口条目上', () => {
const bootstrapped = resolveDirectThreadBootstrap(
mergeDirectHistoryItems(emptyDirectThreadChatState(), [
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }),
]),
{
subscriptionId: 'sub-1',
lastCompletedItemId: 'msg-user',
events: [
// 原生在生命周期锚点上带出本轮 canonical user identity,开口条目已经在历史里。
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'msg-user',
),
event({
type: 'item.completed',
at: 1_000_700,
item: toolOutput(),
}),
event({
type: 'turn.completed',
status: 'completed',
at: 1_000_900,
}),
],
},
);
expect(selectDirectChatEntries(bootstrapped)[0]?.turnEndedAt).toBe(
1_000_900,
);
expect(selectDirectChatEntries(bootstrapped)[0]?.turnStartedAt).toBe(
1_000_000,
);
});
it('没有 userItemId 的生命周期事件保持顺序语义,但不猜历史归属', () => {
const bootstrapped = resolveDirectThreadBootstrap(
mergeDirectHistoryItems(emptyDirectThreadChatState(), [
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }),
]),
{
subscriptionId: 'sub-1',
lastCompletedItemId: 'msg-user',
events: [
// 旧原生事件不带身份:运行态条目照常收口,历史里那条不按尾巴猜。
event({
type: 'item.completed',
at: 1_000_700,
item: toolOutput(),
}),
event({
type: 'turn.completed',
status: 'completed',
at: 1_000_900,
}),
],
},
);
const entries = selectDirectChatEntries(bootstrapped);
expect(entries).toHaveLength(2);
expect(entries[0]?.itemId).toBe('msg-user');
expect(entries[0]?.turnEndedAt).toBeUndefined();
// 运行态那条照常拿到边界(顺序语义不变)。
expect(entries[1]?.turnEndedAt).toBe(1_000_900);
});
it('本轮开口条目已在历史、运行态为空时,收口仍按身份把边界盖在它身上', () => {
const userItem = messageItem({
itemId: 'direct-codex:turn-1:user',
role: 'user',
text: '做一个拼图游戏',
});
const running = reduceDirectThreadEvents(
mergeDirectHistoryItems(emptyDirectThreadChatState(), [userItem]),
[
// 原生在生命周期事件上带出本轮 canonical user identity;这一轮没有任何运行态
// 条目,开口条目只存在于历史里。
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
],
);
expect(running.live).toHaveLength(0);
const done = reduceDirectThreadEvents(running, [
withUserItemId(
event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }),
'direct-codex:turn-1:user',
),
]);
// 边界落在本轮这条用户条目上:既不丢终态时间,也没有第二套回合身份。
expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
});
it('终态先于历史切片到达时,回读到开口条目仍补上已冻结的边界', () => {
const started = resolveDirectThreadBootstrap(
emptyDirectThreadChatState(),
{
subscriptionId: 'sub-1',
lastCompletedItemId: null,
events: [
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
],
},
);
// 终态先到:这一刻历史里还没有那条用户条目。
const finished = reduceDirectThreadEvents(started, [
withUserItemId(
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
'direct-codex:turn-1:user',
),
]);
expect(finished.turnEndedAt).toBe(1_000_900);
expect(finished.history).toHaveLength(0);
const hydrated = mergeDirectHistoryItems(finished, [
messageItem({
itemId: 'direct-codex:turn-1:user',
role: 'user',
text: '做一个拼图游戏',
at: 999_000,
}),
]);
expect(hydrated.history[0]?.turnStartedAt).toBe(1_000_000);
expect(hydrated.history[0]?.turnEndedAt).toBe(1_000_900);
});
it('上一轮迟到的终态按身份被拒,不关掉正在跑的这一轮', () => {
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 2_000_000 }),
'direct-codex:turn-1:user',
),
event({ type: 'item.started', at: 2_000_100, item: toolStarted() }),
]);
const late = reduceDirectThreadEvents(running, [
// 上一轮的终态(身份不同):哪怕时间戳更早,也不允许收口正在跑的这一轮。
withUserItemId(
event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }),
'direct-codex:turn-0:user',
),
]);
expect(late.turnRunning).toBe(true);
expect(late.turnEndedAt).toBe(0);
expect(late.live).toHaveLength(1);
});
it('同一轮的重复终态不抬高已冻结的终点', () => {
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
withUserItemId(
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
'direct-codex:turn-1:user',
),
]);
const replayed = reduceDirectThreadEvents(done, [
withUserItemId(
event({ type: 'turn.completed', status: 'completed', at: 9_900_000 }),
'direct-codex:turn-1:user',
),
]);
expect(replayed.turnEndedAt).toBe(1_000_900);
expect(replayed.live).toHaveLength(0);
});
it('本轮自己没有任何条目时,不把边界盖到上一轮已经收口的开口条目上', () => {
const previousOpener: DirectChatEntry = {
itemId: 'direct-codex:turn-0:user',
kind: 'message',
role: 'user',
text: '上一轮的问题',
at: 900_000,
turnStartedAt: 910_000,
turnEndedAt: 950_000,
};
const previous: DirectThreadChatState = {
...emptyDirectThreadChatState(),
history: [previousOpener],
};
const done = reduceDirectThreadEvents(previous, [
withUserItemId(
event({ type: 'turn.started', at: 2_000_000 }),
'direct-codex:turn-2:user',
),
withUserItemId(
event({ type: 'turn.completed', status: 'aborted', at: 2_000_400 }),
'direct-codex:turn-2:user',
),
]);
expect(done.history[0]?.turnStartedAt).toBe(910_000);
expect(done.history[0]?.turnEndedAt).toBe(950_000);
});
/**
* 本轮开口条目只由 history slice 回读、运行态全程为空:身份只能来自原生生命周期事件上
* 带的 canonical user identity,不能按历史尾或时间戳猜。
*/
it('只有生命周期锚点 + 历史回读的开口条目时,按原生身份精准恢复用时', () => {
const running = resolveDirectThreadBootstrap(
emptyDirectThreadChatState(),
{
subscriptionId: 'sub-1',
lastCompletedItemId: null,
events: [
withUserItemId(
event({ type: 'turn.started', at: 1_000_000 }),
'direct-codex:turn-1:user',
),
],
},
);
const withHistory = mergeDirectHistoryItems(running, [
messageItem({
itemId: 'direct-codex:turn-1:user',
role: 'user',
text: '做一个拼图游戏',
at: 999_000,
}),
]);
expect(withHistory.live).toHaveLength(0);
const done = reduceDirectThreadEvents(withHistory, [
withUserItemId(
event({ type: 'turn.completed', status: 'aborted', at: 1_000_900 }),
'direct-codex:turn-1:user',
),
]);
expect(done.turnRunning).toBe(false);
expect(done.turnEndedAt).toBe(1_000_900);
// 身份来自原生事件,历史里那条开口条目因此拿到同一组边界。
expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
});
it('收口只填空,不抬高条目上已经冻结的终点', () => {
// 规则本身:已经写上的边界先到先用,重复 / 迟到的收口不得抬高它。
const frozenOpener: DirectChatEntry = {
itemId: 'direct-codex:turn-1:user',
kind: 'message',
role: 'user',
text: '做一个拼图游戏',
at: 999_000,
turnStartedAt: 1_000_000,
turnEndedAt: 1_000_900,
};
const state: DirectThreadChatState = {
...emptyDirectThreadChatState(),
history: [frozenOpener],
turnUserItemId: 'direct-codex:turn-1:user',
live: [
{
itemId: 'call-late',
kind: 'tool',
role: null,
text: null,
at: 0,
toolCall: {
schemaVersion: 'agc-tool-call.v1',
id: 'call-late',
kind: 'command',
title: '执行命令',
summary: 'npm run build',
status: 'completed',
detail: { command: 'npm run build' },
startedAt: 1_000_100,
updatedAt: 1_000_700,
},
},
],
turnStartedAt: 1_000_000,
};
const done = finishDirectThreadTurn(state, 9_900_000);
expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
});
it('宿主终止收口:没有权威终态时间就不写,之后的原生终态事件也不会被抬高', () => {
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }),
]);
const released = finishDirectThreadTurn(running, Date.now());
expect(released.turnRunning).toBe(false);
expect(released.live).toHaveLength(0);
expect(released.history[0]?.turnEndedAt).toBe(released.turnEndedAt);
const unknown = finishDirectThreadTurn(running, 0);
expect(unknown.turnEndedAt).toBe(0);
expect(unknown.history[0]?.turnEndedAt).toBeUndefined();
// 已经收口:后续事件不再改动终态时间,也不复活运行态。
const replayed = reduceDirectThreadEvents(unknown, [
event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }),
]);
expect(replayed.turnRunning).toBe(false);
expect(replayed.turnEndedAt).toBe(0);
});
});
});