Files
Genarrative/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
T
k88936 b42966eb9e 前端:Direct 失败说明补上宿主事实句的文案模式
- `projectRuntimeVisibleError` 增加宿主 `Display` 事实句模式:`执行通道已断开`(TransportClosed)、`等待模型回合结束达到硬上限`(TimedOut 的硬上限那档)、`宿主任务提前结束`(host-dropped),并给落盘那档补上 `收尾历史失败` / `未确认历史完整落盘` / `写入本项目对话历史失败`——改动前这几种都掉进「执行失败,请稍后重试」
- 只认宿主写死的短语、不回落原文:原文带 `exitStatus=` / `stderrClass=` 这类内部字段,`TransportClosed` 就是这种
- 修掉宿主收口文案的版本口径:解析只认 `v1`,而宿主发的是多一段 `code=` 的 `v2`,于是脱敏摘要永远命中不了;现在两版都认,并把解析结果拆成 parts,供拒单文案复用(`projectRuntimeVisibleRejectionError`,不带阶段标签——拒单这一轮没有开始)
- `directTurnFailureNoticeText` 的文档注释写明「不加模式就只会看到通用文案」是有意取舍,加模式时补 `agentRuntimeModel.test.ts` 用例
- 用例:`agentRuntimeModel.test.ts` 补 v2 收口文案、拒单文案与三句宿主事实句;`directThreadChat.test.ts` 的「收尾历史失败」期望改成映射后的句子
2026-09-24 16:19:37 +08:00

944 lines
35 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 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);
});
});
});