import { describe, expect, it } from 'vitest'; import { emptyDirectThreadChatState, 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; } /** 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('历史条目与运行态按唯一 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"}'); }); });