99be1d76fe
- 新增 view/project-development/chat 模块:DirectProjectChatView 自己持有项目清单订阅、首屏锚点、历史分页、发送队列、附件、终止、草稿与本地消息 - DirectProjectChatView 入参收窄为项目路径、入口首轮需求(canonical content[])和两条权限门,Direct 状态不再穿过 App.tsx - 线程订阅与聊天 reducer 提成 useDirectThreadChatSubscription:subscribe/consume/notify 单飞循环、订阅欠账、SUBSCRIPTION_EXPIRED 重订与 DirectThreadChatState - chat/components 按「一个组件一个目录、逻辑与表现同目录」重组(ChatHeader、Composer、Conversation、SettingsDialog、ToolCallGroup) - DirectProjectChatView 拆成聊天头、会话区、回合、输入盒四个组件 - App.tsx 删除 Direct 专属 state/ref/effect/handler 与 directCodex 分支,首轮需求按 canonical content 交给聊天 - 工作台级动作(运行本地预览等)经 DirectProjectChatHandle.announce 交给聊天自己的本地消息流 - Direct 首轮认领记录提成 app/initialSupervisorMessageClaims,供各入口共用 - Rust 侧 Direct 契约 ts-rs 导出目录改到 chat/generated - 补齐 direct_codex_user_item/wire.rs 既有的 rustfmt 空格漂移 - 同步 ADR、实施计划、decision-log、pitfalls 与 Direct 技术文档
252 lines
8.2 KiB
TypeScript
252 lines
8.2 KiB
TypeScript
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);
|
|
});
|
|
});
|