import { describe, expect, it, vi } from 'vitest'; import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants'; import type { DirectChatEntry } from '../src/view/project-development/chat/conversation/directThreadChat'; import { emptyDirectThreadChatState, mergeDirectHistoryItems, selectDirectChatEntries, } from '../src/view/project-development/chat/conversation/directThreadChat'; import type { DirectThreadHistorySlice } from '../src/view/project-development/chat/generated/DirectThreadHistorySlice'; import type { DirectThreadItem } from '../src/view/project-development/chat/generated/DirectThreadItem'; import { readDirectHistoryPages } from '../src/view/project-development/chat/history/directHistoryPaging'; function userItem(itemId: string): DirectThreadItem { return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 }; } function assistantItem(itemId: string): DirectThreadItem { return { itemType: 'message', itemId, role: 'assistant', text: `答复 ${itemId}`, at: 2000, }; } function toolItem(itemId: string): DirectThreadItem { return { itemType: 'function_call', itemId, name: 'exec_command', arguments: '{"cmd": "ls"}', at: 1500, }; } function reasoningItem(itemId: string): DirectThreadItem { return { itemType: 'reasoning', itemId, text: '想想', at: 1400 }; } function slice( items: DirectThreadItem[], hasMore: boolean, firstItemId: string | null, ): DirectThreadHistorySlice { return { items, hasMore, firstItemId }; } /** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */ function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] { return selectDirectChatEntries( mergeDirectHistoryItems(emptyDirectThreadChatState(), items), ); } function readerOf(pages: DirectThreadHistorySlice[]) { const remaining = [...pages]; return vi.fn(async (_beforeItemId: string | null) => { const next = remaining.shift(); if (!next) throw new Error('测试桩:不该再取页'); return next; }); } describe('DirectProject 历史分页连拉', () => { it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => { // 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。 const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]); const readSlice = readerOf([ slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'), slice( [userItem('turn-1-user'), assistantItem('turn-1-assistant')], false, 'turn-1-user', ), ]); const result = await readDirectHistoryPages({ existingEntries: existing, beforeItemId: 'turn-2-user', readSlice, }); // 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。 expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]); expect(result.items.map((item) => item.itemId)).toEqual([ 'turn-1-user', 'turn-1-assistant', 'call-3', 'reason-3', ]); expect(result.hasMore).toBe(false); expect(result.firstItemId).toBe('turn-1-user'); expect(result.error).toBeNull(); const merged = selectDirectChatEntries( mergeDirectHistoryItems( { turnRunning: false, history: existing, live: [] }, result.items, ), ); expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true); }); it('第一页就出现新的用户气泡时只取一页', async () => { const existing = entriesOf([userItem('turn-2-user')]); const readSlice = readerOf([ slice([userItem('turn-1-user')], true, 'turn-1-user'), ]); const result = await readDirectHistoryPages({ existingEntries: existing, beforeItemId: 'turn-2-user', readSlice, }); expect(readSlice).toHaveBeenCalledTimes(1); // 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。 expect(result.hasMore).toBe(true); expect(result.firstItemId).toBe('turn-1-user'); }); it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => { const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]); const result = await readDirectHistoryPages({ existingEntries: [], beforeItemId: null, readSlice, }); expect(readSlice.mock.calls).toEqual([[null]]); expect(result.items.map((item) => item.itemId)).toEqual(['call-1']); }); it('hasMore=false 时即使没有新回合也立即停止', async () => { const existing = entriesOf([userItem('turn-2-user')]); const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]); const result = await readDirectHistoryPages({ existingEntries: existing, beforeItemId: 'turn-2-user', readSlice, }); expect(readSlice).toHaveBeenCalledTimes(1); expect(result.hasMore).toBe(false); }); it('一直没有新回合时最多连拉上限页数就停手', async () => { const existing = entriesOf([userItem('turn-2-user')]); const readSlice = readerOf( Array.from( { length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 }, (_, index) => slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`), ), ); const result = await readDirectHistoryPages({ existingEntries: existing, beforeItemId: 'turn-2-user', readSlice, }); expect(readSlice).toHaveBeenCalledTimes( DIRECT_HISTORY_MAX_PAGES_PER_ACTION, ); // 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。 expect(result.hasMore).toBe(true); expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION); expect(result.firstItemId).toBe( `call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`, ); }); it('锚点不前进时立即停止,不空转也不留按钮', async () => { const existing = entriesOf([userItem('turn-2-user')]); const readSlice = readerOf([slice([], true, null)]); const result = await readDirectHistoryPages({ existingEntries: existing, beforeItemId: 'turn-2-user', readSlice, }); expect(readSlice).toHaveBeenCalledTimes(1); expect(result.items).toEqual([]); expect(result.hasMore).toBe(false); expect(result.firstItemId).toBe('turn-2-user'); }); it('锚点原地打转时立即停止,不重复取同一页', async () => { const existing = entriesOf([userItem('turn-2-user')]); const readSlice = readerOf([ slice([toolItem('call-3')], true, 'turn-2-user'), ]); const result = await readDirectHistoryPages({ existingEntries: existing, beforeItemId: 'turn-2-user', readSlice, }); expect(readSlice).toHaveBeenCalledTimes(1); expect(result.hasMore).toBe(false); }); it('某一页读取失败时保留已经取到的页', async () => { const existing = entriesOf([userItem('turn-2-user')]); const failure = new Error('读取失败'); const readSlice = vi.fn(async (beforeItemId: string | null) => { if (beforeItemId === 'turn-2-user') { return slice([toolItem('call-3')], true, 'call-3'); } throw failure; }); const result = await readDirectHistoryPages({ existingEntries: existing, beforeItemId: 'turn-2-user', readSlice, }); expect(readSlice).toHaveBeenCalledTimes(2); expect(result.items.map((item) => item.itemId)).toEqual(['call-3']); // 失败的页没有推进锚点:下一次点击会重取这一页。 expect(result.firstItemId).toBe('call-3'); expect(result.hasMore).toBe(true); expect(result.error).toBe(failure); }); it('首页读取失败时保留首屏锚点并返回错误', async () => { const failure = new Error('首页读取失败'); const readSlice = vi.fn(async (_beforeItemId: string | null) => { throw failure; }); const result = await readDirectHistoryPages({ existingEntries: [], beforeItemId: null, readSlice, }); expect(readSlice).toHaveBeenCalledTimes(1); expect(result.items).toEqual([]); expect(result.firstItemId).toBeNull(); expect(result.hasMore).toBe(false); expect(result.error).toBe(failure); }); });