@trigger.dev/sdk 4.5.10 → 4.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -120,6 +120,37 @@ function defaultSseResponse(chunks = sampleChunksWithTurnComplete) {
120
120
  },
121
121
  });
122
122
  }
123
+ /**
124
+ * An SSE response whose body stays open until the request signal aborts.
125
+ * Models a live subscription sitting on a quiet server.
126
+ */
127
+ function openSseResponse(signal) {
128
+ const body = new ReadableStream({
129
+ start(controller) {
130
+ const onAbort = () => {
131
+ const err = new Error("aborted");
132
+ err.name = "AbortError";
133
+ try {
134
+ controller.error(err);
135
+ }
136
+ catch {
137
+ /* already errored */
138
+ }
139
+ };
140
+ if (signal?.aborted)
141
+ onAbort();
142
+ else
143
+ signal?.addEventListener("abort", onAbort, { once: true });
144
+ },
145
+ });
146
+ return new Response(body, {
147
+ status: 200,
148
+ headers: {
149
+ "content-type": "text/event-stream",
150
+ "X-Stream-Version": "v2",
151
+ },
152
+ });
153
+ }
123
154
  function authError(status = 401) {
124
155
  return new Response(JSON.stringify({ error: "Unauthorized", name: "TriggerApiError", status }), {
125
156
  status,
@@ -912,6 +943,33 @@ describe("TriggerChatTransport", () => {
912
943
  const result = await transport.reconnectToStream({ chatId: "chat-rc" });
913
944
  expect(result).toBeNull();
914
945
  });
946
+ it("resumes in watch mode when the session is hydrated with isStreaming=false", async () => {
947
+ let subscribeCount = 0;
948
+ global.fetch = vi.fn().mockImplementation(async (url) => {
949
+ const urlStr = typeof url === "string" ? url : url.toString();
950
+ if (isSessionOutSubscribeUrl(urlStr)) {
951
+ subscribeCount++;
952
+ const response = defaultSseResponse([{ type: "text-delta", id: "p1", delta: "turn2" }]);
953
+ const headers = new Headers(response.headers);
954
+ headers.set("X-Session-Settled", "true");
955
+ return new Response(response.body, { status: 200, headers });
956
+ }
957
+ throw new Error(`Unexpected URL: ${urlStr}`);
958
+ });
959
+ const transport = new TriggerChatTransport({
960
+ task: "my-chat-task",
961
+ accessToken: () => "pat",
962
+ watch: true,
963
+ sessions: {
964
+ "chat-rc-watch": { publicAccessToken: "p", isStreaming: false },
965
+ },
966
+ });
967
+ const stream = await transport.reconnectToStream({ chatId: "chat-rc-watch" });
968
+ expect(stream).not.toBeNull();
969
+ const chunks = await drainChunks(stream);
970
+ expect(subscribeCount).toBe(1);
971
+ expect(chunks).toEqual([{ type: "text-delta", id: "p1", delta: "turn2" }]);
972
+ });
915
973
  it("opens an SSE subscription with the X-Peek-Settled header set", async () => {
916
974
  let subscribeHeaders;
917
975
  global.fetch = vi.fn().mockImplementation(async (url, init) => {
@@ -935,6 +993,574 @@ describe("TriggerChatTransport", () => {
935
993
  expect(subscribeHeaders?.get("X-Peek-Settled")).toBe("1");
936
994
  });
937
995
  });
996
+ describe("stream body ends mid-turn", () => {
997
+ it("resubscribes from the last event id when the close was not settled", async () => {
998
+ const subscribeHeaders = [];
999
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
1000
+ const urlStr = typeof url === "string" ? url : url.toString();
1001
+ if (isSessionStreamAppendUrl(urlStr))
1002
+ return defaultAppendResponse();
1003
+ if (isSessionOutSubscribeUrl(urlStr)) {
1004
+ subscribeHeaders.push(new Headers(init?.headers));
1005
+ // First connection ends mid-turn: one chunk, no turn-complete,
1006
+ // no `X-Session-Settled`.
1007
+ return subscribeHeaders.length === 1
1008
+ ? defaultSseResponse([{ type: "text-start", id: "part-1" }])
1009
+ : defaultSseResponse([
1010
+ { type: "text-delta", id: "part-1", delta: "resumed" },
1011
+ { type: "trigger:turn-complete" },
1012
+ ]);
1013
+ }
1014
+ throw new Error(`Unexpected URL: ${urlStr}`);
1015
+ });
1016
+ const transport = new TriggerChatTransport({
1017
+ task: "my-chat-task",
1018
+ accessToken: () => "pat",
1019
+ sessions: { "chat-eof": { publicAccessToken: "p" } },
1020
+ });
1021
+ const stream = await transport.sendMessages({
1022
+ trigger: "submit-message",
1023
+ chatId: "chat-eof",
1024
+ messageId: undefined,
1025
+ messages: [createUserMessage("hi")],
1026
+ abortSignal: undefined,
1027
+ });
1028
+ const chunks = await drainChunks(stream);
1029
+ expect(subscribeHeaders).toHaveLength(2);
1030
+ expect(subscribeHeaders[1]?.get("Last-Event-ID")).toBe("1");
1031
+ expect(chunks).toEqual([
1032
+ { type: "text-start", id: "part-1" },
1033
+ { type: "text-delta", id: "part-1", delta: "resumed" },
1034
+ ]);
1035
+ expect(transport.getSession("chat-eof")?.isStreaming).toBe(false);
1036
+ });
1037
+ it("stops and clears isStreaming when the close was settled", async () => {
1038
+ let subscribeCount = 0;
1039
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1040
+ const urlStr = typeof url === "string" ? url : url.toString();
1041
+ if (isSessionStreamAppendUrl(urlStr))
1042
+ return defaultAppendResponse();
1043
+ if (isSessionOutSubscribeUrl(urlStr)) {
1044
+ subscribeCount++;
1045
+ const response = defaultSseResponse([{ type: "text-start", id: "part-1" }]);
1046
+ const headers = new Headers(response.headers);
1047
+ headers.set("X-Session-Settled", "true");
1048
+ return new Response(response.body, { status: 200, headers });
1049
+ }
1050
+ throw new Error(`Unexpected URL: ${urlStr}`);
1051
+ });
1052
+ const onSessionChange = vi.fn();
1053
+ const transport = new TriggerChatTransport({
1054
+ task: "my-chat-task",
1055
+ accessToken: () => "pat",
1056
+ onSessionChange,
1057
+ sessions: { "chat-settled": { publicAccessToken: "p" } },
1058
+ });
1059
+ const stream = await transport.sendMessages({
1060
+ trigger: "submit-message",
1061
+ chatId: "chat-settled",
1062
+ messageId: undefined,
1063
+ messages: [createUserMessage("hi")],
1064
+ abortSignal: undefined,
1065
+ });
1066
+ await drainChunks(stream);
1067
+ expect(subscribeCount).toBe(1);
1068
+ expect(transport.getSession("chat-settled")?.isStreaming).toBe(false);
1069
+ expect(onSessionChange.mock.calls.some(([, session]) => session && session.isStreaming === false)).toBe(true);
1070
+ });
1071
+ it("keeps streaming when every window delivers a single record", async () => {
1072
+ // One record per window arrives via `primed` on the resumed connection —
1073
+ // it must still re-earn the budget, otherwise a slow turn is truncated.
1074
+ const WINDOWS = 8;
1075
+ let subscribeCount = 0;
1076
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1077
+ const urlStr = typeof url === "string" ? url : url.toString();
1078
+ if (isSessionStreamAppendUrl(urlStr))
1079
+ return defaultAppendResponse();
1080
+ if (isSessionOutSubscribeUrl(urlStr)) {
1081
+ subscribeCount++;
1082
+ return subscribeCount > WINDOWS
1083
+ ? defaultSseResponse([{ type: "trigger:turn-complete" }])
1084
+ : defaultSseResponse([
1085
+ { type: "text-delta", id: "part-1", delta: `d${subscribeCount}` },
1086
+ ]);
1087
+ }
1088
+ throw new Error(`Unexpected URL: ${urlStr}`);
1089
+ });
1090
+ const transport = new TriggerChatTransport({
1091
+ task: "my-chat-task",
1092
+ accessToken: () => "pat",
1093
+ sessions: { "chat-slow": { publicAccessToken: "p" } },
1094
+ });
1095
+ const stream = await transport.sendMessages({
1096
+ trigger: "submit-message",
1097
+ chatId: "chat-slow",
1098
+ messageId: undefined,
1099
+ messages: [createUserMessage("hi")],
1100
+ abortSignal: undefined,
1101
+ });
1102
+ const chunks = await drainChunks(stream);
1103
+ expect(subscribeCount).toBe(WINDOWS + 1);
1104
+ expect(chunks).toHaveLength(WINDOWS);
1105
+ expect(transport.getSession("chat-slow")?.isStreaming).toBe(false);
1106
+ });
1107
+ it("surfaces an error after the resubscribe budget is exhausted", async () => {
1108
+ // Fake timers so the 100ms..1.6s backoffs don't cost real seconds.
1109
+ vi.useFakeTimers();
1110
+ try {
1111
+ let subscribeCount = 0;
1112
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1113
+ const urlStr = typeof url === "string" ? url : url.toString();
1114
+ if (isSessionStreamAppendUrl(urlStr))
1115
+ return defaultAppendResponse();
1116
+ if (isSessionOutSubscribeUrl(urlStr)) {
1117
+ subscribeCount++;
1118
+ // Never any records, never settled — the pathological case.
1119
+ return defaultSseResponse([]);
1120
+ }
1121
+ throw new Error(`Unexpected URL: ${urlStr}`);
1122
+ });
1123
+ const transport = new TriggerChatTransport({
1124
+ task: "my-chat-task",
1125
+ accessToken: () => "pat",
1126
+ sessions: { "chat-empty": { publicAccessToken: "p" } },
1127
+ });
1128
+ const stream = await transport.sendMessages({
1129
+ trigger: "submit-message",
1130
+ chatId: "chat-empty",
1131
+ messageId: undefined,
1132
+ messages: [createUserMessage("hi")],
1133
+ abortSignal: undefined,
1134
+ });
1135
+ // A cut-off turn surfaces an error rather than reading as complete.
1136
+ // Attach the rejection assertion before advancing timers so the
1137
+ // rejection is never unhandled.
1138
+ const drained = drainChunks(stream);
1139
+ const rejects = expect(drained).rejects.toThrow(/reconnect budget exhausted/i);
1140
+ await vi.advanceTimersByTimeAsync(10_000);
1141
+ await rejects;
1142
+ // One initial connect plus the five-attempt resubscribe budget.
1143
+ expect(subscribeCount).toBe(6);
1144
+ // State is cleared before the throw, so a reload won't reopen a
1145
+ // doomed subscription.
1146
+ expect(transport.getSession("chat-empty")?.isStreaming).toBe(false);
1147
+ }
1148
+ finally {
1149
+ vi.useRealTimers();
1150
+ }
1151
+ });
1152
+ });
1153
+ describe("watch mode across long-poll window boundaries", () => {
1154
+ function settled(response) {
1155
+ const headers = new Headers(response.headers);
1156
+ headers.set("X-Session-Settled", "true");
1157
+ return new Response(response.body, { status: 200, headers });
1158
+ }
1159
+ it("resubscribes after a completed turn and receives a later wake", async () => {
1160
+ let subscribeCount = 0;
1161
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1162
+ const urlStr = typeof url === "string" ? url : url.toString();
1163
+ if (isSessionOutSubscribeUrl(urlStr)) {
1164
+ subscribeCount++;
1165
+ // Window 1: a turn completes, then the body EOFs with no
1166
+ // settled header — the quiet long-poll boundary.
1167
+ return subscribeCount === 1
1168
+ ? defaultSseResponse([
1169
+ { type: "text-delta", id: "p1", delta: "turn1" },
1170
+ { type: "trigger:turn-complete" },
1171
+ ])
1172
+ : settled(defaultSseResponse([{ type: "text-delta", id: "p2", delta: "wake" }]));
1173
+ }
1174
+ throw new Error(`Unexpected URL: ${urlStr}`);
1175
+ });
1176
+ const transport = new TriggerChatTransport({
1177
+ task: "my-chat-task",
1178
+ accessToken: () => "pat",
1179
+ watch: true,
1180
+ sessions: { "chat-watch-eof": { publicAccessToken: "p", isStreaming: true } },
1181
+ });
1182
+ const stream = await transport.reconnectToStream({ chatId: "chat-watch-eof" });
1183
+ const chunks = await drainChunks(stream);
1184
+ expect(subscribeCount).toBe(2);
1185
+ expect(chunks).toEqual([
1186
+ { type: "text-delta", id: "p1", delta: "turn1" },
1187
+ { type: "text-delta", id: "p2", delta: "wake" },
1188
+ ]);
1189
+ });
1190
+ it("does not peek-settle an idle resubscribe, so the next turn is delivered", async () => {
1191
+ // Watch mode must NOT send X-Peek-Settled between turns: a settled peek
1192
+ // while no turn is in flight closes the standing subscription and the
1193
+ // viewer never sees turn 2. This mock plays the server's peek shortcut —
1194
+ // a peek request with nothing in flight settles — to prove the transport
1195
+ // long-polls instead.
1196
+ const subscribeHeaders = [];
1197
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
1198
+ const urlStr = typeof url === "string" ? url : url.toString();
1199
+ if (isSessionOutSubscribeUrl(urlStr)) {
1200
+ subscribeHeaders.push(new Headers(init?.headers));
1201
+ const n = subscribeHeaders.length;
1202
+ if (n === 1) {
1203
+ // Turn 1 completes, then the body EOFs (no settled header).
1204
+ return defaultSseResponse([
1205
+ { type: "text-delta", id: "p1", delta: "turn1" },
1206
+ { type: "trigger:turn-complete" },
1207
+ ]);
1208
+ }
1209
+ if (n === 2) {
1210
+ // Idle resubscribe. If it peeked, the server settles and the
1211
+ // subscription would close before turn 2; a long-poll delivers it.
1212
+ if (init && new Headers(init.headers).get("X-Peek-Settled")) {
1213
+ return settled(defaultSseResponse([]));
1214
+ }
1215
+ return defaultSseResponse([
1216
+ { type: "text-delta", id: "p2", delta: "turn2" },
1217
+ { type: "trigger:turn-complete" },
1218
+ ]);
1219
+ }
1220
+ // Turn 2 done — end the watch cleanly.
1221
+ return settled(defaultSseResponse([]));
1222
+ }
1223
+ throw new Error(`Unexpected URL: ${urlStr}`);
1224
+ });
1225
+ const transport = new TriggerChatTransport({
1226
+ task: "my-chat-task",
1227
+ accessToken: () => "pat",
1228
+ watch: true,
1229
+ sessions: { "chat-watch-turn2": { publicAccessToken: "p", isStreaming: true } },
1230
+ });
1231
+ const stream = await transport.reconnectToStream({ chatId: "chat-watch-turn2" });
1232
+ const chunks = await drainChunks(stream);
1233
+ expect(subscribeHeaders[1]?.get("X-Peek-Settled")).toBeNull();
1234
+ expect(chunks).toEqual([
1235
+ { type: "text-delta", id: "p1", delta: "turn1" },
1236
+ { type: "text-delta", id: "p2", delta: "turn2" },
1237
+ ]);
1238
+ });
1239
+ it("cancelling the reader stops the resubscribe loop", async () => {
1240
+ // A consumer that stops reading without aborting must not leak the
1241
+ // resubscribe loop — the stream's cancel() aborts it.
1242
+ vi.useFakeTimers();
1243
+ try {
1244
+ let subscribeCount = 0;
1245
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1246
+ const urlStr = typeof url === "string" ? url : url.toString();
1247
+ if (isSessionOutSubscribeUrl(urlStr)) {
1248
+ subscribeCount++;
1249
+ // Quiet: EOF, no records, never settled — watch keeps resubscribing.
1250
+ return defaultSseResponse([]);
1251
+ }
1252
+ throw new Error(`Unexpected URL: ${urlStr}`);
1253
+ });
1254
+ const events = [];
1255
+ const transport = new TriggerChatTransport({
1256
+ task: "my-chat-task",
1257
+ accessToken: () => "pat",
1258
+ watch: true,
1259
+ onEvent: (e) => events.push(e),
1260
+ sessions: { "chat-watch-cancel": { publicAccessToken: "p", isStreaming: true } },
1261
+ });
1262
+ const stream = await transport.reconnectToStream({ chatId: "chat-watch-cancel" });
1263
+ const reader = stream.getReader();
1264
+ await vi.advanceTimersByTimeAsync(10_000);
1265
+ expect(subscribeCount).toBeGreaterThan(1);
1266
+ const countAtCancel = subscribeCount;
1267
+ await reader.cancel();
1268
+ await vi.advanceTimersByTimeAsync(10_000);
1269
+ expect(subscribeCount).toBe(countAtCancel);
1270
+ // A clean cancel must not surface a spurious stream-error (an
1271
+ // unguarded controller.close() after cancel would throw "Invalid
1272
+ // state" and leak it onto the telemetry channel).
1273
+ expect(events.some((e) => e.type === "stream-error")).toBe(false);
1274
+ }
1275
+ finally {
1276
+ vi.useRealTimers();
1277
+ }
1278
+ });
1279
+ it("stops when the server says the session settled", async () => {
1280
+ let subscribeCount = 0;
1281
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1282
+ const urlStr = typeof url === "string" ? url : url.toString();
1283
+ if (isSessionOutSubscribeUrl(urlStr)) {
1284
+ subscribeCount++;
1285
+ return settled(defaultSseResponse([
1286
+ { type: "text-delta", id: "p1", delta: "last" },
1287
+ { type: "trigger:turn-complete" },
1288
+ ]));
1289
+ }
1290
+ throw new Error(`Unexpected URL: ${urlStr}`);
1291
+ });
1292
+ const transport = new TriggerChatTransport({
1293
+ task: "my-chat-task",
1294
+ accessToken: () => "pat",
1295
+ watch: true,
1296
+ sessions: { "chat-watch-settled": { publicAccessToken: "p", isStreaming: true } },
1297
+ });
1298
+ const stream = await transport.reconnectToStream({ chatId: "chat-watch-settled" });
1299
+ const chunks = await drainChunks(stream);
1300
+ expect(subscribeCount).toBe(1);
1301
+ expect(chunks).toHaveLength(1);
1302
+ expect(transport.getSession("chat-watch-settled")?.isStreaming).toBe(false);
1303
+ });
1304
+ it("stops promptly when aborted during backoff", async () => {
1305
+ vi.useFakeTimers();
1306
+ try {
1307
+ let subscribeCount = 0;
1308
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1309
+ const urlStr = typeof url === "string" ? url : url.toString();
1310
+ if (isSessionStreamAppendUrl(urlStr))
1311
+ return defaultAppendResponse();
1312
+ if (isSessionOutSubscribeUrl(urlStr)) {
1313
+ subscribeCount++;
1314
+ // Every window is quiet: EOF with no records, never settled.
1315
+ return defaultSseResponse([]);
1316
+ }
1317
+ throw new Error(`Unexpected URL: ${urlStr}`);
1318
+ });
1319
+ const abortController = new AbortController();
1320
+ const transport = new TriggerChatTransport({
1321
+ task: "my-chat-task",
1322
+ accessToken: () => "pat",
1323
+ watch: true,
1324
+ sessions: { "chat-watch-abort": { publicAccessToken: "p", isStreaming: true } },
1325
+ });
1326
+ const stream = await transport.reconnectToStream({
1327
+ chatId: "chat-watch-abort",
1328
+ abortSignal: abortController.signal,
1329
+ });
1330
+ const drained = drainChunks(stream);
1331
+ await vi.advanceTimersByTimeAsync(10_000);
1332
+ // The budget doesn't apply in watch mode, so it is still reconnecting.
1333
+ expect(subscribeCount).toBeGreaterThan(6);
1334
+ const countAtAbort = subscribeCount;
1335
+ abortController.abort();
1336
+ await drained;
1337
+ await vi.advanceTimersByTimeAsync(10_000);
1338
+ expect(subscribeCount).toBe(countAtAbort);
1339
+ }
1340
+ finally {
1341
+ vi.useRealTimers();
1342
+ }
1343
+ });
1344
+ });
1345
+ describe("reconnectToStream stop-on-abort ownership (TRI-13070)", () => {
1346
+ // A quiet stream: EOF, no records, never settled — the subscription
1347
+ // stays alive (watch mode) so an abort mid-flight exercises the stop path.
1348
+ function quietWatchTransport() {
1349
+ let appendCount = 0;
1350
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1351
+ const urlStr = typeof url === "string" ? url : url.toString();
1352
+ if (isSessionStreamAppendUrl(urlStr)) {
1353
+ appendCount++;
1354
+ return defaultAppendResponse();
1355
+ }
1356
+ if (isSessionOutSubscribeUrl(urlStr))
1357
+ return defaultSseResponse([]);
1358
+ throw new Error(`Unexpected URL: ${urlStr}`);
1359
+ });
1360
+ const transport = new TriggerChatTransport({
1361
+ task: "my-chat-task",
1362
+ accessToken: () => "pat",
1363
+ watch: true,
1364
+ sessions: { "chat-own": { publicAccessToken: "p", isStreaming: true } },
1365
+ });
1366
+ return { transport, appends: () => appendCount };
1367
+ }
1368
+ it("passive subscriber aborting writes no stop chunk to .in", async () => {
1369
+ vi.useFakeTimers();
1370
+ try {
1371
+ const { transport, appends } = quietWatchTransport();
1372
+ const abort = new AbortController();
1373
+ const stream = await transport.reconnectToStream({
1374
+ chatId: "chat-own",
1375
+ abortSignal: abort.signal,
1376
+ });
1377
+ const drained = drainChunks(stream);
1378
+ await vi.advanceTimersByTimeAsync(1_000);
1379
+ abort.abort();
1380
+ await drained;
1381
+ await vi.advanceTimersByTimeAsync(1_000);
1382
+ expect(appends()).toBe(0);
1383
+ }
1384
+ finally {
1385
+ vi.useRealTimers();
1386
+ }
1387
+ });
1388
+ it("owning subscriber with stopOnAbort:true sends a stop chunk on abort", async () => {
1389
+ vi.useFakeTimers();
1390
+ try {
1391
+ const { transport, appends } = quietWatchTransport();
1392
+ const abort = new AbortController();
1393
+ const stream = await transport.reconnectToStream({
1394
+ chatId: "chat-own",
1395
+ abortSignal: abort.signal,
1396
+ stopOnAbort: true,
1397
+ });
1398
+ const drained = drainChunks(stream);
1399
+ await vi.advanceTimersByTimeAsync(1_000);
1400
+ abort.abort();
1401
+ await drained;
1402
+ await vi.advanceTimersByTimeAsync(1_000);
1403
+ expect(appends()).toBe(1);
1404
+ }
1405
+ finally {
1406
+ vi.useRealTimers();
1407
+ }
1408
+ });
1409
+ it("abortSignal presence alone (stopOnAbort unset) sends no stop", async () => {
1410
+ vi.useFakeTimers();
1411
+ try {
1412
+ const { transport, appends } = quietWatchTransport();
1413
+ const abort = new AbortController();
1414
+ const stream = await transport.reconnectToStream({
1415
+ chatId: "chat-own",
1416
+ abortSignal: abort.signal,
1417
+ });
1418
+ const drained = drainChunks(stream);
1419
+ await vi.advanceTimersByTimeAsync(1_000);
1420
+ abort.abort();
1421
+ await drained;
1422
+ await vi.advanceTimersByTimeAsync(1_000);
1423
+ expect(appends()).toBe(0);
1424
+ }
1425
+ finally {
1426
+ vi.useRealTimers();
1427
+ }
1428
+ });
1429
+ });
1430
+ describe("superseded stream teardown", () => {
1431
+ it("keeps the successor's controller registered when the aborted stream tears down", async () => {
1432
+ vi.useFakeTimers();
1433
+ try {
1434
+ let appendCount = 0;
1435
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1436
+ const urlStr = typeof url === "string" ? url : url.toString();
1437
+ if (isSessionStreamAppendUrl(urlStr)) {
1438
+ appendCount++;
1439
+ return defaultAppendResponse();
1440
+ }
1441
+ // Quiet stream: EOF, no records, never settled — watch keeps it open.
1442
+ if (isSessionOutSubscribeUrl(urlStr))
1443
+ return defaultSseResponse([]);
1444
+ throw new Error(`Unexpected URL: ${urlStr}`);
1445
+ });
1446
+ const transport = new TriggerChatTransport({
1447
+ task: "my-chat-task",
1448
+ accessToken: () => "pat",
1449
+ watch: true,
1450
+ sessions: { "chat-race": { publicAccessToken: "p", isStreaming: true } },
1451
+ });
1452
+ const send = () => transport.sendMessages({
1453
+ trigger: "submit-message",
1454
+ chatId: "chat-race",
1455
+ messageId: undefined,
1456
+ messages: [createUserMessage("hi")],
1457
+ abortSignal: undefined,
1458
+ });
1459
+ const first = drainChunks(await send());
1460
+ await vi.advanceTimersByTimeAsync(1_000);
1461
+ // Supersede: the new stream registers its controller synchronously,
1462
+ // the aborted one tears down a microtask later.
1463
+ const second = await send();
1464
+ let secondClosed = false;
1465
+ const secondDrain = drainChunks(second).then(() => {
1466
+ secondClosed = true;
1467
+ });
1468
+ await first;
1469
+ await vi.advanceTimersByTimeAsync(1_000);
1470
+ // stopGeneration posts the stop chunk either way — only the
1471
+ // closing assertion proves it found the successor to abort.
1472
+ appendCount = 0;
1473
+ expect(await transport.stopGeneration("chat-race")).toBe(true);
1474
+ await vi.advanceTimersByTimeAsync(1_000);
1475
+ expect(appendCount).toBe(1);
1476
+ expect(secondClosed).toBe(true);
1477
+ transport.dispose();
1478
+ await secondDrain;
1479
+ }
1480
+ finally {
1481
+ vi.useRealTimers();
1482
+ }
1483
+ });
1484
+ it("keeps the tab claim the successor took (multi-tab)", async () => {
1485
+ vi.useFakeTimers();
1486
+ try {
1487
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
1488
+ const urlStr = typeof url === "string" ? url : url.toString();
1489
+ if (isSessionStreamAppendUrl(urlStr))
1490
+ return defaultAppendResponse();
1491
+ // Open SSE that only ends when the subscription is aborted, so
1492
+ // the superseded stream tears down while the successor is live.
1493
+ if (isSessionOutSubscribeUrl(urlStr))
1494
+ return openSseResponse(init?.signal);
1495
+ throw new Error(`Unexpected URL: ${urlStr}`);
1496
+ });
1497
+ const transport = new TriggerChatTransport({
1498
+ task: "my-chat-task",
1499
+ accessToken: () => "pat",
1500
+ multiTab: true,
1501
+ sessions: { "chat-race-tab": { publicAccessToken: "p", isStreaming: true } },
1502
+ });
1503
+ const send = () => transport.sendMessages({
1504
+ trigger: "submit-message",
1505
+ chatId: "chat-race-tab",
1506
+ messageId: undefined,
1507
+ messages: [createUserMessage("hi")],
1508
+ abortSignal: undefined,
1509
+ });
1510
+ const first = drainChunks(await send());
1511
+ await vi.advanceTimersByTimeAsync(1_000);
1512
+ const secondDrain = drainChunks(await send());
1513
+ await first;
1514
+ await vi.advanceTimersByTimeAsync(1_000);
1515
+ // The superseded stream must not release the claim its successor
1516
+ // holds — otherwise this tab flips to read-only mid-turn.
1517
+ expect(transport.hasClaim("chat-race-tab")).toBe(true);
1518
+ transport.dispose();
1519
+ await secondDrain;
1520
+ }
1521
+ finally {
1522
+ vi.useRealTimers();
1523
+ }
1524
+ });
1525
+ it("releases the tab claim when the user stops generation (multi-tab)", async () => {
1526
+ vi.useFakeTimers();
1527
+ try {
1528
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
1529
+ const urlStr = typeof url === "string" ? url : url.toString();
1530
+ if (isSessionStreamAppendUrl(urlStr))
1531
+ return defaultAppendResponse();
1532
+ if (isSessionOutSubscribeUrl(urlStr))
1533
+ return openSseResponse(init?.signal);
1534
+ throw new Error(`Unexpected URL: ${urlStr}`);
1535
+ });
1536
+ const transport = new TriggerChatTransport({
1537
+ task: "my-chat-task",
1538
+ accessToken: () => "pat",
1539
+ multiTab: true,
1540
+ sessions: { "chat-stop-tab": { publicAccessToken: "p", isStreaming: true } },
1541
+ });
1542
+ const drain = drainChunks(await transport.sendMessages({
1543
+ trigger: "submit-message",
1544
+ chatId: "chat-stop-tab",
1545
+ messageId: undefined,
1546
+ messages: [createUserMessage("hi")],
1547
+ abortSignal: undefined,
1548
+ }));
1549
+ await vi.advanceTimersByTimeAsync(1_000);
1550
+ expect(transport.hasClaim("chat-stop-tab")).toBe(true);
1551
+ expect(await transport.stopGeneration("chat-stop-tab")).toBe(true);
1552
+ await vi.advanceTimersByTimeAsync(1_000);
1553
+ // The turn ends here with no successor stream, so the claim must be
1554
+ // freed or other tabs stay read-only until this one closes.
1555
+ expect(transport.hasClaim("chat-stop-tab")).toBe(false);
1556
+ transport.dispose();
1557
+ await drain;
1558
+ }
1559
+ finally {
1560
+ vi.useRealTimers();
1561
+ }
1562
+ });
1563
+ });
938
1564
  describe("multi-tab coordination", () => {
939
1565
  it("isReadOnly defaults to false when multiTab is disabled", () => {
940
1566
  const transport = new TriggerChatTransport({
@@ -1165,10 +1791,19 @@ describe("TriggerChatTransport", () => {
1165
1791
  { type: "text-delta", id: "p2", delta: "Again" },
1166
1792
  { type: "trigger:turn-complete" },
1167
1793
  ];
1794
+ let subscribeCount = 0;
1168
1795
  global.fetch = vi.fn().mockImplementation(async (url) => {
1169
1796
  const urlStr = typeof url === "string" ? url : url.toString();
1170
- if (isSessionOutSubscribeUrl(urlStr))
1171
- return defaultSseResponse(turn1);
1797
+ if (isSessionOutSubscribeUrl(urlStr)) {
1798
+ subscribeCount++;
1799
+ if (subscribeCount === 1)
1800
+ return defaultSseResponse(turn1);
1801
+ // Watch mode reconnects past the body EOF; settle so the drain ends.
1802
+ const response = defaultSseResponse([]);
1803
+ const headers = new Headers(response.headers);
1804
+ headers.set("X-Session-Settled", "true");
1805
+ return new Response(response.body, { status: 200, headers });
1806
+ }
1172
1807
  throw new Error(`Unexpected URL: ${urlStr}`);
1173
1808
  });
1174
1809
  const transport = new TriggerChatTransport({