import { describe, expect, it } from 'vitest'; import type { ChatMessage } from '../src/app/types'; import type { DirectChatEntry } from '../src/view/project-development/chat/conversation/directThreadChat'; import { buildDirectChatTurns, directChatEntriesToMessages, directMessageTimestamp, mergeDirectChatMessages, normalizeDirectTimestamp, } from '../src/view/project-development/chat/conversation/directTurnPresentation'; const userEntry = ( itemId: string, at = 1_800_000_000_000, ): DirectChatEntry => ({ itemId, kind: 'message', role: 'user', text: `问题 ${itemId}`, at, }); const assistantEntry = ( itemId: string, text: string, at = 1_800_000_001_000, ): DirectChatEntry => ({ itemId, kind: 'message', role: 'assistant', text, at, }); const toolEntry = ( itemId: string, at = 1_800_000_000_500, ): DirectChatEntry => ({ itemId, kind: 'tool', role: null, text: null, at, toolCall: { schemaVersion: 'agc-tool-call.v1', id: itemId, kind: 'command', title: '执行命令', summary: 'npm run build', status: 'completed', detail: { command: 'npm run build' }, startedAt: at, updatedAt: at, }, }); /** 工具条目:边界只来自事件级时间,历史切片拿不到就留 0。 */ const liveToolEntry = ( itemId: string, startedAt: number, updatedAt: number, status: 'running' | 'completed' = 'completed', ): DirectChatEntry => ({ itemId, kind: 'tool', role: null, text: null, at: 0, toolCall: { schemaVersion: 'agc-tool-call.v1', id: itemId, kind: 'command', title: '执行命令', summary: 'npm run build', status, detail: { command: 'npm run build' }, startedAt, updatedAt, }, }); const reasoningEntry = ( itemId: string, text = '先看目录', at = 1_800_000_000_200, ): DirectChatEntry => ({ itemId, kind: 'reasoning', role: null, text, at, }); const localNotice = (text: string, messageId?: string): ChatMessage => ({ role: 'assistant', text, ...(messageId ? { messageId } : {}), updatedAt: 1_800_000_002_500, }); describe('DirectProject 聊天分区', () => { it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => { expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000); expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000); expect(directMessageTimestamp(undefined)).toBe(0); expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0); expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001); }); it('每个用户条目开一个新回合,顺序就是条目顺序', () => { const turns = buildDirectChatTurns({ entries: [ userEntry('u1'), assistantEntry('a1', '第一轮答复'), userEntry('u2', 1_800_000_010_000), assistantEntry('a2', '第二轮答复', 1_800_000_011_000), ], }); expect(turns.map((turn) => turn.key)).toEqual(['u1', 'u2']); expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' }); expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' }); expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' }); // 历史条目没有回合边界:起点也是 0,整条终态文案因此隐藏(不按用户条目的落盘时间编一个)。 expect(turns[0]?.startedAt).toBe(0); // 终点只认明确终态:旧历史条目里没有 `turnEndedAt` 就隐藏,不拿最后一条正文的时间顶替。 expect(turns[0]?.endedAt).toBe(0); }); it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => { const turns = buildDirectChatTurns({ entries: [ userEntry('u1'), reasoningEntry('r1'), assistantEntry('a-mid', '我先看一下项目'), toolEntry('t1'), assistantEntry('a-final', '改好了', 1_800_000_002_000), ], }); expect(turns[0]?.process.map((block) => block.key)).toEqual([ 'u1:r1', 'u1:a-mid', 'u1:t1', ]); expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u1:a-final']); }); it('连续工具合成一块,夹了正文就另起一块', () => { const turns = buildDirectChatTurns({ entries: [ userEntry('u1'), toolEntry('t1'), toolEntry('t2', 1_800_000_000_600), assistantEntry('a-mid', '继续', 1_800_000_000_700), toolEntry('t3', 1_800_000_000_800), ], turnRunning: true, }); const process = turns[0]?.process ?? []; expect(process.map((block) => block.kind)).toEqual([ 'tools', 'assistant', 'tools', ]); expect(process[0]).toMatchObject({ kind: 'tools', calls: [{ id: 't1' }, { id: 't2' }], }); expect(process[2]).toMatchObject({ kind: 'tools', calls: [{ id: 't3' }] }); }); it('运行中的回合:过程不折叠,最后的助手正文仍在过程里流式显示', () => { const turns = buildDirectChatTurns({ entries: [ userEntry('u1'), assistantEntry('a-live', '正在写'), toolEntry('t1', 1_800_000_001_100), ], turnRunning: true, }); expect(turns[0]?.state).toBe('running'); expect(turns[0]?.finals).toEqual([]); expect(turns[0]?.process.map((block) => block.kind)).toEqual([ 'assistant', 'tools', ]); }); it('整轮边界只落在本轮:旧历史回合没有终态就继续隐藏,不吃最新回合的终点', () => { const turns = buildDirectChatTurns({ entries: [ // 两个旧回合:历史切片里没有事件级边界。 userEntry('u1', 1_800_000_000_000), assistantEntry('a1', '第一轮答复', 1_800_000_001_000), userEntry('u2', 1_800_000_010_000), assistantEntry('a2', '第二轮答复', 1_800_000_011_000), // 当前回合:用户发送时间 + 明确终态(收口时盖在本轮条目上)。 { ...userEntry('u3', 1_800_000_020_000), turnEndedAt: 1_800_000_022_500, }, liveToolEntry('t3', 1_800_000_021_000, 1_800_000_022_000), { ...assistantEntry('a3', '第三轮答复', 1_800_000_022_400), turnStartedAt: 1_800_000_020_000, turnEndedAt: 1_800_000_022_500, }, ], }); expect(turns.map((turn) => turn.endedAt)).toEqual([ 0, 0, 1_800_000_022_500, ]); // 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点 / 起点。 expect(turns[0]?.startedAt).toBe(0); expect(turns[2]?.startedAt).toBe(1_800_000_020_000); }); it('运行中的整轮起点只认原生 turn.started.at,条目自己的 at 不当起点', () => { const liveTurn = buildDirectChatTurns({ entries: [ { ...userEntry('u1', 0), at: 0 }, liveToolEntry('t1', 1_800_000_000_500, 0, 'running'), ], turnRunning: true, turnStartedAt: 1_800_000_000_100, }); // 用户条目没有发送时间:用原生 turn.started.at 兜底。 expect(liveTurn[0]?.startedAt).toBe(1_800_000_000_100); const withUserTime = buildDirectChatTurns({ entries: [ userEntry('u1', 1_800_000_000_050), liveToolEntry('t1', 1_800_000_000_500, 0, 'running'), ], turnRunning: true, turnStartedAt: 1_800_000_000_100, }); // 起点只认宿主的回合边界:条目自己的 `at` 是落盘 / 观测时间,不当起点用。 expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_100); }); it('本地用户消息不再进回合:气泡只来自宿主条目', () => { const sentAt = 1_800_000_000_000; const turns = buildDirectChatTurns({ entries: [userEntry('direct-codex:turn-1:user', sentAt + 1_200)], // 本地只保留说明:任何用户消息(哪怕是同一条消息的副本)都不造气泡、也不开回合。 localMessages: [ { role: 'user' as const, text: '问题 direct-codex:turn-1:user', messageId: 'direct-codex:turn-2:user', updatedAt: sentAt, }, ], }); expect(turns.map((turn) => turn.key)).toEqual(['direct-codex:turn-1:user']); // 显示时间就是宿主的落盘 / 观测时间(本地不再有第二份更早的发送时刻)。 expect(turns[0]?.users).toHaveLength(1); expect(turns[0]?.users[0]).toMatchObject({ at: sentAt + 1_200 }); }); it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => { const turns = buildDirectChatTurns({ entries: [userEntry('u1'), assistantEntry('a1', '正文')], localMessages: [localNotice('后台任务失败:端口被占用')], }); expect(turns).toHaveLength(1); expect(turns[0]?.finals.map((block) => block.kind)).toEqual([ 'assistant', 'assistant', ]); expect(turns[0]?.finals[1]).toMatchObject({ notice: true, text: '后台任务失败:端口被占用', }); }); it('本轮开口条目没到时,失败说明按身份挂回自己那一轮', () => { // 现场:回合在宿主下发开口用户条目之前就失败,于是界面上只有这一轮的失败说明——开口条目 // 要等历史切片(或迟到的 item.completed)才到。靠位置分组会把说明留给上一轮,界面表现就是 // "错误显示在用户消息上面",上一轮还会顶替本轮显示耗时。 const turns = buildDirectChatTurns({ entries: [ { ...assistantEntry( 'direct-codex:turn-1:user:failure', '陶泥儿智能创作 连接失败,请重试', ), turnUserItemId: 'direct-codex:turn-1:user', turnStartedAt: 1_800_000_010_000, turnEndedAt: 1_800_000_010_400, }, { ...assistantEntry( 'direct-codex:turn-2:user:failure', '陶泥儿智能创作 连接失败,请重试', ), turnUserItemId: 'direct-codex:turn-2:user', turnStartedAt: 1_800_000_020_000, turnEndedAt: 1_800_000_035_600, }, ], turnRunning: false, }); // 两个回合:说明各自挂在自己的身份上(第二个回合的开口条目还没到,这一组里没有用户气泡)。 expect(turns.map((turn) => turn.key)).toEqual([ 'direct-codex:turn-1:user', 'direct-codex:turn-2:user', ]); expect(turns[0]?.finals.map((block) => block.key)).toEqual([ 'direct-codex:turn-1:user:direct-codex:turn-1:user:failure', ]); expect(turns[0]?.users).toEqual([]); expect(turns[1]?.finals.map((block) => block.key)).toEqual([ 'direct-codex:turn-2:user:direct-codex:turn-2:user:failure', ]); // 边界只看各自条目上盖的回合时间,不借上一轮的终点。 expect(turns[0]?.startedAt).toBe(1_800_000_010_000); expect(turns[0]?.endedAt).toBe(1_800_000_010_400); expect(turns[1]?.startedAt).toBe(1_800_000_020_000); expect(turns[1]?.endedAt).toBe(1_800_000_035_600); }); it('两态:宿主开始事件是唯一的 running 判据,其余都是 finished', () => { // 入队窗口(本地已发出、宿主还没放行)不再是展示态:这一轮在聊天区里根本不存在, // 所以投影拿不到任何条目可渲染,也不会造一个"待认领"的回合出来。 const windowTurns = buildDirectChatTurns({ entries: [] }); expect(windowTurns).toEqual([]); const running = buildDirectChatTurns({ entries: [userEntry('direct-codex:turn-1:user')], turnRunning: true, turnStartedAt: 1_800_000_003_000, }); expect(running.map((turn) => turn.state)).toEqual(['running']); expect(running[0]?.startedAt).toBe(1_800_000_003_000); expect(running[0]?.endedAt).toBe(0); // 拿到终态、或压根没有开始事件的历史回合,都只能是 finished。 const finished = buildDirectChatTurns({ entries: [ { ...userEntry('direct-codex:turn-1:user'), turnEndedAt: 1_800_000_010_000, }, ], turnRunning: false, }); expect(finished.map((turn) => turn.state)).toEqual(['finished']); expect(finished[0]?.endedAt).toBe(1_800_000_010_000); }); it('带身份的本地说明自成一组:不挂进上一轮,也不造出耗时文案', () => { const turns = buildDirectChatTurns({ entries: [userEntry('u1'), assistantEntry('a1', '上一轮答复')], localMessages: [ localNotice( '待发消息已达上限(最多 5 条),请等前面几条发完再发送', 'direct-codex:turn-9:user:rejected', ), ], }); expect(turns.map((turn) => turn.key)).toEqual([ 'u1', 'direct-codex:turn-9:user:rejected', ]); // 这一组没有用户条目,也就没有起点:`DirectProjectTurn` 的 `turn.startedAt` 判据会把整条 // 「本轮结束于 … 」隐藏(不再出现 0.0 秒)。 expect(turns[1]?.users).toEqual([]); expect(turns[1]?.startedAt).toBe(0); expect(turns[1]?.finals.map((block) => block.text)).toEqual([ '待发消息已达上限(最多 5 条),请等前面几条发完再发送', ]); }); it('历史无用户条目时也保留一个回合承载正文', () => { const turns = buildDirectChatTurns({ entries: [assistantEntry('a1', '只有回复')], }); expect(turns).toHaveLength(1); expect(turns[0]?.users).toEqual([]); expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' }); }); it('分页切片开头落在半截回合里时并进后面那个回合,不生成没有用户气泡的孤儿回合', () => { // 更早的一屏把上一条用户条目切在外面:这三条前导条目属于上一轮,但不能自成回合。 const turns = buildDirectChatTurns({ entries: [ reasoningEntry('r-prev'), toolEntry('t-prev'), assistantEntry('a-prev', '上一轮的答复', 1_800_000_000_900), userEntry('u2', 1_800_000_010_000), assistantEntry('a2', '这一轮的答复', 1_800_000_011_000), ], }); expect(turns.map((turn) => turn.key)).toEqual(['u2']); expect(turns[0]?.users.map((block) => block.key)).toEqual(['u2:u2']); expect(turns[0]?.process.map((block) => block.key)).toEqual([ 'u2:r-prev', 'u2:t-prev', 'u2:a-prev', ]); expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u2:a2']); }); it('条目转消息只保留用户 / 助手正文,并按身份去重', () => { const messages = directChatEntriesToMessages([ userEntry('u1'), reasoningEntry('r1'), toolEntry('t1'), assistantEntry('a1', '答复'), ]); expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']); expect( mergeDirectChatMessages(messages, [ { role: 'assistant', text: '答复', messageId: 'a1' }, localNotice('只在运行期的失败说明', 'local-1'), ]).map((message) => message.messageId), ).toEqual(['u1', 'a1', 'local-1']); }); });