/** * A catch-up fetch is async and the active dialog can change while it is in * flight — switching dialogs is the ordinary interaction, not an edge case. * * `useChunkCatchup` already knows about staleness, but only in its `finally`: * the processing loop ran FIRST and unconditionally. Everything downstream of * it is bound to the CURRENT dialog — `onChunkReceived` is read through a ref * the adapter reassigns every render, and the seq bookkeeping refs were just * re-armed by `resetChunkTracking` for the newly-entered dialog — so a fetch * that resolved late wrote dialog A's transcript into dialog B, and pushed B's * seq cursor to A's JetStream sequence. Since the reducer drops anything at or * below `lastAppliedSeq`, and A's stream position is unrelated to B's, that can * silently discard B's own live chunks for the rest of the session. * * The same applies to the flags: finalizing `hasCompletedInitialCatchup` / * `bufferUntilInitialCatchupComplete` on a stale fetch ends the NEW dialog's * buffering window early, and draining `chunkBuffer` hands B's buffered live * deliveries to A's stale batch. */ import { describe, it, expect, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useChunkCatchup } from '../use-chunk-catchup' import type { ChunkData } from '../../types' type Deferred = { promise: Promise resolve: (chunks: ChunkData[]) => void } function deferred(): Deferred { let resolve!: (chunks: ChunkData[]) => void const promise = new Promise((r) => { resolve = r }) return { promise, resolve } } const textChunk = (text: string, streamSeq: number, sequenceId: number): ChunkData => ({ type: 'TEXT', text, streamSeq, sequenceId }) as unknown as ChunkData describe('useChunkCatchup — dialog staleness', () => { it('does not deliver a catchup that resolved after the dialog changed', async () => { const onChunkReceived = vi.fn() const pending = deferred() const fetchChunks = vi.fn((dialogId: string) => dialogId === 'A' ? pending.promise : Promise.resolve([]), ) const { result, rerender } = renderHook( ({ dialogId }: { dialogId: string }) => useChunkCatchup({ dialogId, onChunkReceived, fetchChunks }), { initialProps: { dialogId: 'A' } }, ) // Dialog A starts its catch-up… let catchup!: Promise await act(async () => { catchup = result.current.catchUpChunks() }) expect(fetchChunks).toHaveBeenCalledWith('A', expect.anything(), undefined) // …the user switches to B while A's fetch is still open… rerender({ dialogId: 'B' }) // …and only then does A's fetch land, carrying A's own transcript. await act(async () => { pending.resolve([textChunk('SECRET FROM DIALOG A', 100001, 100001)]) await catchup }) expect(onChunkReceived).not.toHaveBeenCalled() }) it('still delivers a catchup for the dialog that is current when it lands', () => { const onChunkReceived = vi.fn() const fetchChunks = vi.fn(() => Promise.resolve([textChunk('B REAL CONTENT', 50001, 50001)]), ) const { result } = renderHook(() => useChunkCatchup({ dialogId: 'B', onChunkReceived, fetchChunks }), ) return act(async () => { await result.current.catchUpChunks() }).then(() => { // Positive control: the guard must not swallow the normal path. expect(onChunkReceived).toHaveBeenCalledTimes(1) expect(onChunkReceived.mock.calls[0][0]).toMatchObject({ text: 'B REAL CONTENT' }) }) }) })