@trigger.dev/sdk 4.5.10 → 4.5.12

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