Skip to content

Commit 27b04fb

Browse files
committed
fix(sdk): watch-mode chat subscriptions survive quiet windows (TRI-13065)
1 parent a36b670 commit 27b04fb

4 files changed

Lines changed: 147 additions & 6 deletions

File tree

.changeset/watch-mode-keepalive.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Watch-mode chat subscriptions now stay connected across quiet periods.

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

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1218,6 +1218,122 @@ describe("TriggerChatTransport", () => {
12181218
});
12191219
});
12201220

1221+
describe("watch mode across long-poll window boundaries", () => {
1222+
function settled(response: Response): Response {
1223+
const headers = new Headers(response.headers);
1224+
headers.set("X-Session-Settled", "true");
1225+
return new Response(response.body, { status: 200, headers });
1226+
}
1227+
1228+
it("resubscribes after a completed turn and receives a later wake", async () => {
1229+
let subscribeCount = 0;
1230+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1231+
const urlStr = typeof url === "string" ? url : url.toString();
1232+
if (isSessionOutSubscribeUrl(urlStr)) {
1233+
subscribeCount++;
1234+
// Window 1: a turn completes, then the body EOFs with no
1235+
// settled header — the quiet long-poll boundary.
1236+
return subscribeCount === 1
1237+
? defaultSseResponse([
1238+
{ type: "text-delta", id: "p1", delta: "turn1" },
1239+
{ type: "trigger:turn-complete" },
1240+
])
1241+
: settled(defaultSseResponse([{ type: "text-delta", id: "p2", delta: "wake" }]));
1242+
}
1243+
throw new Error(`Unexpected URL: ${urlStr}`);
1244+
});
1245+
1246+
const transport = new TriggerChatTransport({
1247+
task: "my-chat-task",
1248+
accessToken: () => "pat",
1249+
watch: true,
1250+
sessions: { "chat-watch-eof": { publicAccessToken: "p", isStreaming: true } },
1251+
});
1252+
1253+
const stream = await transport.reconnectToStream({ chatId: "chat-watch-eof" });
1254+
const chunks = await drainChunks(stream!);
1255+
1256+
expect(subscribeCount).toBe(2);
1257+
expect(chunks).toEqual([
1258+
{ type: "text-delta", id: "p1", delta: "turn1" },
1259+
{ type: "text-delta", id: "p2", delta: "wake" },
1260+
]);
1261+
});
1262+
1263+
it("stops when the server says the session settled", async () => {
1264+
let subscribeCount = 0;
1265+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1266+
const urlStr = typeof url === "string" ? url : url.toString();
1267+
if (isSessionOutSubscribeUrl(urlStr)) {
1268+
subscribeCount++;
1269+
return settled(
1270+
defaultSseResponse([
1271+
{ type: "text-delta", id: "p1", delta: "last" },
1272+
{ type: "trigger:turn-complete" },
1273+
])
1274+
);
1275+
}
1276+
throw new Error(`Unexpected URL: ${urlStr}`);
1277+
});
1278+
1279+
const transport = new TriggerChatTransport({
1280+
task: "my-chat-task",
1281+
accessToken: () => "pat",
1282+
watch: true,
1283+
sessions: { "chat-watch-settled": { publicAccessToken: "p", isStreaming: true } },
1284+
});
1285+
1286+
const stream = await transport.reconnectToStream({ chatId: "chat-watch-settled" });
1287+
const chunks = await drainChunks(stream!);
1288+
1289+
expect(subscribeCount).toBe(1);
1290+
expect(chunks).toHaveLength(1);
1291+
expect(transport.getSession("chat-watch-settled")?.isStreaming).toBe(false);
1292+
});
1293+
1294+
it("stops promptly when aborted during backoff", async () => {
1295+
vi.useFakeTimers();
1296+
try {
1297+
let subscribeCount = 0;
1298+
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
1299+
const urlStr = typeof url === "string" ? url : url.toString();
1300+
if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse();
1301+
if (isSessionOutSubscribeUrl(urlStr)) {
1302+
subscribeCount++;
1303+
// Every window is quiet: EOF with no records, never settled.
1304+
return defaultSseResponse([]);
1305+
}
1306+
throw new Error(`Unexpected URL: ${urlStr}`);
1307+
});
1308+
1309+
const abortController = new AbortController();
1310+
const transport = new TriggerChatTransport({
1311+
task: "my-chat-task",
1312+
accessToken: () => "pat",
1313+
watch: true,
1314+
sessions: { "chat-watch-abort": { publicAccessToken: "p", isStreaming: true } },
1315+
});
1316+
1317+
const stream = await transport.reconnectToStream({
1318+
chatId: "chat-watch-abort",
1319+
abortSignal: abortController.signal,
1320+
});
1321+
const drained = drainChunks(stream!);
1322+
await vi.advanceTimersByTimeAsync(10_000);
1323+
// The budget doesn't apply in watch mode, so it is still reconnecting.
1324+
expect(subscribeCount).toBeGreaterThan(6);
1325+
1326+
const countAtAbort = subscribeCount;
1327+
abortController.abort();
1328+
await drained;
1329+
await vi.advanceTimersByTimeAsync(10_000);
1330+
expect(subscribeCount).toBe(countAtAbort);
1331+
} finally {
1332+
vi.useRealTimers();
1333+
}
1334+
});
1335+
});
1336+
12211337
describe("multi-tab coordination", () => {
12221338
it("isReadOnly defaults to false when multiTab is disabled", () => {
12231339
const transport = new TriggerChatTransport({
@@ -1488,9 +1604,18 @@ describe("TriggerChatTransport", () => {
14881604
{ type: "text-delta", id: "p2", delta: "Again" },
14891605
{ type: "trigger:turn-complete" },
14901606
];
1607+
let subscribeCount = 0;
14911608
global.fetch = vi.fn().mockImplementation(async (url: string | URL) => {
14921609
const urlStr = typeof url === "string" ? url : url.toString();
1493-
if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(turn1);
1610+
if (isSessionOutSubscribeUrl(urlStr)) {
1611+
subscribeCount++;
1612+
if (subscribeCount === 1) return defaultSseResponse(turn1);
1613+
// Watch mode reconnects past the body EOF; settle so the drain ends.
1614+
const response = defaultSseResponse([]);
1615+
const headers = new Headers(response.headers);
1616+
headers.set("X-Session-Settled", "true");
1617+
return new Response(response.body, { status: 200, headers });
1618+
}
14941619
throw new Error(`Unexpected URL: ${urlStr}`);
14951620
});
14961621

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1780,11 +1780,13 @@ export class TriggerChatTransport implements ChatTransport<UIMessage> {
17801780
let eofResubscribes = 0;
17811781

17821782
const resumeAfterEof = async () => {
1783+
// Watch mode is a standing subscription: it outlives turn-complete
1784+
// (which clears `isStreaming`) and idle windows EOF by design, so the
1785+
// give-up budget doesn't apply. Only abort or a settled session ends it.
17831786
while (
1784-
state.isStreaming &&
1787+
(this.watchMode || (state.isStreaming && eofResubscribes < MAX_EOF_RESUBSCRIBES)) &&
17851788
!currentSubscription?.sessionSettled &&
1786-
!combinedSignal.aborted &&
1787-
eofResubscribes < MAX_EOF_RESUBSCRIBES
1789+
!combinedSignal.aborted
17881790
) {
17891791
eofResubscribes++;
17901792
// Sleep, but wake immediately on abort — otherwise a stop lands

packages/trigger-sdk/test/chat-transport-events.test.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,11 +211,20 @@ describe("transport stream events", () => {
211211
``,
212212
].join("\n");
213213

214+
let subscribes = 0;
214215
const { transport, events } = makeTransport({
215216
watch: true,
216217
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: true } },
217-
fetch: async (_url, _init, ctx) =>
218-
ctx.endpoint === "in" ? jsonOk() : sseResponse(TWO_TURNS),
218+
fetch: async (_url, _init, ctx) => {
219+
if (ctx.endpoint === "in") return jsonOk();
220+
if (subscribes++ > 0) {
221+
// Watch mode reconnects past the body EOF; settle so the read ends.
222+
const settled = sseResponse("");
223+
settled.headers.set("X-Session-Settled", "true");
224+
return settled;
225+
}
226+
return sseResponse(TWO_TURNS);
227+
},
219228
});
220229

221230
const stream = await transport.reconnectToStream({ chatId: "c1" });

0 commit comments

Comments
 (0)