chatHistoryPagination.test.ts4.7 KBView on GitHub import { useCedarStore } from '@/modules/store';
import { mergeCanonicalPage } from '@/store/messages/messageStorage';
import type { Message } from '@/store/messages/MessageTypes';
/**
* Scrolling to the top of a long chat has to reach the rest of it.
*
* The store only ever holds the newest page (50 rows), and whether an older page exists is
* something ONLY the server knows — so `hasMoreMessages` has to be carried back from every
* path that loads a thread, not guessed from the page size and not set on one path only.
*/
function msg(id: string, createdAt: string, content = id): Message {
return { id, role: 'user', type: 'text', content, createdAt } as Message;
}
const PAGE = Array.from({ length: 50 }, (_, i) =>
msg(`m${i}`, `2026-09-01T10:${String(i).padStart(2, '0')}:00.000Z`),
);
describe('paging older chat messages in', () => {
beforeEach(() => {
useCedarStore.setState({ threadMap: {}, mainThreadId: '', activeThreadId: '', messages: [] });
});
it('records that older pages exist when the thread loads at startup', async () => {
useCedarStore.getState().setMessageStorageAdapter({
type: 'custom',
adapter: {
async listThreads() {
return [{ id: 'long', title: 'Adobe QBR', updatedAt: '2026-09-01T11:00:00Z' }];
},
async loadMessages() {
return { messages: PAGE, hasMore: true };
},
},
});
useCedarStore.setState({ userId: 'user_1' });
await useCedarStore.getState().initializeChat({ userId: 'user_1', threadId: 'long' });
// Without this the top of the transcript is a dead end: initializeChat stamps lastLoaded,
// which makes loadThreadMessages early-return forever after, so nothing else ever sets it.
expect(useCedarStore.getState().threadMap.long?.hasMoreMessages).toBe(true);
});
it('prepends the older page and stops when the server says there is no more', async () => {
const older = [msg('older-1', '2026-09-01T09:00:00.000Z')];
useCedarStore.getState().setMessageStorageAdapter({
type: 'custom',
adapter: {
async listThreads() {
return [];
},
async loadMessages() {
return { messages: PAGE, hasMore: true };
},
async loadMoreMessages(_userId, _threadId, before) {
expect(before).toBe(PAGE[0].createdAt);
return { messages: older, hasMore: false };
},
},
});
useCedarStore.setState({
userId: 'user_1',
threadMap: {
long: {
id: 'long',
messages: PAGE,
lastLoaded: '2026-09-01T11:00:00Z',
hasMoreMessages: true,
},
},
});
await useCedarStore.getState().loadMoreMessages('long');
const thread = useCedarStore.getState().threadMap.long;
expect(thread?.messages[0]?.id).toBe('older-1');
expect(thread?.messages).toHaveLength(51);
expect(thread?.hasMoreMessages).toBe(false);
});
it('a load that returns nothing leaves the transcript alone', async () => {
useCedarStore.getState().setMessageStorageAdapter({
type: 'custom',
adapter: {
async listThreads() {
return [];
},
async loadMessages() {
return { messages: PAGE, hasMore: true };
},
async loadMoreMessages() {
return { messages: [], hasMore: false };
},
},
});
useCedarStore.setState({
userId: 'user_1',
threadMap: {
long: { id: 'long', messages: PAGE, hasMoreMessages: true },
},
});
await useCedarStore.getState().loadMoreMessages('long');
expect(useCedarStore.getState().threadMap.long?.messages).toHaveLength(50);
expect(useCedarStore.getState().threadMap.long?.hasMoreMessages).toBe(false);
});
});
/**
* After every send the store re-reads the thread's canonical rows — but that read is one PAGE,
* so replacing the transcript with it threw away everything the user had just scrolled back to.
*/
describe('reconciling against the canonical page', () => {
it('keeps the scrollback that the page does not reach', () => {
const older = [msg('older-1', '2026-09-01T09:00:00.000Z')];
const merged = mergeCanonicalPage([...older, ...PAGE], PAGE);
expect(merged[0]?.id).toBe('older-1');
expect(merged).toHaveLength(51);
});
it('takes the server copy of anything the page does cover', () => {
const first = PAGE[0];
if (!first?.createdAt) throw new Error('the page fixture has no first message');
const stale = msg('m0', first.createdAt, 'stale');
const merged = mergeCanonicalPage([stale], PAGE);
expect(merged).toHaveLength(50);
expect(merged[0]?.content).toBe('m0');
});
it('an empty page is a failed fetch, not an empty thread', () => {
expect(mergeCanonicalPage(PAGE, [])).toBe(PAGE);
});
});