Files
Genarrative/apps/ai-game-creator-shell/tests/directThreadChat.test.ts
T
suzmii cd8f480719 修复生成草稿隔离与回合计时恢复及布局排队
按占位身份隔离生成面板并保存草稿,阻止改变失败请求输入后伪装重试。
保留用户真实发送时间,按既有用户条目身份恢复空运行态回合的终态边界。
修复跨栏目整理队列覆盖及多选拖动预览起点漂移。
补齐六项 review 发现的回归、原生绑定、交叉审查及浏览器验收记录。
2026-09-18 13:25:18 +08:00

685 lines
26 KiB
TypeScript

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/features/project-workspace/directThreadChat';
import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents';
import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection';
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('历史条目与运行态按唯一 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('事件级计时边界', () => {
/** 工具的开始 / 完成只读事件级 `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('同一轮内的重复开始事件保留第一次的起点', () => {
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
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_000);
});
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);
});
});
});