From 8195bcdad8df454112d8847684641ed445dc088e Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 15 Jun 2026 14:31:17 +0200 Subject: [PATCH 1/2] [world-vercel] Read live streams from v3 endpoint Point the world-vercel live-stream reader at the v3 stream endpoint. On a max-duration timeout (or mid-stream connection drop) the v3 server errors the response body rather than closing it cleanly, which is what lets the reconnecting reader resume from the next chunk instead of treating the timeout as end-of-stream. Writes, completion, and snapshot reads stay on v2. Co-Authored-By: Claude Opus 4.8 --- .changeset/stream-read-v3-reconnect.md | 5 +++++ packages/world-vercel/src/streamer.test.ts | 10 ++++++---- packages/world-vercel/src/streamer.ts | 17 ++++++++++++++++- 3 files changed, 27 insertions(+), 5 deletions(-) create mode 100644 .changeset/stream-read-v3-reconnect.md diff --git a/.changeset/stream-read-v3-reconnect.md b/.changeset/stream-read-v3-reconnect.md new file mode 100644 index 0000000000..4ce9d7eb14 --- /dev/null +++ b/.changeset/stream-read-v3-reconnect.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-vercel': patch +--- + +Read live streams from the v3 endpoint so reads transparently reconnect when the server's max-duration timeout fires, instead of silently truncating long-lived streams. diff --git a/packages/world-vercel/src/streamer.test.ts b/packages/world-vercel/src/streamer.test.ts index af44a6c755..515981b784 100644 --- a/packages/world-vercel/src/streamer.test.ts +++ b/packages/world-vercel/src/streamer.test.ts @@ -190,7 +190,7 @@ describe('streams.get', () => { vi.restoreAllMocks(); }); - it('includes runId in the fetch URL', async () => { + it('reads the live stream from the v3 endpoint (error-on-timeout)', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockImplementation( @@ -202,10 +202,12 @@ describe('streams.get', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); const url = new URL(fetchSpy.mock.calls[0][0] as string); - expect(url.pathname).toBe('/v2/runs/run-123/stream/my-stream'); + // v3, not v2: the reconnecting reader relies on the server erroring the + // body on a max-duration timeout rather than closing it cleanly. + expect(url.pathname).toBe('/v3/runs/run-123/stream/my-stream'); }); - it('passes startIndex as a query parameter', async () => { + it('passes startIndex as a query parameter on the v3 read', async () => { const fetchSpy = vi .spyOn(globalThis, 'fetch') .mockImplementation( @@ -216,7 +218,7 @@ describe('streams.get', () => { await streamer.streams.get('run-123', 'my-stream', 5); const url = new URL(fetchSpy.mock.calls[0][0] as string); - expect(url.pathname).toBe('/v2/runs/run-123/stream/my-stream'); + expect(url.pathname).toBe('/v3/runs/run-123/stream/my-stream'); expect(url.searchParams.get('startIndex')).toBe('5'); }); }); diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index b74e2ae2e9..9be08bbe9a 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -23,12 +23,27 @@ export const MAX_CHUNKS_PER_REQUEST = 1000; // (partial writes, long-lived reads), and duplex streams are incompatible // with undici's experimental H2 support. +// Writes (PUT) and stream completion use the v2 stream endpoint. function getStreamUrl(name: string, runId: string, httpConfig: HttpConfig) { return new URL( `${httpConfig.baseUrl}/v2/runs/${encodeURIComponent(runId)}/stream/${encodeURIComponent(name)}` ); } +// The live-read (GET) endpoint is versioned at v3: on a max-duration timeout +// (or a mid-stream connection drop) the server errors the response body +// instead of closing it cleanly, which is what lets the reconnecting reader +// (`createReconnectingFramedStream`) resume from the next chunk rather than +// treating the timeout as end-of-stream. Reading from v2 would silently +// truncate long-lived streams at the server's 2-minute limit. Only the live +// read is affected by the timeout — writes, completion, and snapshot reads +// (chunks/info/list) stay on v2. +function getStreamReadUrl(name: string, runId: string, httpConfig: HttpConfig) { + return new URL( + `${httpConfig.baseUrl}/v3/runs/${encodeURIComponent(runId)}/stream/${encodeURIComponent(name)}` + ); +} + function createStreamRequestError( operation: 'write' | 'close', url: URL, @@ -190,7 +205,7 @@ export function createStreamer(config?: APIConfig): Streamer { async get(runId: string, name: string, startIndex?: number) { const httpConfig = await getHttpConfig(config); - const url = getStreamUrl(name, runId, httpConfig); + const url = getStreamReadUrl(name, runId, httpConfig); if (typeof startIndex === 'number') { url.searchParams.set('startIndex', String(startIndex)); } From 7b7f9d7c684748faa05dc1cf3cc5c6a9b4849af3 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Mon, 15 Jun 2026 15:12:50 +0200 Subject: [PATCH 2/2] Apply suggestion from @VaguelySerious Signed-off-by: Peter Wielander --- .changeset/stream-read-v3-reconnect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/stream-read-v3-reconnect.md b/.changeset/stream-read-v3-reconnect.md index 4ce9d7eb14..71fd0c843c 100644 --- a/.changeset/stream-read-v3-reconnect.md +++ b/.changeset/stream-read-v3-reconnect.md @@ -2,4 +2,4 @@ '@workflow/world-vercel': patch --- -Read live streams from the v3 endpoint so reads transparently reconnect when the server's max-duration timeout fires, instead of silently truncating long-lived streams. +Use v3 endpoint for stream reads, which supports automatic transparent reconnects.