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);
  });
});