Skip to content

Commit dd789e2

Browse files
committed
fix(sdk): re-earn the reconnect budget on any record and honor abort during backoff
1 parent df64bec commit dd789e2

3 files changed

Lines changed: 98 additions & 7 deletions

File tree

.changeset/chat-stream-mid-turn-reconnect.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
"@trigger.dev/sdk": patch
44
---
55

6-
Chat streams now reconnect when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating.
6+
Chat in the browser now reconnects when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating.

packages/trigger-sdk/src/v3/chat.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1136,6 +1136,86 @@ describe("TriggerChatTransport", () => {
11361136
onSessionChange.mock.calls.some(([, session]) => session && session.isStreaming === false)
11371137
).toBe(true);
11381138
});
1139+
1140+
it("keeps streaming when every window delivers a single record", async () => {
1141+
// One record per window arrives via `primed` on the resumed connection —
1142+
// it must still re-earn the budget, otherwise a slow turn is truncated.
1143+
const WINDOWS = 8;
1144+
let subscribeCount = 0;
1145+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1146+
const urlStr = typeof url === "string" ? url : url.toString();
1147+
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
1148+
if (isSessionOutSubscribeUrl(urlStr)) {
1149+
subscribeCount++;
1150+
return subscribeCount > WINDOWS
1151+
? defaultSseResponse([{ type: "trigger:turn-complete" }])
1152+
: defaultSseResponse([
1153+
{ type: "text-delta", id: "part-1", delta: `d${subscribeCount}` },
1154+
]);
1155+
}
1156+
throw new Error(`Unexpected URL: ${urlStr}`);
1157+
});
1158+
1159+
const transport = new TriggerChatTransport({
1160+
task: "my-chat-task",
1161+
accessToken: () => "pat",
1162+
sessions: { "chat-slow": { publicAccessToken: "p" } },
1163+
});
1164+
1165+
const stream = await transport.sendMessages({
1166+
trigger: "submit-message",
1167+
chatId: "chat-slow",
1168+
messageId: undefined,
1169+
messages: [createUserMessage("hi")],
1170+
abortSignal: undefined,
1171+
});
1172+
const chunks = await drainChunks(stream);
1173+
1174+
expect(subscribeCount).toBe(WINDOWS + 1);
1175+
expect(chunks).toHaveLength(WINDOWS);
1176+
expect(transport.getSession("chat-slow")?.isStreaming).toBe(false);
1177+
});
1178+
1179+
it("gives up after a bounded number of resubscribes", async () => {
1180+
// Fake timers so the 100ms..1.6s backoffs don't cost real seconds.
1181+
vi.useFakeTimers();
1182+
try {
1183+
let subscribeCount = 0;
1184+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1185+
const urlStr = typeof url === "string" ? url : url.toString();
1186+
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
1187+
if (isSessionOutSubscribeUrl(urlStr)) {
1188+
subscribeCount++;
1189+
// Never any records, never settled — the pathological case.
1190+
return defaultSseResponse([]);
1191+
}
1192+
throw new Error(`Unexpected URL: ${urlStr}`);
1193+
});
1194+
1195+
const transport = new TriggerChatTransport({
1196+
task: "my-chat-task",
1197+
accessToken: () => "pat",
1198+
sessions: { "chat-empty": { publicAccessToken: "p" } },
1199+
});
1200+
1201+
const stream = await transport.sendMessages({
1202+
trigger: "submit-message",
1203+
chatId: "chat-empty",
1204+
messageId: undefined,
1205+
messages: [createUserMessage("hi")],
1206+
abortSignal: undefined,
1207+
});
1208+
const drained = drainChunks(stream);
1209+
await vi.advanceTimersByTimeAsync(10_000);
1210+
await drained;
1211+
1212+
// One initial connect plus the five-attempt resubscribe budget.
1213+
expect(subscribeCount).toBe(6);
1214+
expect(transport.getSession("chat-empty")?.isStreaming).toBe(false);
1215+
} finally {
1216+
vi.useRealTimers();
1217+
}
1218+
});
11391219
});
11401220

11411221
describe("multi-tab coordination", () => {

packages/trigger-sdk/src/v3/chat.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,9 +1787,19 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
17871787
eofResubscribes < MAX_EOF_RESUBSCRIBES
17881788
) {
17891789
eofResubscribes++;
1790-
await new Promise((resolve) =>
1791-
setTimeout(resolve, Math.min(100 * 2 ** (eofResubscribes - 1), 5_000))
1792-
);
1790+
// Sleep, but wake immediately on abort — otherwise a stop lands
1791+
// mid-backoff and the stream stays open for the rest of it.
1792+
await new Promise<void>((resolve) => {
1793+
let timer: ReturnType<typeof setTimeout>;
1794+
const done = () => {
1795+
clearTimeout(timer);
1796+
combinedSignal.removeEventListener("abort", done);
1797+
resolve();
1798+
};
1799+
timer = setTimeout(done, Math.min(100 * 2 ** (eofResubscribes - 1), 5_000));
1800+
combinedSignal.addEventListener("abort", done);
1801+
});
1802+
if (combinedSignal.aborted) break;
17931803
const opened = await openWithAuthRetry();
17941804
if (opened) return opened;
17951805
}
@@ -1851,9 +1861,6 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
18511861
continue;
18521862
}
18531863
value = next.value;
1854-
// A productive connection re-earns the resubscribe budget, so a
1855-
// long turn spanning many long-poll windows keeps streaming.
1856-
eofResubscribes = 0;
18571864
}
18581865

18591866
if (combinedSignal.aborted) {
@@ -1864,6 +1871,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
18641871
}
18651872

18661873
if (value.id) state.lastEventId = value.id;
1874+
// Any record — including the first of a resumed connection, which
1875+
// arrives via `primed` — re-earns the resubscribe budget, so a long
1876+
// turn spanning many long-poll windows keeps streaming.
1877+
eofResubscribes = 0;
18671878

18681879
// Trigger control record (turn-complete, upgrade-required) —
18691880
// routed by header, body is empty. Detect via the

0 commit comments

Comments
 (0)