Files
Codeman/test/services/unified-session-service.test.ts
Michael Grundberg 327e440607 fix(codex): fold a codex session into its own rollout row
Review fixes for #386.

Duplicate rows. A codex conversation showed twice, once live and once as a
past rollout row, because nothing aliased a codex session to its thread id.
That is worse than cosmetic: the stale row still resumes, so clicking it
starts a second `codex resume` on a thread already open in another pane.

  - A RESUMED session knows its thread id up front, so it folds from its own
    side: add `codexConfig.resumeSessionId` to the `claudeSessionId` chain.
    Not only in the constructor — `start()` recomputes that id at two further
    points (the mux branch, and the unconditional "third reset point" whose
    own comment already warned that omitting omp's fallback there stomps the
    mux branch's resolved alias). Both listed Claude's and omp's ids only, so
    for codex every mux reattach and boot recovery reset the alias back to
    the Codeman id and the duplicate returned.
  - A FRESH session has no thread id until codex writes the rollout, so it is
    folded from the other side. The scanner now reports
    `session_meta.originator`, which is `codeman_<sessionId>` for every pane
    Codeman spawns, and `gatherUnifiedInputs()` stamps the matching live and
    persisted rows, newest rollout winning (`/new` inside the TUI leaves
    several rollouts sharing one originator).
  - Persisted rows read `codexConfig.resumeSessionId` too. A resumed session
    demoted to a persisted-only record would otherwise lose its alias, and
    the originator fallback cannot rescue that one: a resumed rollout keeps
    its ORIGINAL session_meta, so it still names the pane that created the
    thread rather than the pane that resumed it.

Identity cache. It was written as soon as the thread id was known, but codex
writes the first user message only when the user submits, so any scan in that
window pinned `firstPrompt: undefined` for the life of the process — and the
home screen, the command palette and the search-index refresh all scan.
`shouldCacheIdentity()` now keeps an identity only once the prompt is known or
the head read filled its whole window.

Also from review: both caps count emitted rows rather than file index, so a
store of sub-agent threads no longer spends the `lastPrompt` budget before the
first row that needed it; the cache is an `LRUMap` sized like the one beside
it; the unreachable filename fallback is gone; a rollout recording no cwd is
dropped rather than emitted with `workingDir: ''`; and the unified-session
module header names all three transcript stores.

Tests. The resume wiring now has cases for a row with a thread id, a row
without one, and a `resumeId` on a non-codex row; the "no continuation is
wired" case narrows to gemini/antigravity, which is no longer true of codex.
`codex-resume-alias-survives-start.test.ts` drives a real Session through
`start()` rather than asserting on pre-stamped inputs — that gap is why the
reset points went unnoticed. Plus the maintainer's own cache repro, the
tail-budget case, a no-cwd case, and merge cases for both folds.
2026-09-06 21:53:38 +02:00

592 lines
23 KiB
TypeScript

/**
* @fileoverview Unit tests for the pure unified-session merge/filter service (COD-121).
*
* Covers dedup across sources, source precedence, mux-stat merge + meaningfulness
* floor, sort ordering, and filterAndPaginate (search + paging + clamps).
* Node env only — no jsdom, no IO.
*/
import { describe, it, expect } from 'vitest';
import {
mergeUnifiedSessions,
filterAndPaginate,
type UnifiedSessionItem,
} from '../../src/services/unified-session-service.js';
describe('mergeUnifiedSessions', () => {
// A codex conversation showing twice is worse than cosmetic: the stale PAST row
// still resumes, so clicking it starts a SECOND `codex resume` on a thread
// already open in another pane. Both folds below are what prevent that.
it('folds a RESUMED codex session into its own rollout row', () => {
// Session sets claudeSessionId from codexConfig.resumeSessionId, so the live
// row already names the thread the rollout is keyed by.
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'codex-thread-id' }],
history: [
{
sessionId: 'codex-thread-id',
workingDir: '/w',
sizeBytes: 4000,
lastModified: '2026-09-02T00:00:00.000Z',
mode: 'codex',
resumeId: 'codex-thread-id',
},
],
});
expect(merged).toHaveLength(1);
expect(merged[0].sessionId).toBe('codeman-uuid');
expect([...merged[0].sources].sort()).toEqual(['history', 'live']);
});
it('folds a FRESH codex session once its rollout has been matched by originator', () => {
// A fresh pane knows no thread id, so gatherUnifiedInputs() stamps one onto
// the live row from session_meta.originator (see codexThreadBySessionId).
// This is that stamped row.
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-uuid', status: 'busy', mode: 'codex', claudeSessionId: 'fresh-thread-id' }],
persisted: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'fresh-thread-id' }],
history: [
{
sessionId: 'fresh-thread-id',
workingDir: '/w',
sizeBytes: 900,
lastModified: '2026-09-02T00:00:00.000Z',
mode: 'codex',
resumeId: 'fresh-thread-id',
},
],
});
expect(merged).toHaveLength(1);
expect(merged[0].sessionId).toBe('codeman-uuid');
expect(merged[0].status).toBe('busy');
});
it('leaves an unrelated codex rollout as its own row', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex', claudeSessionId: 'thread-one' }],
history: [
{
sessionId: 'thread-two',
workingDir: '/w',
sizeBytes: 4000,
lastModified: '2026-09-02T00:00:00.000Z',
mode: 'codex',
resumeId: 'thread-two',
},
],
});
expect(merged.map((m) => m.sessionId).sort()).toEqual(['codeman-uuid', 'thread-two']);
});
it("carries a transcript row's own resume token, and stamps none on a live row", () => {
// codex names a thread by an id in its rollout, not by Codeman's session id.
// The scanner sets `resumeId`; a live session never does, which is what stops
// a resume from asking codex for a thread whose id is really Codeman's uuid.
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-uuid', status: 'idle', mode: 'codex' }],
history: [
{
sessionId: 'codex-thread-id',
workingDir: '/w',
sizeBytes: 4000,
lastModified: '2026-09-02T00:00:00.000Z',
mode: 'codex',
resumeId: 'codex-thread-id',
},
],
});
const fromTranscript = merged.find((m) => m.sessionId === 'codex-thread-id');
const fromLive = merged.find((m) => m.sessionId === 'codeman-uuid');
expect(fromTranscript?.resumeId).toBe('codex-thread-id');
expect(fromTranscript?.mode).toBe('codex');
expect(fromLive?.resumeId).toBeUndefined();
});
it('dedupes the same sessionId across live + persisted into one item', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 's1', status: 'working', isWorking: true }],
persisted: [{ id: 's1', status: 'idle' }],
history: [{ sessionId: 's1', workingDir: '/w', sizeBytes: 5000, lastModified: '2026-01-01T00:00:00.000Z' }],
});
expect(merged).toHaveLength(1);
const item = merged[0];
expect(item.sessionId).toBe('s1');
// sources accumulate from every contributing source (order-insensitive)
expect([...item.sources].sort()).toEqual(['history', 'live', 'persisted']);
// live wins for status
expect(item.status).toBe('working');
expect(item.isWorking).toBe(true);
});
it('folds a resumed session transcript (claudeSessionId != id) into ONE row', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'cm-1', status: 'working', claudeSessionId: 'uuid-resume' }],
history: [
{
sessionId: 'uuid-resume', // transcript rows are keyed by the conversation UUID
workingDir: '/w',
sizeBytes: 7000,
lastModified: '2026-01-03T00:00:00.000Z',
firstPrompt: 'resumed prompt',
},
],
});
expect(merged).toHaveLength(1);
const item = merged[0];
expect(item.sessionId).toBe('cm-1');
expect([...item.sources].sort()).toEqual(['history', 'live']);
expect(item.firstPrompt).toBe('resumed prompt');
expect(item.sizeBytes).toBe(7000);
});
it('resolves history rows through a persisted claudeSessionId alias', () => {
const merged = mergeUnifiedSessions({
persisted: [{ id: 'cm-2', name: 'Resumed', claudeSessionId: 'uuid-p' }],
history: [{ sessionId: 'uuid-p', workingDir: '/w', sizeBytes: 4200, lastModified: '2026-01-04T00:00:00.000Z' }],
});
expect(merged).toHaveLength(1);
expect(merged[0].sessionId).toBe('cm-2');
expect([...merged[0].sources].sort()).toEqual(['history', 'persisted']);
});
it('surfaces the NEWEST lifecycle name/mode (entries arrive newest-first)', () => {
const merged = mergeUnifiedSessions({
// query() returns newest-first: the rename must win over the original
// name — an unconditional overwrite would leave the OLDEST standing.
lifecycle: [
{ sessionId: 'del-1', name: 'Renamed', mode: 'claude', ts: 2000, event: 'deleted' },
{ sessionId: 'del-1', name: 'Original', mode: 'shell', ts: 1000, event: 'created' },
],
history: [
{
sessionId: 'del-1',
workingDir: '/w',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'hello',
},
],
});
expect(merged).toHaveLength(1);
expect(merged[0].name).toBe('Renamed');
expect(merged[0].mode).toBe('claude');
});
it('lets live status win over persisted (precedence)', () => {
const merged = mergeUnifiedSessions({
persisted: [{ id: 's1', status: 'idle', name: 'Persisted Name' }],
live: [{ id: 's1', status: 'working' }],
});
expect(merged).toHaveLength(1);
expect(merged[0].status).toBe('working');
// persisted name survives because live did not provide one
expect(merged[0].name).toBe('Persisted Name');
});
it('merges mux stats onto a live item but drops a mux-only entry with no name', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 's1', status: 'working' }],
mux: [
{ sessionId: 's1', stats: { memoryMB: 42, cpuPercent: 3.5 }, remote: true },
{ sessionId: 'noise', stats: { memoryMB: 10, cpuPercent: 1 } },
],
});
expect(merged).toHaveLength(1);
const item = merged[0];
expect(item.sessionId).toBe('s1');
expect(item.stats).toEqual({ memoryMB: 42, cpuPercent: 3.5 });
expect(item.remote).toBe(true);
});
it('drops a lifecycle-only entry with no name/firstPrompt but keeps a history item with firstPrompt', () => {
const merged = mergeUnifiedSessions({
lifecycle: [{ sessionId: 'bare', event: 'created', ts: 1000 }],
history: [
{
sessionId: 'hist',
workingDir: '/w',
sizeBytes: 9000,
lastModified: '2026-01-02T00:00:00.000Z',
firstPrompt: 'do the thing',
},
],
});
const ids = merged.map((m) => m.sessionId);
expect(ids).toContain('hist');
expect(ids).not.toContain('bare');
});
it('sorts by lastActivityAt desc with undefined last', () => {
const merged = mergeUnifiedSessions({
live: [
{ id: 'a', status: 'idle', lastActivityAt: 100 },
{ id: 'b', status: 'idle', lastActivityAt: 300 },
{ id: 'c', status: 'idle' }, // no lastActivityAt → sorts last
{ id: 'd', status: 'idle', lastActivityAt: 200 },
],
});
expect(merged.map((m) => m.sessionId)).toEqual(['b', 'd', 'a', 'c']);
});
it('derives lastActivityAt from history lastModified when none better exists', () => {
const merged = mergeUnifiedSessions({
history: [{ sessionId: 'h', workingDir: '/w', sizeBytes: 5000, lastModified: '2026-01-01T00:00:00.000Z' }],
});
expect(merged).toHaveLength(1);
expect(merged[0].lastActivityAt).toBe(new Date('2026-01-01T00:00:00.000Z').getTime());
});
it('surfaces projectKey from a history input onto the merged item', () => {
const merged = mergeUnifiedSessions({
history: [
{
sessionId: 'h',
workingDir: '/w',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
projectKey: '-repo-alpha',
},
],
});
expect(merged).toHaveLength(1);
expect(merged[0].projectKey).toBe('-repo-alpha');
});
// COD-140: firstPrompt backfill — live sessions whose Codeman id does not match an
// on-disk transcript UUID still surface a first prompt (by claudeSessionId join, then
// by newest transcript in the same workingDir).
it('backfills firstPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-1', status: 'working', claudeSessionId: 'uuid-A', workingDir: '/w' }],
history: [
{
sessionId: 'uuid-A',
workingDir: '/w',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'fix the bug',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-1');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBe('fix the bug');
// The upstream unified-service alias map (COD-160/161) folds a history row keyed
// by the Claude conversation UUID into the owning live session (claudeSessionId
// join), so it does NOT surface as a separate item — the firstPrompt reaches the
// live row above rather than a duplicate uuid-A entry.
const hist = merged.find((m) => m.sessionId === 'uuid-A');
expect(hist).toBeUndefined();
});
it('falls back to the workingDir transcript when no uuid join exists (workingDir fallback)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w2' }],
history: [
{
sessionId: 'uuid-other',
workingDir: '/w2',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'borrowed prompt',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-2');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBe('borrowed prompt');
});
it('uses the newest transcript per workingDir for the fallback (newest-wins)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/w3' }],
history: [
{
sessionId: 'uuid-old',
workingDir: '/w3',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'older prompt',
},
{
sessionId: 'uuid-new',
workingDir: '/w3',
sizeBytes: 6000,
lastModified: '2026-02-01T00:00:00.000Z',
firstPrompt: 'newer prompt',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-3');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBe('newer prompt');
});
it('never overwrites a firstPrompt that already merged from the session own transcript (no overwrite)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'self-uuid', status: 'working', claudeSessionId: 'self-uuid', workingDir: '/w4' }],
history: [
// the session's own transcript (keyed by its id) — provides the real prompt
{
sessionId: 'self-uuid',
workingDir: '/w4',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'own prompt',
},
// a newer sibling transcript in the same dir that must NOT clobber it
{
sessionId: 'sibling-uuid',
workingDir: '/w4',
sizeBytes: 6000,
lastModified: '2026-03-01T00:00:00.000Z',
firstPrompt: 'sibling prompt',
},
],
});
const self = merged.find((m) => m.sessionId === 'self-uuid');
expect(self).toBeDefined();
expect(self!.firstPrompt).toBe('own prompt');
});
it('leaves firstPrompt undefined when there is no transcript at all (no transcript)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-5', status: 'working', claudeSessionId: 'uuid-none', workingDir: '/empty' }],
});
const live = merged.find((m) => m.sessionId === 'codeman-5');
expect(live).toBeDefined();
expect(live!.firstPrompt).toBeUndefined();
});
it('does NOT borrow a sibling transcript for a history-only row whose own extraction failed (no cross-contamination)', () => {
// A pure history row already got its own real scan (step 1 keys it under its
// OWN sessionId) — if that extraction genuinely failed (oversized first
// message, noise-filtered, etc.), the workingDir guess must not paper over
// it with an unrelated session's opening line. Regression: an old session
// in a shared workingDir was displaying TODAY's live session's firstPrompt
// as its own, because the guess didn't check whether this row already had
// its own (failed) attempt.
const merged = mergeUnifiedSessions({
history: [
// This session's own transcript scan found no usable prompt.
{
sessionId: 'old-uuid',
workingDir: '/shared',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: undefined,
},
// A much newer, unrelated session in the same directory.
{
sessionId: 'newer-uuid',
workingDir: '/shared',
sizeBytes: 6000,
lastModified: '2026-06-01T00:00:00.000Z',
firstPrompt: "today's real prompt",
},
],
});
const old = merged.find((m) => m.sessionId === 'old-uuid');
expect(old).toBeDefined();
expect(old!.firstPrompt).toBeUndefined();
});
it('leaves a RESUMED session blank rather than borrowing a sibling, once its own transcript is aliased in', () => {
// The exact scenario COD-140's own comment lists first: a live/persisted row
// whose claudeSessionId aliases to an on-disk transcript. Once that alias
// successfully folds the transcript's own (failed) extraction into this row
// (sources includes 'history'), it must NOT then fall through to the
// workingDir guess and borrow an unrelated sibling's prompt -- same bug as
// the plain history-only case above, but for the resumed-session path the
// backfill mechanism was actually built for.
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-resumed', status: 'working', claudeSessionId: 'resumed-uuid', workingDir: '/shared' }],
history: [
// The resumed session's OWN transcript -- aliased in via claudeSessionId,
// but its own extraction found nothing.
{
sessionId: 'resumed-uuid',
workingDir: '/shared',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: undefined,
},
// An unrelated, newer sibling in the same directory.
{
sessionId: 'sibling-uuid',
workingDir: '/shared',
sizeBytes: 6000,
lastModified: '2026-06-01T00:00:00.000Z',
firstPrompt: "unrelated sibling's prompt",
},
],
});
const resumed = merged.find((m) => m.sessionId === 'codeman-resumed');
expect(resumed).toBeDefined();
expect([...resumed!.sources].sort()).toEqual(['history', 'live']);
expect(resumed!.firstPrompt).toBeUndefined();
});
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-l1', status: 'working', claudeSessionId: 'uuid-LA', workingDir: '/wl' }],
history: [
{
sessionId: 'uuid-LA',
workingDir: '/wl',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'fix the bug',
lastPrompt: 'now ship it',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-l1');
expect(live).toBeDefined();
expect(live!.lastPrompt).toBe('now ship it');
// The upstream unified-service alias map (COD-160/161) folds the UUID-keyed
// history row into the owning live session, so lastPrompt reaches the live row
// above rather than surfacing as a separate uuid-LA entry.
const hist = merged.find((m) => m.sessionId === 'uuid-LA');
expect(hist).toBeUndefined();
});
it('falls back to the workingDir transcript for lastPrompt when no uuid join exists (workingDir fallback)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-l2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl2' }],
history: [
{
sessionId: 'uuid-other',
workingDir: '/wl2',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'borrowed first',
lastPrompt: 'borrowed last',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-l2');
expect(live).toBeDefined();
expect(live!.lastPrompt).toBe('borrowed last');
});
it('uses the newest transcript per workingDir for the lastPrompt fallback (newest-wins)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'codeman-l3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl3' }],
history: [
{
sessionId: 'uuid-old',
workingDir: '/wl3',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'older first',
lastPrompt: 'older last',
},
{
sessionId: 'uuid-new',
workingDir: '/wl3',
sizeBytes: 6000,
lastModified: '2026-02-01T00:00:00.000Z',
firstPrompt: 'newer first',
lastPrompt: 'newer last',
},
],
});
const live = merged.find((m) => m.sessionId === 'codeman-l3');
expect(live).toBeDefined();
expect(live!.lastPrompt).toBe('newer last');
});
it('never overwrites a lastPrompt that already merged from the session own transcript (no overwrite)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'self-luuid', status: 'working', claudeSessionId: 'self-luuid', workingDir: '/wl4' }],
history: [
{
sessionId: 'self-luuid',
workingDir: '/wl4',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
firstPrompt: 'own first',
lastPrompt: 'own last',
},
{
sessionId: 'sibling-uuid',
workingDir: '/wl4',
sizeBytes: 6000,
lastModified: '2026-03-01T00:00:00.000Z',
firstPrompt: 'sibling first',
lastPrompt: 'sibling last',
},
],
});
const self = merged.find((m) => m.sessionId === 'self-luuid');
expect(self).toBeDefined();
expect(self!.lastPrompt).toBe('own last');
});
});
describe('filterAndPaginate', () => {
const items: UnifiedSessionItem[] = [
{ sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' },
{ sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' },
{ sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' },
{
sessionId: 's4',
name: 'Delta',
firstPrompt: 'start the migration',
lastPrompt: 'roll back the migration',
sources: ['history'],
workingDir: '/repo/delta',
},
];
it('filters by name (case-insensitive)', () => {
const r = filterAndPaginate(items, { q: 'alpha' });
expect(r.total).toBe(1);
expect(r.sessions[0].sessionId).toBe('s1');
});
it('filters by firstPrompt and workingDir', () => {
expect(filterAndPaginate(items, { q: 'login bug' }).sessions[0].sessionId).toBe('s2');
expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3');
});
it('filters by lastPrompt (COD-145)', () => {
const r = filterAndPaginate(items, { q: 'roll back' });
expect(r.total).toBe(1);
expect(r.sessions[0].sessionId).toBe('s4');
});
it('reports total as the pre-page filtered count', () => {
const r = filterAndPaginate(items, { q: 'repo', limit: 1 });
// s1, s2, and s4 all have /repo/ workingDir
expect(r.total).toBe(3);
expect(r.sessions).toHaveLength(1);
});
it('clamps limit to a max of 500', () => {
const r = filterAndPaginate(items, { limit: 99999 });
expect(r.sessions).toHaveLength(items.length);
// clamp does not throw and returns all items (< 500)
expect(r.total).toBe(items.length);
});
it('clamps limit to a min of 1', () => {
const r = filterAndPaginate(items, { limit: 0 });
expect(r.sessions).toHaveLength(1);
});
it('paginates with disjoint pages via offset', () => {
const page1 = filterAndPaginate(items, { offset: 0, limit: 2 });
const page2 = filterAndPaginate(items, { offset: 2, limit: 2 });
expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']);
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3', 's4']);
const overlap = page1.sessions
.map((s) => s.sessionId)
.filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id));
expect(overlap).toEqual([]);
});
});