Skip to content

Commit df64bec

Browse files
committed
fix(sdk): chat streams reconnect when the body ends mid-turn
1 parent 08871bf commit df64bec

4 files changed

Lines changed: 159 additions & 25 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
---
5+
6+
Chat streams now reconnect when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating.

packages/core/src/v3/apiClient/runStream.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ export class SSEStreamSubscription implements StreamSubscription {
206206
private cancelledByConsumer = false;
207207
private completeNotified = false;
208208

209+
/**
210+
* True when the most recent response carried `X-Session-Settled: true` —
211+
* the server has no more records coming, so a clean end of the body is
212+
* terminal rather than the end of a long-poll window.
213+
*/
214+
sessionSettled = false;
215+
209216
constructor(
210217
private url: string,
211218
private options: {
@@ -414,6 +421,7 @@ export class SSEStreamSubscription implements StreamSubscription {
414421
}
415422

416423
const streamVersion = response.headers.get("X-Stream-Version") ?? "v1";
424+
this.sessionSettled = response.headers.get("X-Session-Settled") === "true";
417425
this.retryCount = 0; // reset on success
418426
armStall();
419427

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,90 @@ describe("TriggerChatTransport", () => {
10541054
});
10551055
});
10561056

1057+
describe("stream body ends mid-turn", () => {
1058+
it("resubscribes from the last event id when the close was not settled", async () => {
1059+
const subscribeHeaders: Headers[] = [];
1060+
global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => {
1061+
const urlStr = typeof url === "string" ? url : url.toString();
1062+
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
1063+
if (isSessionOutSubscribeUrl(urlStr)) {
1064+
subscribeHeaders.push(new Headers(init?.headers));
1065+
// First connection ends mid-turn: one chunk, no turn-complete,
1066+
// no `X-Session-Settled`.
1067+
return subscribeHeaders.length === 1
1068+
? defaultSseResponse([{ type: "text-start", id: "part-1" }])
1069+
: defaultSseResponse([
1070+
{ type: "text-delta", id: "part-1", delta: "resumed" },
1071+
{ type: "trigger:turn-complete" },
1072+
]);
1073+
}
1074+
throw new Error(`Unexpected URL: ${urlStr}`);
1075+
});
1076+
1077+
const transport = new TriggerChatTransport({
1078+
task: "my-chat-task",
1079+
accessToken: () => "pat",
1080+
sessions: { "chat-eof": { publicAccessToken: "p" } },
1081+
});
1082+
1083+
const stream = await transport.sendMessages({
1084+
trigger: "submit-message",
1085+
chatId: "chat-eof",
1086+
messageId: undefined,
1087+
messages: [createUserMessage("hi")],
1088+
abortSignal: undefined,
1089+
});
1090+
const chunks = await drainChunks(stream);
1091+
1092+
expect(subscribeHeaders).toHaveLength(2);
1093+
expect(subscribeHeaders[1]?.get("Last-Event-ID")).toBe("1");
1094+
expect(chunks).toEqual([
1095+
{ type: "text-start", id: "part-1" },
1096+
{ type: "text-delta", id: "part-1", delta: "resumed" },
1097+
]);
1098+
expect(transport.getSession("chat-eof")?.isStreaming).toBe(false);
1099+
});
1100+
1101+
it("stops and clears isStreaming when the close was settled", async () => {
1102+
let subscribeCount = 0;
1103+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1104+
const urlStr = typeof url === "string" ? url : url.toString();
1105+
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
1106+
if (isSessionOutSubscribeUrl(urlStr)) {
1107+
subscribeCount++;
1108+
const response = defaultSseResponse([{ type: "text-start", id: "part-1" }]);
1109+
const headers = new Headers(response.headers);
1110+
headers.set("X-Session-Settled", "true");
1111+
return new Response(response.body, { status: 200, headers });
1112+
}
1113+
throw new Error(`Unexpected URL: ${urlStr}`);
1114+
});
1115+
1116+
const onSessionChange = vi.fn();
1117+
const transport = new TriggerChatTransport({
1118+
task: "my-chat-task",
1119+
accessToken: () => "pat",
1120+
onSessionChange,
1121+
sessions: { "chat-settled": { publicAccessToken: "p" } },
1122+
});
1123+
1124+
const stream = await transport.sendMessages({
1125+
trigger: "submit-message",
1126+
chatId: "chat-settled",
1127+
messageId: undefined,
1128+
messages: [createUserMessage("hi")],
1129+
abortSignal: undefined,
1130+
});
1131+
await drainChunks(stream);
1132+
1133+
expect(subscribeCount).toBe(1);
1134+
expect(transport.getSession("chat-settled")?.isStreaming).toBe(false);
1135+
expect(
1136+
onSessionChange.mock.calls.some(([, session]) => session && session.isStreaming === false)
1137+
).toBe(true);
1138+
});
1139+
});
1140+
10571141
describe("multi-tab coordination", () => {
10581142
it("isReadOnly defaults to false when multiTab is disabled", () => {
10591143
const transport = new TriggerChatTransport({

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

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,6 +1759,50 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
17591759
}
17601760
};
17611761

1762+
const openWithAuthRetry = async () => {
1763+
try {
1764+
return await connectSseOnce(state.publicAccessToken);
1765+
} catch (e) {
1766+
if (!isAuthError(e)) throw e;
1767+
const fresh = await this.resolveAccessToken({ chatId });
1768+
state.publicAccessToken = fresh;
1769+
this.notifySessionChange(chatId, state);
1770+
return await connectSseOnce(fresh);
1771+
}
1772+
};
1773+
1774+
// A body that ends without a turn-complete is only terminal when the
1775+
// server says the session settled — otherwise the turn is still
1776+
// running and we lost the connection (long-poll window closed, proxy
1777+
// restarted). Resubscribe from `state.lastEventId`, bounded so a
1778+
// permanently empty stream can't spin.
1779+
const MAX_EOF_RESUBSCRIBES = 5;
1780+
let eofResubscribes = 0;
1781+
1782+
const resumeAfterEof = async () => {
1783+
while (
1784+
state.isStreaming &&
1785+
!currentSubscription?.sessionSettled &&
1786+
!combinedSignal.aborted &&
1787+
eofResubscribes < MAX_EOF_RESUBSCRIBES
1788+
) {
1789+
eofResubscribes++;
1790+
await new Promise((resolve) =>
1791+
setTimeout(resolve, Math.min(100 * 2 ** (eofResubscribes - 1), 5_000))
1792+
);
1793+
const opened = await openWithAuthRetry();
1794+
if (opened) return opened;
1795+
}
1796+
1797+
// Settled close, or the turn is gone — tell the UI instead of
1798+
// leaving it spinning on a stream nobody will finish.
1799+
if (state.isStreaming) {
1800+
state.isStreaming = false;
1801+
this.notifySessionChange(chatId, state);
1802+
}
1803+
return null;
1804+
};
1805+
17621806
try {
17631807
let reader: ReadableStreamDefaultReader<{
17641808
id: string;
@@ -1767,30 +1811,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
17671811
}>;
17681812
let primed: { id: string; chunk: unknown; timestamp: number } | undefined;
17691813

1770-
try {
1771-
const opened = await connectSseOnce(state.publicAccessToken);
1772-
if (opened === null) {
1773-
controller.close();
1774-
return;
1775-
}
1776-
reader = opened.reader;
1777-
primed = opened.primed;
1778-
} catch (e) {
1779-
if (isAuthError(e)) {
1780-
const fresh = await this.resolveAccessToken({ chatId });
1781-
state.publicAccessToken = fresh;
1782-
this.notifySessionChange(chatId, state);
1783-
const opened = await connectSseOnce(fresh);
1784-
if (opened === null) {
1785-
controller.close();
1786-
return;
1787-
}
1788-
reader = opened.reader;
1789-
primed = opened.primed;
1790-
} else {
1791-
throw e;
1792-
}
1814+
const opened = (await openWithAuthRetry()) ?? (await resumeAfterEof());
1815+
if (opened === null) {
1816+
controller.close();
1817+
return;
17931818
}
1819+
reader = opened.reader;
1820+
primed = opened.primed;
17941821

17951822
this.emitEvent({
17961823
type: "stream-connected",
@@ -1814,10 +1841,19 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
18141841
} else {
18151842
const next = await reader.read();
18161843
if (next.done) {
1817-
controller.close();
1818-
return;
1844+
const resumed = await resumeAfterEof();
1845+
if (resumed === null) {
1846+
controller.close();
1847+
return;
1848+
}
1849+
reader = resumed.reader;
1850+
primed = resumed.primed;
1851+
continue;
18191852
}
18201853
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;
18211857
}
18221858

18231859
if (combinedSignal.aborted) {

0 commit comments

Comments
 (0)