@@ -428,3 +428,83 @@ describe('getWorkflowRunEvents remoteRefBehavior mapping', () => {
428428 agent . assertNoPendingInterceptors ( ) ;
429429 } ) ;
430430} ) ;
431+
432+ /**
433+ * The v4 LIST sentinel carries a trailing `next` cursor even on the final
434+ * page (it doubles as the incremental-load resume point), so the runtime's
435+ * `while (hasMore)` replay loader must key off the server's explicit
436+ * `hasMore` — not `Boolean(next)` — to avoid one wasted empty-page request
437+ * per event-log load. Older servers omit the flag; the Boolean(next)
438+ * fallback preserves their (correct, if slower) behavior.
439+ */
440+ describe ( 'getWorkflowRunEvents hasMore mapping' , ( ) => {
441+ function mockListResponse ( agent : MockAgent , sentinelMeta : object ) {
442+ const frames = Buffer . concat ( [
443+ encodeFrame (
444+ {
445+ eventId : 'evnt_1' ,
446+ runId : 'wrun_1' ,
447+ eventType : 'run_created' ,
448+ createdAt : '2026-06-10T00:00:00.000Z' ,
449+ eventData : { } ,
450+ } ,
451+ new Uint8Array ( 0 )
452+ ) ,
453+ encodeFrame ( sentinelMeta as Record < string , unknown > , new Uint8Array ( 0 ) ) ,
454+ ] ) ;
455+ agent
456+ . get ( ORIGIN )
457+ . intercept ( {
458+ path : '/api/v4/runs/wrun_1/events' ,
459+ method : 'GET' ,
460+ // These tests use the default resolveData ('all' → resolve), which
461+ // the adapter forwards as a query param; match it so the mock fires.
462+ query : { remoteRefBehavior : 'resolve' } ,
463+ } )
464+ . reply ( 200 , frames , {
465+ headers : { 'content-type' : V4_FRAME_CONTENT_TYPE } ,
466+ } ) ;
467+ }
468+
469+ it ( 'honors an explicit hasMore:false even when a trailing cursor is present' , async ( ) => {
470+ const agent = mockAgent ( ) ;
471+ mockListResponse ( agent , { _end : 1 , next : 'eid:last' , hasMore : false } ) ;
472+
473+ const result = await getWorkflowRunEvents (
474+ { runId : 'wrun_1' } ,
475+ { token : 'test-token' , dispatcher : agent }
476+ ) ;
477+
478+ expect ( result . data ) . toHaveLength ( 1 ) ;
479+ expect ( result . hasMore ) . toBe ( false ) ;
480+ // The cursor still rides along for incremental loads.
481+ expect ( result . cursor ) . toBe ( 'eid:last' ) ;
482+ agent . assertNoPendingInterceptors ( ) ;
483+ } ) ;
484+
485+ it ( 'maps an explicit hasMore:true through' , async ( ) => {
486+ const agent = mockAgent ( ) ;
487+ mockListResponse ( agent , { _end : 1 , next : 'cursor-2' , hasMore : true } ) ;
488+
489+ const result = await getWorkflowRunEvents (
490+ { runId : 'wrun_1' } ,
491+ { token : 'test-token' , dispatcher : agent }
492+ ) ;
493+
494+ expect ( result . hasMore ) . toBe ( true ) ;
495+ expect ( result . cursor ) . toBe ( 'cursor-2' ) ;
496+ } ) ;
497+
498+ it ( 'falls back to Boolean(next) against a legacy server without the flag' , async ( ) => {
499+ const agent = mockAgent ( ) ;
500+ mockListResponse ( agent , { _end : 1 , next : 'cursor-2' } ) ;
501+
502+ const result = await getWorkflowRunEvents (
503+ { runId : 'wrun_1' } ,
504+ { token : 'test-token' , dispatcher : agent }
505+ ) ;
506+
507+ expect ( result . hasMore ) . toBe ( true ) ;
508+ expect ( result . cursor ) . toBe ( 'cursor-2' ) ;
509+ } ) ;
510+ } ) ;
0 commit comments