补跨页同回合的历史分页回归用例

用 51 条历史复现用户报的现象:20 条工具条目整屏落进同一个折叠的执行过程
断言一次点击连拉两页拿到新用户气泡,并按 process-31 / process-11 两次锚点推进
断言连拉插入旧内容后视口仍停在原处,不被拉回列表底部
This commit is contained in:
2026-09-17 16:49:08 +08:00
parent 37ae7508b8
commit e382d23892
@@ -8423,6 +8423,114 @@ export function registerProjectSupervisorSurfaceTests() {
});
});
it('keeps pulling earlier pages while the page only deepens the rendered turn', async () => {
// 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里,
// 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。
const projectPath = '/tmp/launcher-direct-history-cross-page-game';
const manifest = createGameCreationAppManifest(
'local-project-draft',
'launcher-direct-history-cross-page-game',
);
const supervisorHarness = createProjectSupervisorRuntimeHarness({
projectPath,
});
// 51 条:用户气泡 + 50 条工具条目。首屏取尾 20 条(process-31..process-50),
// 第一次翻页再取 20 条(process-11..process-30),都还在同一个回合里。
supervisorHarness.setDirectThreadHistory([
{
itemType: 'message',
itemId: 'direct-codex:turn-更早:user',
role: 'user',
text: '更早的用户提问',
at: 1000,
},
...Array.from({ length: 50 }, (_, index) => ({
itemType: 'function_call',
itemId: `process-${index + 1}`,
name: 'exec_command',
arguments: `echo ${index + 1}`,
at: 1100 + index,
})),
]);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_design_agent_runtime_mode') return null;
if (command === 'inspect_local_project_directory') {
return {
projectPath,
exists: true,
isDirectory: true,
isGameCreatorProject: true,
projectName: 'launcher-direct-history-cross-page-game',
recentRunStatus: null,
recentRunStopReason: null,
};
}
if (command === 'get_local_game_manifest') {
return manifest;
}
if (command === 'get_local_game_preview_status') {
return { status: 'stopped', url: null, port: null, root: null };
}
return supervisorHarness.invoke(command, args);
},
);
window.__TAURI__ = {
core: { invoke },
event: { listen: supervisorHarness.listen },
};
renderLauncherProjectsAt('/?launcher');
pickProjectFromLauncher(projectPath);
const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话');
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
limit: 20,
}),
);
// 首屏只有这个回合的执行过程:用户气泡还在更早的页里。
expect(within(supervisorSurface).queryByText('更早的用户提问')).toBeNull();
// 视口停在列表中部(既不在顶部触发自动翻页,也不在底部跟随最新)。
const messageList = screen.getByLabelText('陶泥儿消息') as HTMLDivElement;
let messageScrollHeight = 600;
Object.defineProperty(messageList, 'scrollHeight', {
configurable: true,
get: () => messageScrollHeight,
});
Object.defineProperty(messageList, 'clientHeight', {
configurable: true,
get: () => 100,
});
messageList.scrollTop = 100;
fireEvent.scroll(messageList);
// 插入后列表会变长:跟随最新的话视口会被拉到这个高度。
messageScrollHeight = 2000;
fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' }));
// 一次点击连拉两页:第一页仍然只有执行过程,第二页才带回用户气泡。
expect(
await within(supervisorSurface).findByText('更早的用户提问'),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
beforeItemId: 'process-31',
limit: 20,
});
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
projectPath,
beforeItemId: 'process-11',
limit: 20,
});
// 取到文件头后按钮收起。
expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull();
// 连拉插入的旧内容不得把视口弹到列表底部。
expect(messageList.scrollTop).toBe(100);
});
it('drains a notify that lands before the subscribe receipt so the running turn is not stranded', async () => {
// 真实竞态:`subscribe` 在 Rust 侧已经注册好 subscriber 并开始通知,但前端还没拿到
// subscriptionId。此时的通知不能白丢——拿到回执后必须补一次 consume,否则整个回合会卡在队列里。