新增 DirectProject 历史分页连拉模块与单测
directHistoryPaging 按「合并后聊天投影回合数增加」判断可见反馈,出现新回合或 hasMore=false 即停 连拉上限 DIRECT_HISTORY_MAX_PAGES_PER_ACTION 放进 app/constants,每个操作最多 5 次请求 锚点未推进(items 为空 / firstItemId 为 null 或原地打转)时立即停手并收口 hasMore 某页读取失败不丢已取到的页,返回 error 交给调用方报错 directThreadChat 抽出 projectDirectHistoryItems,让分页判据与视图共用同一份投影
This commit is contained in:
@@ -14,6 +14,8 @@ export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20;
|
||||
export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20;
|
||||
export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20;
|
||||
export const CONVERSATION_VISIBLE_STEP = 20;
|
||||
/** 一次翻页操作最多连拉几页:见 ADR「分页锚点取原始条目 id」。 */
|
||||
export const DIRECT_HISTORY_MAX_PAGES_PER_ACTION = 5;
|
||||
export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48;
|
||||
export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor';
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。
|
||||
*
|
||||
* 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏,
|
||||
* 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后
|
||||
* 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。
|
||||
*
|
||||
* 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。
|
||||
*/
|
||||
|
||||
import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants';
|
||||
import {
|
||||
type DirectChatEntry,
|
||||
mergeHistoryEntries,
|
||||
projectDirectHistoryItems,
|
||||
} from './directThreadChat';
|
||||
import type {
|
||||
DirectThreadHistorySlice,
|
||||
DirectThreadItem,
|
||||
} from './directThreadEvents';
|
||||
import { buildDirectChatTurns } from './directTurnPresentation';
|
||||
|
||||
export type DirectHistoryPages = {
|
||||
/** 累积到的条目,保持文件顺序(旧 → 新)。 */
|
||||
items: DirectThreadItem[];
|
||||
/** 后端声明的"还有更早的历史";取不动时收口为 false。 */
|
||||
hasMore: boolean;
|
||||
/** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */
|
||||
firstItemId: string | null;
|
||||
/** 中断原因;`null` 表示正常停止。 */
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
function visibleTurnCount(entries: readonly DirectChatEntry[]): number {
|
||||
return buildDirectChatTurns({ entries }).length;
|
||||
}
|
||||
|
||||
/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */
|
||||
function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) {
|
||||
return [...pages].reverse().flat();
|
||||
}
|
||||
|
||||
export async function readDirectHistoryPages({
|
||||
existingEntries,
|
||||
beforeItemId,
|
||||
readSlice,
|
||||
}: {
|
||||
/** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */
|
||||
existingEntries: readonly DirectChatEntry[];
|
||||
beforeItemId: string | null;
|
||||
readSlice: (beforeItemId: string | null) => Promise<DirectThreadHistorySlice>;
|
||||
}): Promise<DirectHistoryPages> {
|
||||
const baseTurnCount = visibleTurnCount(existingEntries);
|
||||
const pages: DirectThreadItem[][] = [];
|
||||
let anchor = beforeItemId;
|
||||
let hasMore = false;
|
||||
let error: unknown = null;
|
||||
for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) {
|
||||
let slice: DirectThreadHistorySlice;
|
||||
try {
|
||||
slice = await readSlice(anchor);
|
||||
} catch (thrown) {
|
||||
// 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。
|
||||
error = thrown;
|
||||
break;
|
||||
}
|
||||
pages.push([...slice.items]);
|
||||
hasMore = slice.hasMore;
|
||||
const nextAnchor = slice.firstItemId;
|
||||
if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) {
|
||||
// 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。
|
||||
hasMore = false;
|
||||
break;
|
||||
}
|
||||
anchor = nextAnchor;
|
||||
if (!hasMore) break;
|
||||
const merged = mergeHistoryEntries(
|
||||
projectDirectHistoryItems(inFileOrder(pages)),
|
||||
existingEntries,
|
||||
);
|
||||
if (visibleTurnCount(merged) > baseTurnCount) break;
|
||||
}
|
||||
return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error };
|
||||
}
|
||||
@@ -230,6 +230,15 @@ export function mergeHistoryEntries(
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** 历史切片条目 → 聊天条目:可见性判定的唯一入口,分页判据也读这一份。 */
|
||||
export function projectDirectHistoryItems(
|
||||
items: readonly DirectThreadItem[],
|
||||
): DirectChatEntry[] {
|
||||
return items
|
||||
.map((item) => projectDirectThreadItem(item))
|
||||
.filter((entry): entry is DirectChatEntry => Boolean(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。
|
||||
*
|
||||
@@ -239,10 +248,13 @@ export function mergeDirectHistoryItems(
|
||||
state: DirectThreadChatState,
|
||||
items: readonly DirectThreadItem[],
|
||||
): DirectThreadChatState {
|
||||
const entries = items
|
||||
.map((item) => projectDirectThreadItem(item))
|
||||
.filter((entry): entry is DirectChatEntry => Boolean(entry));
|
||||
return { ...state, history: mergeHistoryEntries(entries, state.history) };
|
||||
return {
|
||||
...state,
|
||||
history: mergeHistoryEntries(
|
||||
projectDirectHistoryItems(items),
|
||||
state.history,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeDirectThreadHistorySlice(
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants';
|
||||
import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging';
|
||||
import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat';
|
||||
import {
|
||||
emptyDirectThreadChatState,
|
||||
mergeDirectHistoryItems,
|
||||
selectDirectChatEntries,
|
||||
} from '../src/features/project-workspace/directThreadChat';
|
||||
import type {
|
||||
DirectThreadHistorySlice,
|
||||
DirectThreadItem,
|
||||
} from '../src/features/project-workspace/directThreadEvents';
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user