Skip to content

Commit 26fd184

Browse files
[world-vercel] Honor hasMore flag from v4 list pagination endpoint (#2486)
1 parent f193d6e commit 26fd184

6 files changed

Lines changed: 183 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@workflow/world-vercel': patch
3+
---
4+
5+
Honor the server's explicit pagination flag when listing run events, avoiding one extra empty-page request per event-log load on replay.

‎packages/world-vercel/src/events-v4.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,74 @@ describe('getWorkflowRunEventsV4 over HTTP', () => {
132132
agent.assertNoPendingInterceptors();
133133
});
134134

135+
it('captures an explicit hasMore from the sentinel, independent of next', async () => {
136+
const origin = 'https://vercel-workflow.com';
137+
const agent = new MockAgent();
138+
agent.disableNetConnect();
139+
140+
// The regression shape: a final page still carries a trailing `next`
141+
// cursor (incremental-load resume point) but hasMore is false.
142+
const frames = Buffer.concat([
143+
encodeFrame(
144+
{
145+
eventId: 'evnt_1',
146+
runId: 'wrun_1',
147+
eventType: 'run_created',
148+
createdAt: '2026-06-10T00:00:00.000Z',
149+
eventData: {},
150+
},
151+
new Uint8Array(0)
152+
),
153+
encodeFrame(
154+
{ _end: 1, next: 'eid:last', hasMore: false },
155+
new Uint8Array(0)
156+
),
157+
]);
158+
159+
agent
160+
.get(origin)
161+
.intercept({ path: '/api/v4/runs/wrun_1/events', method: 'GET' })
162+
.reply(200, frames, {
163+
headers: { 'content-type': V4_FRAME_CONTENT_TYPE },
164+
});
165+
166+
const result = await getWorkflowRunEventsV4(
167+
'wrun_1',
168+
{},
169+
{ token: 'test-token', dispatcher: agent }
170+
);
171+
172+
expect(result.next).toBe('eid:last');
173+
expect(result.hasMore).toBe(false);
174+
});
175+
176+
it('leaves hasMore undefined for a legacy sentinel without the flag', async () => {
177+
const origin = 'https://vercel-workflow.com';
178+
const agent = new MockAgent();
179+
agent.disableNetConnect();
180+
181+
const frames = encodeFrame(
182+
{ _end: 1, next: 'cursor-2' },
183+
new Uint8Array(0)
184+
);
185+
186+
agent
187+
.get(origin)
188+
.intercept({ path: '/api/v4/runs/wrun_1/events', method: 'GET' })
189+
.reply(200, frames, {
190+
headers: { 'content-type': V4_FRAME_CONTENT_TYPE },
191+
});
192+
193+
const result = await getWorkflowRunEventsV4(
194+
'wrun_1',
195+
{},
196+
{ token: 'test-token', dispatcher: agent }
197+
);
198+
199+
expect(result.next).toBe('cursor-2');
200+
expect(result.hasMore).toBeUndefined();
201+
});
202+
135203
it('throws when the stream ends without the end sentinel (truncated response)', async () => {
136204
const origin = 'https://vercel-workflow.com';
137205
const agent = new MockAgent();

‎packages/world-vercel/src/events-v4.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* the server stores without ever decoding it.
1212
* - **GET single event**: response body is one frame.
1313
* - **LIST events**: response body is a stream of frames terminated by a
14-
* sentinel frame (meta = `{_end: 1, next?: cursor}`).
14+
* sentinel frame (meta = `{_end: 1, next?: cursor, hasMore?: boolean}`).
1515
*
1616
* Requests carry special HTTP response headers (eventId / runId / createdAt)
1717
* for client convenience, to allow metadata access without decoding the body.
@@ -388,8 +388,18 @@ export interface ListedEventV4 {
388388

389389
export interface ListEventsV4Result {
390390
events: ListedEventV4[];
391-
/** Pagination cursor — present when more pages remain. */
391+
/**
392+
* Trailing cursor. Present even on the final page — it doubles as the
393+
* resume point for incremental loads — so it is NOT a reliable "more
394+
* pages" signal on its own. Use `hasMore` for that.
395+
*/
392396
next?: string;
397+
/**
398+
* Explicit "another page of results exists" flag from the sentinel.
399+
* `undefined` against older servers that don't emit it, in which case
400+
* the caller falls back to `Boolean(next)`.
401+
*/
402+
hasMore?: boolean;
393403
}
394404

395405
/**
@@ -440,10 +450,12 @@ async function consumeListFrameStream(
440450

441451
const events: ListedEventV4[] = [];
442452
let next: string | undefined;
453+
let hasMore: boolean | undefined;
443454
let sawEndSentinel = false;
444455
for await (const frame of decodeFrames(chunks)) {
445456
if (frame.meta._end === 1) {
446457
if (typeof frame.meta.next === 'string') next = frame.meta.next;
458+
if (typeof frame.meta.hasMore === 'boolean') hasMore = frame.meta.hasMore;
447459
sawEndSentinel = true;
448460
break;
449461
}
@@ -466,7 +478,11 @@ async function consumeListFrameStream(
466478
);
467479
}
468480

469-
return { events, ...(next ? { next } : {}) };
481+
return {
482+
events,
483+
...(next ? { next } : {}),
484+
...(hasMore !== undefined ? { hasMore } : {}),
485+
};
470486
}
471487

472488
/**

‎packages/world-vercel/src/events.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

‎packages/world-vercel/src/events.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
* - GET single event returns one v4 frame: the event entity in the
2323
* frame meta, the user payload bytes in the frame body.
2424
* - LIST events returns a stream of v4 frames terminated by a sentinel
25-
* frame whose meta carries `{_end: 1, next?: cursor}`. The old
25+
* frame whose meta carries `{_end: 1, next?: cursor, hasMore?: boolean}`.
26+
* The old
2627
* per-event `/refs` round-trip is eliminated.
2728
*
2829
* Public function signatures are unchanged: storage.ts continues to
@@ -486,8 +487,15 @@ export async function getWorkflowRunEvents(
486487

487488
return {
488489
data: events,
490+
// `next` is present even on the final page (it's the incremental-load
491+
// resume cursor), so prefer the server's explicit `hasMore`. The
492+
// `Boolean(next)` fallback covers older servers that don't emit it —
493+
// at the cost of one extra empty-page request per load.
489494
cursor: result.next ?? null,
490-
hasMore: Boolean(result.next),
495+
hasMore:
496+
typeof result.hasMore === 'boolean'
497+
? result.hasMore
498+
: Boolean(result.next),
491499
} as PaginatedResponse<Event>;
492500
}
493501

‎packages/world-vercel/src/frames.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*
66
* list-response := frame* end-frame
77
* frame := u32_be(meta_len) || cbor_meta || u32_be(body_len) || body_bytes
8-
* end-frame := u32_be(meta_len) || cbor_meta {_end: 1, next?: string} || u32_be(0)
8+
* end-frame := u32_be(meta_len) || cbor_meta {_end: 1, next?: string, hasMore?: boolean} || u32_be(0)
99
*/
1010

1111
import { decode, encode } from 'cbor-x';

0 commit comments

Comments
 (0)