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 & Partial, ): 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 { 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> = {}, ): 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('事件级计时边界', () => { /** 工具的开始 / 完成只读事件级 `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); }); }); });