@@ -14,20 +14,25 @@ const fixture = vi.hoisted(() => ({
1414 restore : vi . fn ( ) ,
1515 restoreTail : vi . fn ( ) ,
1616 loadTurnWindow : vi . fn ( ) ,
17+ synchronize : vi . fn ( ) ,
18+ extend : vi . fn ( ) ,
1719 present : null as ( ( sessionId : string , presentation : SessionHistoryPresentation ) => void ) | null ,
1820} ) ) ;
1921vi . mock ( '../../store/FlowChatStore' , ( ) => ( { flowChatStore : {
2022 getState : ( ) => ( { activeSessionId : fixture . session ?. sessionId , sessions : new Map ( fixture . session ? [ [ fixture . session . sessionId , fixture . session ] ] : [ ] ) } ) ,
2123 retainSessionHistory : ( ) => ( ) => { } ,
2224 subscribeSelector : ( ) => ( ) => { } ,
23- getSessionHistoryViewState : ( ) => undefined ,
25+ getSessionHistoryViewState : ( ) => ( {
26+ catalog : fixture . session ?. turnCatalog ?? null , loadedRanges : [ ] , activeRange : fixture . cached ?. range ?? null ,
27+ } ) ,
28+ synchronizeSessionHistory : ( ...args : unknown [ ] ) => fixture . synchronize ( ...args ) ,
2429 reactivateSessionHistoryWindow : ( ) => fixture . cached ,
2530 restoreSessionHistoryWindow : ( ...args : unknown [ ] ) => fixture . restore ( ...args ) ,
2631 restoreSessionTailPresentation : ( ...args : unknown [ ] ) => fixture . restoreTail ( ...args ) ,
2732 hasPendingSessionHistoryCompletion : ( ) => false ,
2833 hasDeferredSessionHistoryProjection : ( ) => false ,
2934 releaseSessionHistoryCompletionAfterInitialPaint : ( ) => false ,
30- extendSessionHistoryWindow : ( ) => null ,
35+ extendSessionHistoryWindow : ( ... args : unknown [ ] ) => fixture . extend ( ... args ) ,
3136 loadSessionTurnWindow : ( ...args : unknown [ ] ) => fixture . loadTurnWindow ( ...args ) ,
3237 loadPreviousSessionTurnWindow : ( ...args : unknown [ ] ) => fixture . loadTurnWindow ( ...args ) ,
3338 activateSessionHistoryWindow : ( ) => fixture . cached ,
@@ -45,6 +50,7 @@ vi.mock('./VirtualMessageList', () => ({ VirtualMessageList: React.forwardRef((p
4550 fixture . listProps = props ;
4651 React . useImperativeHandle ( ref , ( ) => ( {
4752 scrollToTurnEnd : ( ) => true ,
53+ scrollToLatestEndPosition : ( ) => { } ,
4854 captureViewportSnapshot : ( ) => null ,
4955 clearSearchMatch : ( ) => { } ,
5056 prepareTurnNavigation : ( ) => 'pending' ,
@@ -103,6 +109,14 @@ function session(totalTurnCount = 8, historyState: Session['historyState'] = 're
103109 } ] ,
104110 } as Session ;
105111}
112+ function sessionWithCatalog ( count : number , revision : string ) : Session {
113+ return { ...session ( count ) , turnCatalog : {
114+ schemaVersion : 1 , sessionId : 'session-a' , revision, totalTurnCount : count ,
115+ complete : true , entries : Array . from ( { length : count } , ( _ , ordinal ) => ( {
116+ ordinal, storageTurnIndex : ordinal , turnId : `turn-${ ordinal } ` , previewTruncated : false ,
117+ } ) ) ,
118+ } } ;
119+ }
106120function presentation ( startOrdinal = 4 , endOrdinalExclusive = 8 ) : SessionHistoryPresentation {
107121 return {
108122 range : { startOrdinal, endOrdinalExclusive, targetTurnId : `turn-${ startOrdinal } ` , mode : 'history-window' } ,
@@ -151,6 +165,8 @@ describe('FlowChat reader restoration across history rewrites', () => {
151165 fixture . session = session ( ) ;
152166 fixture . cached = presentation ( ) ;
153167 fixture . restore . mockResolvedValue ( null ) ;
168+ fixture . extend . mockReturnValue ( null ) ;
169+ fixture . synchronize . mockResolvedValue ( undefined ) ;
154170 fixture . listProps = null ;
155171 fixture . viewportSettled = true ;
156172 fixture . present = null ;
@@ -223,6 +239,109 @@ describe('FlowChat reader restoration across history rewrites', () => {
223239 expect ( host . querySelector ( '[data-turn-id="turn-2"]' ) ) . not . toBeNull ( ) ;
224240 } ) ;
225241
242+ it . each ( [ [ 'local' , 8 ] , [ 'local' , 9 ] , [ 'ssh' , 8 ] , [ 'ssh' , 9 ] , [ 'peer-device' , 8 ] , [ 'peer-device' , 9 ] ] as const ) (
243+ 'reacquires the reader window when the %s catalog changes after a turn (total: %d)' , async ( location , count ) => {
244+ if ( location === 'peer-device' ) activateSurface ( 'peer-device' ) ;
245+ const withCatalog = ( total : number , revision : string ) : Session => ( {
246+ ...sessionWithCatalog ( total , revision ) ,
247+ ...( location === 'ssh' ? { remoteConnectionId : 'ssh-target' , remoteSshHost : 'build-host' } : { } ) ,
248+ } ) ;
249+ fixture . session = withCatalog ( 8 , 'before-turn' ) ;
250+ const window = presentation ( 3 , 7 ) ;
251+ fixture . cached = window ;
252+ stageBookmark ( window ) ;
253+ await render ( ) ;
254+ expectPresented ( ) ;
255+ // Authoritative completion changes the catalog and invalidates the
256+ // store's cached windows, without changing Session id or ready status.
257+ fixture . cached = null ;
258+ const first = deferred < SessionHistoryPresentation | null > ( ) ;
259+ fixture . restore . mockReturnValueOnce ( first . promise ) ;
260+ fixture . session = withCatalog ( count , 'after-turn' ) ;
261+ await render ( ) ;
262+ expect ( fixture . restore ) . toHaveBeenCalledWith ( 'session-a' , window . range , expect . any ( Function ) ) ;
263+ const firstOwner = fixture . restore . mock . calls [ 0 ] [ 2 ] as ( ) => boolean ;
264+ const second = deferred < SessionHistoryPresentation | null > ( ) ;
265+ fixture . restore . mockReturnValueOnce ( second . promise ) ;
266+ fixture . session = withCatalog ( count , 'corrected-turn' ) ;
267+ await render ( ) ;
268+ expect ( fixture . restore ) . toHaveBeenCalledTimes ( 2 ) ;
269+ expect ( firstOwner ( ) ) . toBe ( false ) ;
270+ await act ( async ( ) => first . reject ( new Error ( 'Retired history read failed' ) ) ) ;
271+ await act ( async ( ) => second . resolve ( window ) ) ;
272+ expectPresented ( ) ;
273+ expect ( messages ( ) . dataset . presentationMode ) . toBe ( 'history-window' ) ;
274+ expect ( host . querySelector ( '[data-turn-id="turn-3"]' ) ) . not . toBeNull ( ) ;
275+ } ,
276+ ) ;
277+
278+ it ( 'drops a retained live-tail history projection when its catalog changes' , async ( ) => {
279+ fixture . session = sessionWithCatalog ( 8 , 'before-turn' ) ;
280+ fixture . cached = presentation ( 0 , 8 ) ;
281+ stageBookmark ( fixture . cached ) ;
282+ await render ( ) ;
283+ await act ( async ( ) => { fixture . listProps ! . onRequestJumpToLatest ! ( ) ; } ) ;
284+ expect ( host . querySelector ( '[data-turn-id="turn-0"]' ) ) . not . toBeNull ( ) ;
285+ fixture . cached = null ;
286+ fixture . session = sessionWithCatalog ( 8 , 'after-turn' ) ;
287+ await render ( ) ;
288+ expectPresented ( ) ;
289+ expect ( messages ( ) . dataset . presentationMode ) . toBe ( 'tail' ) ;
290+ expect ( host . querySelector ( '[data-turn-id="turn-0"]' ) ) . toBeNull ( ) ;
291+ expect ( fixture . restore ) . not . toHaveBeenCalled ( ) ;
292+ } ) ;
293+
294+ it . each ( [ false , true ] ) ( 'retries an orphaned reader after a failed read (explicit sync: %s)' , async synchronizeHistory => {
295+ const window = presentation ( 3 , 7 ) ;
296+ fixture . cached = window ;
297+ stageBookmark ( window ) ;
298+ await render ( ) ;
299+ fixture . cached = null ;
300+ fixture . restore . mockImplementation ( async ( ) => {
301+ fixture . cached = window ;
302+ return window ;
303+ } ) ;
304+ fixture . restore . mockRejectedValueOnce ( new Error ( 'History read timed out' ) ) ;
305+ fixture . loadTurnWindow . mockResolvedValue ( { status : 'ready' , isCurrent : true } ) ;
306+ fixture . extend . mockImplementation ( ( ) => fixture . cached ? presentation ( 0 , 7 ) : null ) ;
307+ let failed : unknown ;
308+ await act ( async ( ) => {
309+ failed = await fixture . listProps ! . onHistoryWindowBoundaryIntent ! ( 'before' , { synchronizeHistory } ) ;
310+ } ) ;
311+ expect ( failed ) . toBe ( 'not-ready' ) ;
312+ expect ( fixture . loadTurnWindow ) . not . toHaveBeenCalled ( ) ;
313+ expect ( host . querySelector ( '[data-turn-id="turn-3"]' ) ) . not . toBeNull ( ) ;
314+ let outcome : unknown ;
315+ await act ( async ( ) => {
316+ outcome = await fixture . listProps ! . onHistoryWindowBoundaryIntent ! ( 'before' , { synchronizeHistory } ) ;
317+ } ) ;
318+ expect ( outcome ) . toBe ( 'applied' ) ;
319+ expect ( fixture . restore ) . toHaveBeenCalledWith ( 'session-a' , window . range , expect . any ( Function ) ) ;
320+ expect ( fixture . restore ) . toHaveBeenCalledTimes ( 2 ) ;
321+ expect ( fixture . synchronize ) . toHaveBeenCalledTimes ( synchronizeHistory ? 2 : 0 ) ;
322+ expect ( host . querySelector ( '[data-turn-id="turn-0"]' ) ) . not . toBeNull ( ) ;
323+ } ) ;
324+
325+ it ( 'rejects a late orphan recovery after changing devices' , async ( ) => {
326+ const window = presentation ( 3 , 7 ) ;
327+ fixture . cached = window ;
328+ stageBookmark ( window ) ;
329+ await render ( ) ;
330+ fixture . cached = null ;
331+ const read = deferred < SessionHistoryPresentation | null > ( ) ;
332+ fixture . restore . mockReturnValueOnce ( read . promise ) ;
333+ let outcome ! : Promise < unknown > ;
334+ await act ( async ( ) => {
335+ outcome = fixture . listProps ! . onHistoryWindowBoundaryIntent ! ( 'before' , { synchronizeHistory : true } ) ;
336+ } ) ;
337+ expect ( fixture . restore ) . toHaveBeenCalledTimes ( 1 ) ;
338+ activateSurface ( 'peer-device' ) ;
339+ await act ( async ( ) => { read . resolve ( window ) ; await outcome ; } ) ;
340+ expect ( await outcome ) . toBe ( 'cancelled' ) ;
341+ expect ( fixture . loadTurnWindow ) . not . toHaveBeenCalled ( ) ;
342+ expect ( fixture . extend ) . not . toHaveBeenCalled ( ) ;
343+ } ) ;
344+
226345 it ( 'cancels a pending cold range when a ready history rewrite removes it' , async ( ) => {
227346 fixture . cached = null ;
228347 stageBookmark ( ) ;
0 commit comments