Skip to content

Commit 4378c69

Browse files
committed
Merge remote-tracking branch 'origin/feat/dashboard-agent-flows' into feat/dashboard-agent-ui
2 parents bf82cce + dd789e2 commit 4378c69

4 files changed

Lines changed: 250 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 in the browser now reconnects 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: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,170 @@ 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+
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+
});
1219+
});
1220+
10571221
describe("multi-tab coordination", () => {
10581222
it("isReadOnly defaults to false when multiTab is disabled", () => {
10591223
const transport = new TriggerChatTransport({

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

Lines changed: 72 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1759,6 +1759,60 @@ 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+
// 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;
1803+
const opened = await openWithAuthRetry();
1804+
if (opened) return opened;
1805+
}
1806+
1807+
// Settled close, or the turn is gone — tell the UI instead of
1808+
// leaving it spinning on a stream nobody will finish.
1809+
if (state.isStreaming) {
1810+
state.isStreaming = false;
1811+
this.notifySessionChange(chatId, state);
1812+
}
1813+
return null;
1814+
};
1815+
17621816
try {
17631817
let reader: ReadableStreamDefaultReader<{
17641818
id: string;
@@ -1767,30 +1821,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
17671821
}>;
17681822
let primed: { id: string; chunk: unknown; timestamp: number } | undefined;
17691823

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-
}
1824+
const opened = (await openWithAuthRetry()) ?? (await resumeAfterEof());
1825+
if (opened === null) {
1826+
controller.close();
1827+
return;
17931828
}
1829+
reader = opened.reader;
1830+
primed = opened.primed;
17941831

17951832
this.emitEvent({
17961833
type: "stream-connected",
@@ -1814,8 +1851,14 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
18141851
} else {
18151852
const next = await reader.read();
18161853
if (next.done) {
1817-
controller.close();
1818-
return;
1854+
const resumed = await resumeAfterEof();
1855+
if (resumed === null) {
1856+
controller.close();
1857+
return;
1858+
}
1859+
reader = resumed.reader;
1860+
primed = resumed.primed;
1861+
continue;
18191862
}
18201863
value = next.value;
18211864
}
@@ -1828,6 +1871,10 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
18281871
}
18291872

18301873
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;
18311878

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

0 commit comments

Comments
 (0)