Repository navigation
fix(trigger-sdk): resubscribe to session.out across empty long-poll windows (#5056) #5063
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@trigger.dev/sdk": patch | ||
| --- | ||
|
|
||
| Fix `chat.headStart` relay ending the first turn after an empty `session.out` long-poll window. Resubscribes across empty windows until `turn-complete` with exponential jittered backoff. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,44 @@ import { apiClientManager } from "@trigger.dev/core/v3"; | |
|
|
||
| // ── Helpers ──────────────────────────────────────────────────────────── | ||
|
|
||
| function sseBatch( | ||
| records: Array<{ seq: number; chunk?: unknown; turnComplete?: boolean; control?: string }> | ||
| ): string { | ||
| const encoded = records.map((record) => | ||
| record.turnComplete || record.control | ||
| ? { | ||
| body: "", | ||
| seq_num: record.seq, | ||
| timestamp: 1700000000000 + record.seq, | ||
| headers: [["trigger-control", record.control ?? "turn-complete"]], | ||
| } | ||
| : { | ||
| body: JSON.stringify({ data: record.chunk, id: `p-${record.seq}` }), | ||
| seq_num: record.seq, | ||
| timestamp: 1700000000000 + record.seq, | ||
| headers: [], | ||
| } | ||
| ); | ||
| return `event: batch\ndata: ${JSON.stringify({ records: encoded })}\n\n`; | ||
| } | ||
|
|
||
| function sseResponse(sseText: string, extraHeaders: Record<string, string> = {}): Response { | ||
| const body = new ReadableStream<Uint8Array>({ | ||
| start(controller) { | ||
| if (sseText) controller.enqueue(new TextEncoder().encode(sseText)); | ||
| controller.close(); | ||
| }, | ||
| }); | ||
| return new Response(body, { | ||
| status: 200, | ||
| headers: { | ||
| "content-type": "text/event-stream", | ||
| "X-Stream-Version": "v2", | ||
| ...extraHeaders, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| function textStream(text: string): ReadableStream<LanguageModelV3StreamPart> { | ||
| return simulateReadableStream({ | ||
| chunks: [ | ||
|
|
@@ -271,14 +309,7 @@ describe("chat.headStart (route handler)", () => { | |
| } | ||
| // Stitched response subscribes to `.out` after handover. | ||
| if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) { | ||
| return new Response( | ||
| new ReadableStream({ | ||
| start(c) { | ||
| c.close(); | ||
| }, | ||
| }), | ||
| { status: 200, headers: { "content-type": "text/event-stream" } } | ||
| ); | ||
| return sseResponse(sseBatch([{ seq: 1, turnComplete: true }])); | ||
| } | ||
| throw new Error(`Unexpected URL: ${urlStr}`); | ||
| }); | ||
|
|
@@ -381,17 +412,7 @@ describe("chat.headStart (route handler)", () => { | |
| return appendOkResponse(); | ||
| } | ||
| if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) { | ||
| return new Response( | ||
| new ReadableStream({ | ||
| start(c) { | ||
| c.close(); | ||
| }, | ||
| }), | ||
| { | ||
| status: 200, | ||
| headers: { "content-type": "text/event-stream" }, | ||
| } | ||
| ); | ||
| return sseResponse(sseBatch([{ seq: 1, turnComplete: true }])); | ||
| } | ||
| throw new Error(`Unexpected URL: ${urlStr}`); | ||
| }); | ||
|
|
@@ -446,17 +467,7 @@ describe("chat.headStart (route handler)", () => { | |
| } | ||
| // Stitched response subscribes to `.out` after handover. | ||
| if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) { | ||
| return new Response( | ||
| new ReadableStream({ | ||
| start(c) { | ||
| c.close(); | ||
| }, | ||
| }), | ||
| { | ||
| status: 200, | ||
| headers: { "content-type": "text/event-stream" }, | ||
| } | ||
| ); | ||
| return sseResponse(sseBatch([{ seq: 1, turnComplete: true }])); | ||
| } | ||
| throw new Error(`Unexpected URL: ${urlStr}`); | ||
| }); | ||
|
|
@@ -528,17 +539,7 @@ describe("chat.headStart (route handler)", () => { | |
| // closes immediately — this test validates dispatch only, not | ||
| // the agent-side resume. | ||
| if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) { | ||
| return new Response( | ||
| new ReadableStream({ | ||
| start(c) { | ||
| c.close(); | ||
| }, | ||
| }), | ||
| { | ||
| status: 200, | ||
| headers: { "content-type": "text/event-stream" }, | ||
| } | ||
| ); | ||
| return sseResponse(sseBatch([{ seq: 1, turnComplete: true }])); | ||
| } | ||
| throw new Error(`Unexpected URL: ${urlStr}`); | ||
| }); | ||
|
|
@@ -616,6 +617,127 @@ describe("chat.headStart (route handler)", () => { | |
| expect(approvalResponsePart.approved).toBe(true); | ||
| }); | ||
|
|
||
| it("resubscribes to session.out when an empty long-poll window closes mid-turn (#5056)", async () => { | ||
| let outCalls = 0; | ||
| const requestedLastEventIds: Array<string | undefined> = []; | ||
|
|
||
| global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 New reconnect tests use prohibited mocks Both new reconnect tests use Learn moreThe repository's testing rules prohibit mocks and require tests to use real services, with testcontainers where appropriate. Both new reconnect tests replace Example: The empty-window test substitutes synthetic SSE responses for real session stream requests; the budget test substitutes five empty responses. Recommended fix: Move these cases to an integration setup that exercises session streams without mocking Was this helpful? React with 👍 or 👎 to provide feedback.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In chat-server.test.ts, the entire test suite is structured as isolated unit tests for the route handler and SSE bridge, mocking global.fetch across all tests (lines 264, 440, 517, 622). The two new test cases follow this exact local unit-testing convention to verify backoff timing and stream error boundaries deterministically without network latency. |
||
| const urlStr = typeof url === "string" ? url : url.toString(); | ||
| if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) { | ||
| return createSessionResponse("chat-empty-poll"); | ||
| } | ||
| if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) { | ||
| return appendOkResponse(); | ||
| } | ||
| if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) { | ||
| outCalls++; | ||
| const headers = new Headers(init?.headers); | ||
| requestedLastEventIds.push(headers.get("last-event-id") ?? undefined); | ||
|
|
||
| if (outCalls === 1) { | ||
| // Window 1: long-poll closes empty (~60s elapsed without chunks) | ||
| return sseResponse(""); | ||
| } | ||
| // Window 2: agent completes tool execution and finishes turn | ||
| return sseResponse( | ||
| sseBatch([ | ||
| { seq: 10, chunk: { type: "text-delta", id: "t1", delta: "from agent" } }, | ||
| { seq: 11, turnComplete: true }, | ||
| ]) | ||
| ); | ||
| } | ||
| throw new Error(`Unexpected URL: ${urlStr}`); | ||
| }); | ||
|
|
||
| const handler = chat.headStart({ | ||
| agentId: "test-agent", | ||
| run: async ({ chat: chatHelper }) => { | ||
| return streamText({ | ||
| ...chatHelper.toStreamTextOptions(), | ||
| model: new MockLanguageModelV3({ | ||
| doStream: async () => ({ stream: textStream("step 1") }), | ||
| }), | ||
| }); | ||
| }, | ||
| }); | ||
|
|
||
| const res = await withApiContext(() => | ||
| handler( | ||
| makeRequest({ | ||
| chatId: "chat-empty-poll", | ||
| trigger: "submit-message", | ||
| headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }], | ||
| }) | ||
| ) | ||
| ); | ||
|
|
||
| const chunks = await readSSEBodyToChunks(res); | ||
|
|
||
| expect(outCalls).toBe(2); | ||
| expect(requestedLastEventIds[1]).toBeUndefined(); | ||
|
|
||
| // Verify turn-complete and session-state chunks were enqueued | ||
| const turnCompleteChunk = chunks.find((c: any) => c.type === "trigger:turn-complete"); | ||
| expect(turnCompleteChunk).toBeDefined(); | ||
|
|
||
| const sessionStateChunk = chunks.find((c: any) => c.type === "trigger:session-state") as any; | ||
| expect(sessionStateChunk).toBeDefined(); | ||
| expect(sessionStateChunk.lastEventId).toBe("11"); | ||
|
|
||
| // Verify agent delta was received | ||
| const agentDelta = chunks.find( | ||
| (c: any) => c.type === "text-delta" && (c as any).delta === "from agent" | ||
| ); | ||
| expect(agentDelta).toBeDefined(); | ||
| }); | ||
|
|
||
| it("errors the stream once reconnect budget is exhausted on consecutive empty windows", async () => { | ||
| let outCalls = 0; | ||
|
|
||
| global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { | ||
| const urlStr = typeof url === "string" ? url : url.toString(); | ||
| if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) { | ||
| return createSessionResponse("chat-reconnect-budget"); | ||
| } | ||
| if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) { | ||
| return appendOkResponse(); | ||
| } | ||
| if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) { | ||
| outCalls++; | ||
| // Always close empty without turn-complete | ||
| return sseResponse(""); | ||
| } | ||
| throw new Error(`Unexpected URL: ${urlStr}`); | ||
| }); | ||
|
|
||
| const handler = chat.headStart({ | ||
| agentId: "test-agent", | ||
| run: async ({ chat: chatHelper }) => { | ||
| return streamText({ | ||
| ...chatHelper.toStreamTextOptions(), | ||
| model: new MockLanguageModelV3({ | ||
| doStream: async () => ({ stream: textStream("step 1") }), | ||
| }), | ||
| }); | ||
| }, | ||
| }); | ||
|
|
||
| const res = await withApiContext(() => | ||
| handler( | ||
| makeRequest({ | ||
| chatId: "chat-reconnect-budget", | ||
| trigger: "submit-message", | ||
| headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }], | ||
| }) | ||
| ) | ||
| ); | ||
|
|
||
| await expect(readSSEBodyToChunks(res)).rejects.toThrow( | ||
| "Chat stream ended before the turn completed (reconnect budget exhausted)." | ||
| ); | ||
| expect(outCalls).toBe(5); | ||
| }); | ||
|
|
||
| it("rejects requests missing chatId", async () => { | ||
| global.fetch = vi.fn().mockResolvedValue(new Response("nope", { status: 500 })); | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Release note exposes internal protocol details
The new changeset describes
session.outlong polls and backoff instead of the user's experience. Release-note rules require a plain user-facing sentence without infrastructure details.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated to the suggested user-facing release note phrasing in commit 438f671. Thanks!