@trigger.dev/sdk 0.0.0-prerelease-20260220162801 → 0.0.0-prerelease-20260304181730

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 (54) hide show
  1. package/dist/commonjs/v3/ai.d.ts +245 -2
  2. package/dist/commonjs/v3/ai.js +384 -1
  3. package/dist/commonjs/v3/ai.js.map +1 -1
  4. package/dist/commonjs/v3/auth.d.ts +4 -0
  5. package/dist/commonjs/v3/auth.js.map +1 -1
  6. package/dist/commonjs/v3/chat-constants.d.ts +10 -0
  7. package/dist/commonjs/v3/chat-constants.js +14 -0
  8. package/dist/commonjs/v3/chat-constants.js.map +1 -0
  9. package/dist/commonjs/v3/chat-react.d.ts +42 -0
  10. package/dist/commonjs/v3/chat-react.js +63 -0
  11. package/dist/commonjs/v3/chat-react.js.map +1 -0
  12. package/dist/commonjs/v3/chat.d.ts +156 -0
  13. package/dist/commonjs/v3/chat.js +270 -0
  14. package/dist/commonjs/v3/chat.js.map +1 -0
  15. package/dist/commonjs/v3/chat.test.d.ts +1 -0
  16. package/dist/commonjs/v3/chat.test.js +1304 -0
  17. package/dist/commonjs/v3/chat.test.js.map +1 -0
  18. package/dist/commonjs/v3/runs.d.ts +4 -4
  19. package/dist/commonjs/v3/shared.js +10 -9
  20. package/dist/commonjs/v3/shared.js.map +1 -1
  21. package/dist/commonjs/v3/streams.d.ts +34 -2
  22. package/dist/commonjs/v3/streams.js +166 -2
  23. package/dist/commonjs/v3/streams.js.map +1 -1
  24. package/dist/commonjs/v3/wait.d.ts +2 -9
  25. package/dist/commonjs/v3/wait.js +7 -26
  26. package/dist/commonjs/v3/wait.js.map +1 -1
  27. package/dist/commonjs/version.js +1 -1
  28. package/dist/esm/v3/ai.d.ts +245 -2
  29. package/dist/esm/v3/ai.js +385 -2
  30. package/dist/esm/v3/ai.js.map +1 -1
  31. package/dist/esm/v3/auth.d.ts +4 -0
  32. package/dist/esm/v3/auth.js.map +1 -1
  33. package/dist/esm/v3/chat-constants.d.ts +10 -0
  34. package/dist/esm/v3/chat-constants.js +11 -0
  35. package/dist/esm/v3/chat-constants.js.map +1 -0
  36. package/dist/esm/v3/chat-react.d.ts +42 -0
  37. package/dist/esm/v3/chat-react.js +60 -0
  38. package/dist/esm/v3/chat-react.js.map +1 -0
  39. package/dist/esm/v3/chat.d.ts +156 -0
  40. package/dist/esm/v3/chat.js +265 -0
  41. package/dist/esm/v3/chat.js.map +1 -0
  42. package/dist/esm/v3/chat.test.d.ts +1 -0
  43. package/dist/esm/v3/chat.test.js +1302 -0
  44. package/dist/esm/v3/chat.test.js.map +1 -0
  45. package/dist/esm/v3/shared.js +10 -9
  46. package/dist/esm/v3/shared.js.map +1 -1
  47. package/dist/esm/v3/streams.d.ts +34 -2
  48. package/dist/esm/v3/streams.js +167 -3
  49. package/dist/esm/v3/streams.js.map +1 -1
  50. package/dist/esm/v3/wait.d.ts +2 -9
  51. package/dist/esm/v3/wait.js +2 -22
  52. package/dist/esm/v3/wait.js.map +1 -1
  53. package/dist/esm/version.js +1 -1
  54. package/package.json +40 -5
@@ -0,0 +1,1302 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { TriggerChatTransport, createChatTransport } from "./chat.js";
3
+ // Helper: encode text as SSE format
4
+ function sseEncode(chunks) {
5
+ return chunks.map((chunk, i) => `id: ${i}\ndata: ${JSON.stringify(chunk)}\n\n`).join("");
6
+ }
7
+ // Helper: create a ReadableStream from SSE text
8
+ function createSSEStream(sseText) {
9
+ const encoder = new TextEncoder();
10
+ return new ReadableStream({
11
+ start(controller) {
12
+ controller.enqueue(encoder.encode(sseText));
13
+ controller.close();
14
+ },
15
+ });
16
+ }
17
+ // Helper: create test UIMessages with unique IDs
18
+ let messageIdCounter = 0;
19
+ function createUserMessage(text) {
20
+ return {
21
+ id: `msg-user-${++messageIdCounter}`,
22
+ role: "user",
23
+ parts: [{ type: "text", text }],
24
+ };
25
+ }
26
+ function createAssistantMessage(text) {
27
+ return {
28
+ id: `msg-assistant-${++messageIdCounter}`,
29
+ role: "assistant",
30
+ parts: [{ type: "text", text }],
31
+ };
32
+ }
33
+ // Sample UIMessageChunks as the AI SDK would produce
34
+ const sampleChunks = [
35
+ { type: "text-start", id: "part-1" },
36
+ { type: "text-delta", id: "part-1", delta: "Hello" },
37
+ { type: "text-delta", id: "part-1", delta: " world" },
38
+ { type: "text-delta", id: "part-1", delta: "!" },
39
+ { type: "text-end", id: "part-1" },
40
+ ];
41
+ describe("TriggerChatTransport", () => {
42
+ let originalFetch;
43
+ beforeEach(() => {
44
+ originalFetch = global.fetch;
45
+ });
46
+ afterEach(() => {
47
+ global.fetch = originalFetch;
48
+ vi.restoreAllMocks();
49
+ });
50
+ describe("constructor", () => {
51
+ it("should create transport with required options", () => {
52
+ const transport = new TriggerChatTransport({
53
+ task: "my-chat-task",
54
+ accessToken: "test-token",
55
+ });
56
+ expect(transport).toBeInstanceOf(TriggerChatTransport);
57
+ });
58
+ it("should accept optional configuration", () => {
59
+ const transport = new TriggerChatTransport({
60
+ task: "my-chat-task",
61
+ accessToken: "test-token",
62
+ baseURL: "https://custom.trigger.dev",
63
+ streamKey: "custom-stream",
64
+ headers: { "X-Custom": "value" },
65
+ });
66
+ expect(transport).toBeInstanceOf(TriggerChatTransport);
67
+ });
68
+ it("should accept a function for accessToken", () => {
69
+ let tokenCallCount = 0;
70
+ const transport = new TriggerChatTransport({
71
+ task: "my-chat-task",
72
+ accessToken: () => {
73
+ tokenCallCount++;
74
+ return `dynamic-token-${tokenCallCount}`;
75
+ },
76
+ });
77
+ expect(transport).toBeInstanceOf(TriggerChatTransport);
78
+ });
79
+ });
80
+ describe("sendMessages", () => {
81
+ it("should trigger the task and return a ReadableStream of UIMessageChunks", async () => {
82
+ const triggerRunId = "run_abc123";
83
+ const publicToken = "pub_token_xyz";
84
+ // Mock fetch to handle both the trigger request and the SSE stream request
85
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
86
+ const urlStr = typeof url === "string" ? url : url.toString();
87
+ // Handle the task trigger request
88
+ if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) {
89
+ return new Response(JSON.stringify({ id: triggerRunId }), {
90
+ status: 200,
91
+ headers: {
92
+ "content-type": "application/json",
93
+ "x-trigger-jwt": publicToken,
94
+ },
95
+ });
96
+ }
97
+ // Handle the SSE stream request
98
+ if (urlStr.includes("/realtime/v1/streams/")) {
99
+ const sseText = sseEncode(sampleChunks);
100
+ return new Response(createSSEStream(sseText), {
101
+ status: 200,
102
+ headers: {
103
+ "content-type": "text/event-stream",
104
+ "X-Stream-Version": "v1",
105
+ },
106
+ });
107
+ }
108
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
109
+ });
110
+ const transport = new TriggerChatTransport({
111
+ task: "my-chat-task",
112
+ accessToken: "test-token",
113
+ baseURL: "https://api.test.trigger.dev",
114
+ });
115
+ const messages = [createUserMessage("Hello!")];
116
+ const stream = await transport.sendMessages({
117
+ trigger: "submit-message",
118
+ chatId: "chat-1",
119
+ messageId: undefined,
120
+ messages,
121
+ abortSignal: undefined,
122
+ });
123
+ expect(stream).toBeInstanceOf(ReadableStream);
124
+ // Read all chunks from the stream
125
+ const reader = stream.getReader();
126
+ const receivedChunks = [];
127
+ while (true) {
128
+ const { done, value } = await reader.read();
129
+ if (done)
130
+ break;
131
+ receivedChunks.push(value);
132
+ }
133
+ expect(receivedChunks).toHaveLength(sampleChunks.length);
134
+ expect(receivedChunks[0]).toEqual({ type: "text-start", id: "part-1" });
135
+ expect(receivedChunks[1]).toEqual({ type: "text-delta", id: "part-1", delta: "Hello" });
136
+ expect(receivedChunks[4]).toEqual({ type: "text-end", id: "part-1" });
137
+ });
138
+ it("should send the correct payload to the trigger API", async () => {
139
+ const fetchSpy = vi.fn().mockImplementation(async (url, init) => {
140
+ const urlStr = typeof url === "string" ? url : url.toString();
141
+ if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) {
142
+ return new Response(JSON.stringify({ id: "run_test" }), {
143
+ status: 200,
144
+ headers: {
145
+ "content-type": "application/json",
146
+ "x-trigger-jwt": "pub_token",
147
+ },
148
+ });
149
+ }
150
+ if (urlStr.includes("/realtime/v1/streams/")) {
151
+ return new Response(createSSEStream(""), {
152
+ status: 200,
153
+ headers: {
154
+ "content-type": "text/event-stream",
155
+ "X-Stream-Version": "v1",
156
+ },
157
+ });
158
+ }
159
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
160
+ });
161
+ global.fetch = fetchSpy;
162
+ const transport = new TriggerChatTransport({
163
+ task: "my-chat-task",
164
+ accessToken: "test-token",
165
+ baseURL: "https://api.test.trigger.dev",
166
+ });
167
+ const messages = [createUserMessage("Hello!")];
168
+ await transport.sendMessages({
169
+ trigger: "submit-message",
170
+ chatId: "chat-123",
171
+ messageId: undefined,
172
+ messages,
173
+ abortSignal: undefined,
174
+ metadata: { custom: "data" },
175
+ });
176
+ // Verify the trigger fetch call
177
+ const triggerCall = fetchSpy.mock.calls.find((call) => (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger"));
178
+ expect(triggerCall).toBeDefined();
179
+ const triggerUrl = typeof triggerCall[0] === "string" ? triggerCall[0] : triggerCall[0].toString();
180
+ expect(triggerUrl).toContain("/api/v1/tasks/my-chat-task/trigger");
181
+ const triggerBody = JSON.parse(triggerCall[1]?.body);
182
+ const payload = triggerBody.payload;
183
+ expect(payload.messages).toEqual(messages);
184
+ expect(payload.chatId).toBe("chat-123");
185
+ expect(payload.trigger).toBe("submit-message");
186
+ expect(payload.metadata).toEqual({ custom: "data" });
187
+ });
188
+ it("should use the correct stream URL with custom streamKey", async () => {
189
+ const fetchSpy = vi.fn().mockImplementation(async (url) => {
190
+ const urlStr = typeof url === "string" ? url : url.toString();
191
+ if (urlStr.includes("/trigger")) {
192
+ return new Response(JSON.stringify({ id: "run_custom" }), {
193
+ status: 200,
194
+ headers: {
195
+ "content-type": "application/json",
196
+ "x-trigger-jwt": "token",
197
+ },
198
+ });
199
+ }
200
+ if (urlStr.includes("/realtime/v1/streams/")) {
201
+ return new Response(createSSEStream(""), {
202
+ status: 200,
203
+ headers: {
204
+ "content-type": "text/event-stream",
205
+ "X-Stream-Version": "v1",
206
+ },
207
+ });
208
+ }
209
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
210
+ });
211
+ global.fetch = fetchSpy;
212
+ const transport = new TriggerChatTransport({
213
+ task: "my-task",
214
+ accessToken: "token",
215
+ baseURL: "https://api.test.trigger.dev",
216
+ streamKey: "my-custom-stream",
217
+ });
218
+ await transport.sendMessages({
219
+ trigger: "submit-message",
220
+ chatId: "chat-1",
221
+ messageId: undefined,
222
+ messages: [createUserMessage("test")],
223
+ abortSignal: undefined,
224
+ });
225
+ // Verify the stream URL uses the custom stream key
226
+ const streamCall = fetchSpy.mock.calls.find((call) => (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/"));
227
+ expect(streamCall).toBeDefined();
228
+ const streamUrl = typeof streamCall[0] === "string" ? streamCall[0] : streamCall[0].toString();
229
+ expect(streamUrl).toContain("/realtime/v1/streams/run_custom/my-custom-stream");
230
+ });
231
+ it("should include extra headers in stream requests", async () => {
232
+ const fetchSpy = vi.fn().mockImplementation(async (url) => {
233
+ const urlStr = typeof url === "string" ? url : url.toString();
234
+ if (urlStr.includes("/trigger")) {
235
+ return new Response(JSON.stringify({ id: "run_hdrs" }), {
236
+ status: 200,
237
+ headers: {
238
+ "content-type": "application/json",
239
+ "x-trigger-jwt": "token",
240
+ },
241
+ });
242
+ }
243
+ if (urlStr.includes("/realtime/v1/streams/")) {
244
+ return new Response(createSSEStream(""), {
245
+ status: 200,
246
+ headers: {
247
+ "content-type": "text/event-stream",
248
+ "X-Stream-Version": "v1",
249
+ },
250
+ });
251
+ }
252
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
253
+ });
254
+ global.fetch = fetchSpy;
255
+ const transport = new TriggerChatTransport({
256
+ task: "my-task",
257
+ accessToken: "token",
258
+ baseURL: "https://api.test.trigger.dev",
259
+ headers: { "X-Custom-Header": "custom-value" },
260
+ });
261
+ await transport.sendMessages({
262
+ trigger: "submit-message",
263
+ chatId: "chat-1",
264
+ messageId: undefined,
265
+ messages: [createUserMessage("test")],
266
+ abortSignal: undefined,
267
+ });
268
+ // Verify the stream request includes custom headers
269
+ const streamCall = fetchSpy.mock.calls.find((call) => (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/"));
270
+ expect(streamCall).toBeDefined();
271
+ const requestHeaders = streamCall[1]?.headers;
272
+ expect(requestHeaders["X-Custom-Header"]).toBe("custom-value");
273
+ });
274
+ });
275
+ describe("reconnectToStream", () => {
276
+ it("should return null when no session exists for chatId", async () => {
277
+ const transport = new TriggerChatTransport({
278
+ task: "my-task",
279
+ accessToken: "token",
280
+ });
281
+ const result = await transport.reconnectToStream({
282
+ chatId: "nonexistent-chat",
283
+ });
284
+ expect(result).toBeNull();
285
+ });
286
+ it("should reconnect to an existing session", async () => {
287
+ const triggerRunId = "run_reconnect";
288
+ const publicToken = "pub_reconnect_token";
289
+ global.fetch = vi.fn().mockImplementation(async (url) => {
290
+ const urlStr = typeof url === "string" ? url : url.toString();
291
+ if (urlStr.includes("/trigger")) {
292
+ return new Response(JSON.stringify({ id: triggerRunId }), {
293
+ status: 200,
294
+ headers: {
295
+ "content-type": "application/json",
296
+ "x-trigger-jwt": publicToken,
297
+ },
298
+ });
299
+ }
300
+ if (urlStr.includes("/realtime/v1/streams/")) {
301
+ const chunks = [
302
+ { type: "text-start", id: "part-1" },
303
+ { type: "text-delta", id: "part-1", delta: "Reconnected!" },
304
+ { type: "text-end", id: "part-1" },
305
+ ];
306
+ return new Response(createSSEStream(sseEncode(chunks)), {
307
+ status: 200,
308
+ headers: {
309
+ "content-type": "text/event-stream",
310
+ "X-Stream-Version": "v1",
311
+ },
312
+ });
313
+ }
314
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
315
+ });
316
+ const transport = new TriggerChatTransport({
317
+ task: "my-task",
318
+ accessToken: "token",
319
+ baseURL: "https://api.test.trigger.dev",
320
+ });
321
+ // First, send messages to establish a session
322
+ await transport.sendMessages({
323
+ trigger: "submit-message",
324
+ chatId: "chat-reconnect",
325
+ messageId: undefined,
326
+ messages: [createUserMessage("Hello")],
327
+ abortSignal: undefined,
328
+ });
329
+ // Now reconnect
330
+ const stream = await transport.reconnectToStream({
331
+ chatId: "chat-reconnect",
332
+ });
333
+ expect(stream).toBeInstanceOf(ReadableStream);
334
+ // Read the stream
335
+ const reader = stream.getReader();
336
+ const receivedChunks = [];
337
+ while (true) {
338
+ const { done, value } = await reader.read();
339
+ if (done)
340
+ break;
341
+ receivedChunks.push(value);
342
+ }
343
+ expect(receivedChunks.length).toBeGreaterThan(0);
344
+ });
345
+ });
346
+ describe("createChatTransport", () => {
347
+ it("should create a TriggerChatTransport instance", () => {
348
+ const transport = createChatTransport({
349
+ task: "my-task",
350
+ accessToken: "token",
351
+ });
352
+ expect(transport).toBeInstanceOf(TriggerChatTransport);
353
+ });
354
+ it("should pass options through to the transport", () => {
355
+ const transport = createChatTransport({
356
+ task: "custom-task",
357
+ accessToken: "custom-token",
358
+ baseURL: "https://custom.example.com",
359
+ streamKey: "custom-key",
360
+ headers: { "X-Test": "value" },
361
+ });
362
+ expect(transport).toBeInstanceOf(TriggerChatTransport);
363
+ });
364
+ });
365
+ describe("publicAccessToken from trigger response", () => {
366
+ it("should use x-trigger-jwt from trigger response as the stream auth token", async () => {
367
+ const fetchSpy = vi.fn().mockImplementation(async (url, init) => {
368
+ const urlStr = typeof url === "string" ? url : url.toString();
369
+ if (urlStr.includes("/trigger")) {
370
+ // Return with x-trigger-jwt header — this public token should be
371
+ // used for the subsequent stream subscription request.
372
+ return new Response(JSON.stringify({ id: "run_pat" }), {
373
+ status: 200,
374
+ headers: {
375
+ "content-type": "application/json",
376
+ "x-trigger-jwt": "server-generated-public-token",
377
+ },
378
+ });
379
+ }
380
+ if (urlStr.includes("/realtime/v1/streams/")) {
381
+ // Verify the Authorization header uses the server-generated token
382
+ const authHeader = init?.headers?.["Authorization"];
383
+ expect(authHeader).toBe("Bearer server-generated-public-token");
384
+ const chunks = [
385
+ { type: "text-start", id: "p1" },
386
+ { type: "text-end", id: "p1" },
387
+ ];
388
+ return new Response(createSSEStream(sseEncode(chunks)), {
389
+ status: 200,
390
+ headers: {
391
+ "content-type": "text/event-stream",
392
+ "X-Stream-Version": "v1",
393
+ },
394
+ });
395
+ }
396
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
397
+ });
398
+ global.fetch = fetchSpy;
399
+ const transport = new TriggerChatTransport({
400
+ task: "my-task",
401
+ accessToken: "caller-token",
402
+ baseURL: "https://api.test.trigger.dev",
403
+ });
404
+ const stream = await transport.sendMessages({
405
+ trigger: "submit-message",
406
+ chatId: "chat-pat",
407
+ messageId: undefined,
408
+ messages: [createUserMessage("test")],
409
+ abortSignal: undefined,
410
+ });
411
+ // Consume the stream
412
+ const reader = stream.getReader();
413
+ while (true) {
414
+ const { done } = await reader.read();
415
+ if (done)
416
+ break;
417
+ }
418
+ // Verify the stream subscription used the public token, not the caller token
419
+ const streamCall = fetchSpy.mock.calls.find((call) => (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/realtime/v1/streams/"));
420
+ expect(streamCall).toBeDefined();
421
+ const streamHeaders = streamCall[1]?.headers;
422
+ expect(streamHeaders["Authorization"]).toBe("Bearer server-generated-public-token");
423
+ });
424
+ });
425
+ describe("error handling", () => {
426
+ it("should propagate trigger API errors", async () => {
427
+ global.fetch = vi.fn().mockImplementation(async (url) => {
428
+ const urlStr = typeof url === "string" ? url : url.toString();
429
+ if (urlStr.includes("/trigger")) {
430
+ return new Response(JSON.stringify({ error: "Task not found" }), {
431
+ status: 404,
432
+ headers: { "content-type": "application/json" },
433
+ });
434
+ }
435
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
436
+ });
437
+ const transport = new TriggerChatTransport({
438
+ task: "nonexistent-task",
439
+ accessToken: "token",
440
+ baseURL: "https://api.test.trigger.dev",
441
+ });
442
+ await expect(transport.sendMessages({
443
+ trigger: "submit-message",
444
+ chatId: "chat-error",
445
+ messageId: undefined,
446
+ messages: [createUserMessage("test")],
447
+ abortSignal: undefined,
448
+ })).rejects.toThrow();
449
+ });
450
+ });
451
+ describe("abort signal", () => {
452
+ it("should close the stream gracefully when aborted", async () => {
453
+ let streamResolve;
454
+ const streamWait = new Promise((resolve) => {
455
+ streamResolve = resolve;
456
+ });
457
+ global.fetch = vi.fn().mockImplementation(async (url) => {
458
+ const urlStr = typeof url === "string" ? url : url.toString();
459
+ if (urlStr.includes("/trigger")) {
460
+ return new Response(JSON.stringify({ id: "run_abort" }), {
461
+ status: 200,
462
+ headers: {
463
+ "content-type": "application/json",
464
+ "x-trigger-jwt": "token",
465
+ },
466
+ });
467
+ }
468
+ if (urlStr.includes("/realtime/v1/streams/")) {
469
+ // Create a slow stream that waits before sending data
470
+ const stream = new ReadableStream({
471
+ async start(controller) {
472
+ const encoder = new TextEncoder();
473
+ controller.enqueue(encoder.encode(`id: 0\ndata: ${JSON.stringify({ type: "text-start", id: "p1" })}\n\n`));
474
+ // Wait for the test to signal it's done
475
+ await streamWait;
476
+ controller.close();
477
+ },
478
+ });
479
+ return new Response(stream, {
480
+ status: 200,
481
+ headers: {
482
+ "content-type": "text/event-stream",
483
+ "X-Stream-Version": "v1",
484
+ },
485
+ });
486
+ }
487
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
488
+ });
489
+ const abortController = new AbortController();
490
+ const transport = new TriggerChatTransport({
491
+ task: "my-task",
492
+ accessToken: "token",
493
+ baseURL: "https://api.test.trigger.dev",
494
+ });
495
+ const stream = await transport.sendMessages({
496
+ trigger: "submit-message",
497
+ chatId: "chat-abort",
498
+ messageId: undefined,
499
+ messages: [createUserMessage("test")],
500
+ abortSignal: abortController.signal,
501
+ });
502
+ // Read the first chunk
503
+ const reader = stream.getReader();
504
+ const first = await reader.read();
505
+ expect(first.done).toBe(false);
506
+ // Abort and clean up
507
+ abortController.abort();
508
+ streamResolve?.();
509
+ // The stream should close — reading should return done
510
+ const next = await reader.read();
511
+ expect(next.done).toBe(true);
512
+ });
513
+ });
514
+ describe("multiple sessions", () => {
515
+ it("should track multiple chat sessions independently", async () => {
516
+ let callCount = 0;
517
+ global.fetch = vi.fn().mockImplementation(async (url) => {
518
+ const urlStr = typeof url === "string" ? url : url.toString();
519
+ if (urlStr.includes("/trigger")) {
520
+ callCount++;
521
+ return new Response(JSON.stringify({ id: `run_multi_${callCount}` }), {
522
+ status: 200,
523
+ headers: {
524
+ "content-type": "application/json",
525
+ "x-trigger-jwt": `token_${callCount}`,
526
+ },
527
+ });
528
+ }
529
+ if (urlStr.includes("/realtime/v1/streams/")) {
530
+ return new Response(createSSEStream(""), {
531
+ status: 200,
532
+ headers: {
533
+ "content-type": "text/event-stream",
534
+ "X-Stream-Version": "v1",
535
+ },
536
+ });
537
+ }
538
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
539
+ });
540
+ const transport = new TriggerChatTransport({
541
+ task: "my-task",
542
+ accessToken: "token",
543
+ baseURL: "https://api.test.trigger.dev",
544
+ });
545
+ // Start two independent chat sessions
546
+ await transport.sendMessages({
547
+ trigger: "submit-message",
548
+ chatId: "session-a",
549
+ messageId: undefined,
550
+ messages: [createUserMessage("Hello A")],
551
+ abortSignal: undefined,
552
+ });
553
+ await transport.sendMessages({
554
+ trigger: "submit-message",
555
+ chatId: "session-b",
556
+ messageId: undefined,
557
+ messages: [createUserMessage("Hello B")],
558
+ abortSignal: undefined,
559
+ });
560
+ // Both sessions should be independently reconnectable
561
+ const streamA = await transport.reconnectToStream({ chatId: "session-a" });
562
+ const streamB = await transport.reconnectToStream({ chatId: "session-b" });
563
+ const streamC = await transport.reconnectToStream({ chatId: "nonexistent" });
564
+ expect(streamA).toBeInstanceOf(ReadableStream);
565
+ expect(streamB).toBeInstanceOf(ReadableStream);
566
+ expect(streamC).toBeNull();
567
+ });
568
+ });
569
+ describe("dynamic accessToken", () => {
570
+ it("should call the accessToken function for each sendMessages call", async () => {
571
+ let tokenCallCount = 0;
572
+ global.fetch = vi.fn().mockImplementation(async (url) => {
573
+ const urlStr = typeof url === "string" ? url : url.toString();
574
+ if (urlStr.includes("/trigger")) {
575
+ return new Response(JSON.stringify({ id: `run_dyn_${tokenCallCount}` }), {
576
+ status: 200,
577
+ headers: {
578
+ "content-type": "application/json",
579
+ "x-trigger-jwt": "stream-token",
580
+ },
581
+ });
582
+ }
583
+ if (urlStr.includes("/realtime/v1/streams/")) {
584
+ const chunks = [
585
+ { type: "text-start", id: "p1" },
586
+ { type: "text-end", id: "p1" },
587
+ ];
588
+ return new Response(createSSEStream(sseEncode(chunks)), {
589
+ status: 200,
590
+ headers: {
591
+ "content-type": "text/event-stream",
592
+ "X-Stream-Version": "v1",
593
+ },
594
+ });
595
+ }
596
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
597
+ });
598
+ const transport = new TriggerChatTransport({
599
+ task: "my-task",
600
+ accessToken: () => {
601
+ tokenCallCount++;
602
+ return `dynamic-token-${tokenCallCount}`;
603
+ },
604
+ baseURL: "https://api.test.trigger.dev",
605
+ });
606
+ // First call — the token function should be invoked
607
+ await transport.sendMessages({
608
+ trigger: "submit-message",
609
+ chatId: "chat-dyn-1",
610
+ messageId: undefined,
611
+ messages: [createUserMessage("first")],
612
+ abortSignal: undefined,
613
+ });
614
+ const firstCount = tokenCallCount;
615
+ expect(firstCount).toBeGreaterThanOrEqual(1);
616
+ // Second call — the token function should be invoked again
617
+ await transport.sendMessages({
618
+ trigger: "submit-message",
619
+ chatId: "chat-dyn-2",
620
+ messageId: undefined,
621
+ messages: [createUserMessage("second")],
622
+ abortSignal: undefined,
623
+ });
624
+ // Token function was called at least once more
625
+ expect(tokenCallCount).toBeGreaterThan(firstCount);
626
+ });
627
+ });
628
+ describe("body merging", () => {
629
+ it("should merge ChatRequestOptions.body into the task payload", async () => {
630
+ const fetchSpy = vi.fn().mockImplementation(async (url) => {
631
+ const urlStr = typeof url === "string" ? url : url.toString();
632
+ if (urlStr.includes("/trigger")) {
633
+ return new Response(JSON.stringify({ id: "run_body" }), {
634
+ status: 200,
635
+ headers: {
636
+ "content-type": "application/json",
637
+ "x-trigger-jwt": "token",
638
+ },
639
+ });
640
+ }
641
+ if (urlStr.includes("/realtime/v1/streams/")) {
642
+ return new Response(createSSEStream(""), {
643
+ status: 200,
644
+ headers: {
645
+ "content-type": "text/event-stream",
646
+ "X-Stream-Version": "v1",
647
+ },
648
+ });
649
+ }
650
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
651
+ });
652
+ global.fetch = fetchSpy;
653
+ const transport = new TriggerChatTransport({
654
+ task: "my-task",
655
+ accessToken: "token",
656
+ baseURL: "https://api.test.trigger.dev",
657
+ });
658
+ await transport.sendMessages({
659
+ trigger: "submit-message",
660
+ chatId: "chat-body",
661
+ messageId: undefined,
662
+ messages: [createUserMessage("test")],
663
+ abortSignal: undefined,
664
+ body: { systemPrompt: "You are helpful", temperature: 0.7 },
665
+ });
666
+ const triggerCall = fetchSpy.mock.calls.find((call) => (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger"));
667
+ const triggerBody = JSON.parse(triggerCall[1]?.body);
668
+ const payload = triggerBody.payload;
669
+ // body properties should be merged into the payload
670
+ expect(payload.systemPrompt).toBe("You are helpful");
671
+ expect(payload.temperature).toBe(0.7);
672
+ // Standard fields should still be present
673
+ expect(payload.chatId).toBe("chat-body");
674
+ expect(payload.trigger).toBe("submit-message");
675
+ });
676
+ });
677
+ describe("message types", () => {
678
+ it("should handle regenerate-message trigger", async () => {
679
+ const fetchSpy = vi.fn().mockImplementation(async (url) => {
680
+ const urlStr = typeof url === "string" ? url : url.toString();
681
+ if (urlStr.includes("/trigger")) {
682
+ return new Response(JSON.stringify({ id: "run_regen" }), {
683
+ status: 200,
684
+ headers: {
685
+ "content-type": "application/json",
686
+ "x-trigger-jwt": "token",
687
+ },
688
+ });
689
+ }
690
+ if (urlStr.includes("/realtime/v1/streams/")) {
691
+ return new Response(createSSEStream(""), {
692
+ status: 200,
693
+ headers: {
694
+ "content-type": "text/event-stream",
695
+ "X-Stream-Version": "v1",
696
+ },
697
+ });
698
+ }
699
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
700
+ });
701
+ global.fetch = fetchSpy;
702
+ const transport = new TriggerChatTransport({
703
+ task: "my-task",
704
+ accessToken: "token",
705
+ baseURL: "https://api.test.trigger.dev",
706
+ });
707
+ const messages = [
708
+ createUserMessage("Hello!"),
709
+ createAssistantMessage("Hi there!"),
710
+ ];
711
+ await transport.sendMessages({
712
+ trigger: "regenerate-message",
713
+ chatId: "chat-regen",
714
+ messageId: "msg-to-regen",
715
+ messages,
716
+ abortSignal: undefined,
717
+ });
718
+ // Verify the payload includes the regenerate trigger type and messageId
719
+ const triggerCall = fetchSpy.mock.calls.find((call) => (typeof call[0] === "string" ? call[0] : call[0].toString()).includes("/trigger"));
720
+ const triggerBody = JSON.parse(triggerCall[1]?.body);
721
+ const payload = triggerBody.payload;
722
+ expect(payload.trigger).toBe("regenerate-message");
723
+ expect(payload.messageId).toBe("msg-to-regen");
724
+ });
725
+ });
726
+ describe("lastEventId tracking", () => {
727
+ it("should pass lastEventId to SSE subscription on subsequent turns", async () => {
728
+ const controlChunk = {
729
+ type: "__trigger_waitpoint_ready",
730
+ tokenId: "wp_token_eid",
731
+ publicAccessToken: "wp_access_eid",
732
+ };
733
+ let triggerCallCount = 0;
734
+ const streamFetchCalls = [];
735
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
736
+ const urlStr = typeof url === "string" ? url : url.toString();
737
+ if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) {
738
+ triggerCallCount++;
739
+ return new Response(JSON.stringify({ id: "run_eid" }), {
740
+ status: 200,
741
+ headers: {
742
+ "content-type": "application/json",
743
+ "x-trigger-jwt": "pub_token_eid",
744
+ },
745
+ });
746
+ }
747
+ if (urlStr.includes("/api/v1/waitpoints/tokens/") && urlStr.includes("/complete")) {
748
+ return new Response(JSON.stringify({ success: true }), {
749
+ status: 200,
750
+ headers: { "content-type": "application/json" },
751
+ });
752
+ }
753
+ if (urlStr.includes("/realtime/v1/streams/")) {
754
+ streamFetchCalls.push({
755
+ url: urlStr,
756
+ headers: init?.headers ?? {},
757
+ });
758
+ const chunks = [
759
+ ...sampleChunks,
760
+ { type: "finish", id: "part-1" },
761
+ controlChunk,
762
+ ];
763
+ return new Response(createSSEStream(sseEncode(chunks)), {
764
+ status: 200,
765
+ headers: {
766
+ "content-type": "text/event-stream",
767
+ "X-Stream-Version": "v1",
768
+ },
769
+ });
770
+ }
771
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
772
+ });
773
+ const transport = new TriggerChatTransport({
774
+ task: "my-task",
775
+ accessToken: "token",
776
+ baseURL: "https://api.test.trigger.dev",
777
+ });
778
+ // First message — triggers a new run
779
+ const stream1 = await transport.sendMessages({
780
+ trigger: "submit-message",
781
+ chatId: "chat-eid",
782
+ messageId: undefined,
783
+ messages: [createUserMessage("Hello")],
784
+ abortSignal: undefined,
785
+ });
786
+ const reader1 = stream1.getReader();
787
+ while (true) {
788
+ const { done } = await reader1.read();
789
+ if (done)
790
+ break;
791
+ }
792
+ // Second message — completes the waitpoint
793
+ const stream2 = await transport.sendMessages({
794
+ trigger: "submit-message",
795
+ chatId: "chat-eid",
796
+ messageId: undefined,
797
+ messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("What's up?")],
798
+ abortSignal: undefined,
799
+ });
800
+ const reader2 = stream2.getReader();
801
+ while (true) {
802
+ const { done } = await reader2.read();
803
+ if (done)
804
+ break;
805
+ }
806
+ // The second stream subscription should include a Last-Event-ID header
807
+ expect(streamFetchCalls.length).toBe(2);
808
+ const secondStreamHeaders = streamFetchCalls[1].headers;
809
+ // SSEStreamSubscription passes lastEventId as the Last-Event-ID header
810
+ expect(secondStreamHeaders["Last-Event-ID"]).toBeDefined();
811
+ });
812
+ });
813
+ describe("AbortController cleanup", () => {
814
+ it("should terminate SSE connection after intercepting control chunk", async () => {
815
+ const controlChunk = {
816
+ type: "__trigger_waitpoint_ready",
817
+ tokenId: "wp_token_abort",
818
+ publicAccessToken: "wp_access_abort",
819
+ };
820
+ let streamAborted = false;
821
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
822
+ const urlStr = typeof url === "string" ? url : url.toString();
823
+ if (urlStr.includes("/trigger")) {
824
+ return new Response(JSON.stringify({ id: "run_abort_cleanup" }), {
825
+ status: 200,
826
+ headers: {
827
+ "content-type": "application/json",
828
+ "x-trigger-jwt": "pub_token",
829
+ },
830
+ });
831
+ }
832
+ if (urlStr.includes("/realtime/v1/streams/")) {
833
+ // Track abort signal
834
+ const signal = init?.signal;
835
+ if (signal) {
836
+ signal.addEventListener("abort", () => {
837
+ streamAborted = true;
838
+ });
839
+ }
840
+ const chunks = [
841
+ ...sampleChunks,
842
+ { type: "finish", id: "part-1" },
843
+ controlChunk,
844
+ ];
845
+ return new Response(createSSEStream(sseEncode(chunks)), {
846
+ status: 200,
847
+ headers: {
848
+ "content-type": "text/event-stream",
849
+ "X-Stream-Version": "v1",
850
+ },
851
+ });
852
+ }
853
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
854
+ });
855
+ const transport = new TriggerChatTransport({
856
+ task: "my-task",
857
+ accessToken: "token",
858
+ baseURL: "https://api.test.trigger.dev",
859
+ });
860
+ const stream = await transport.sendMessages({
861
+ trigger: "submit-message",
862
+ chatId: "chat-abort-cleanup",
863
+ messageId: undefined,
864
+ messages: [createUserMessage("Hello")],
865
+ abortSignal: undefined,
866
+ });
867
+ // Consume all chunks
868
+ const reader = stream.getReader();
869
+ while (true) {
870
+ const { done } = await reader.read();
871
+ if (done)
872
+ break;
873
+ }
874
+ // The internal AbortController should have aborted the fetch
875
+ expect(streamAborted).toBe(true);
876
+ });
877
+ });
878
+ describe("async accessToken", () => {
879
+ it("should accept an async function for accessToken", async () => {
880
+ let tokenCallCount = 0;
881
+ global.fetch = vi.fn().mockImplementation(async (url) => {
882
+ const urlStr = typeof url === "string" ? url : url.toString();
883
+ if (urlStr.includes("/trigger")) {
884
+ return new Response(JSON.stringify({ id: `run_async_${tokenCallCount}` }), {
885
+ status: 200,
886
+ headers: {
887
+ "content-type": "application/json",
888
+ "x-trigger-jwt": "stream-token",
889
+ },
890
+ });
891
+ }
892
+ if (urlStr.includes("/realtime/v1/streams/")) {
893
+ const chunks = [
894
+ { type: "text-start", id: "p1" },
895
+ { type: "text-end", id: "p1" },
896
+ ];
897
+ return new Response(createSSEStream(sseEncode(chunks)), {
898
+ status: 200,
899
+ headers: {
900
+ "content-type": "text/event-stream",
901
+ "X-Stream-Version": "v1",
902
+ },
903
+ });
904
+ }
905
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
906
+ });
907
+ const transport = new TriggerChatTransport({
908
+ task: "my-task",
909
+ accessToken: async () => {
910
+ tokenCallCount++;
911
+ // Simulate async work (e.g. server action)
912
+ await new Promise((r) => setTimeout(r, 1));
913
+ return `async-token-${tokenCallCount}`;
914
+ },
915
+ baseURL: "https://api.test.trigger.dev",
916
+ });
917
+ await transport.sendMessages({
918
+ trigger: "submit-message",
919
+ chatId: "chat-async",
920
+ messageId: undefined,
921
+ messages: [createUserMessage("Hello")],
922
+ abortSignal: undefined,
923
+ });
924
+ expect(tokenCallCount).toBe(1);
925
+ });
926
+ it("should resolve async token for waitpoint completion flow", async () => {
927
+ const controlChunk = {
928
+ type: "__trigger_waitpoint_ready",
929
+ tokenId: "wp_token_async",
930
+ publicAccessToken: "wp_access_async",
931
+ };
932
+ let tokenCallCount = 0;
933
+ let completeWaitpointCalled = false;
934
+ global.fetch = vi.fn().mockImplementation(async (url) => {
935
+ const urlStr = typeof url === "string" ? url : url.toString();
936
+ if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) {
937
+ return new Response(JSON.stringify({ id: "run_async_wp" }), {
938
+ status: 200,
939
+ headers: {
940
+ "content-type": "application/json",
941
+ "x-trigger-jwt": "stream-token",
942
+ },
943
+ });
944
+ }
945
+ if (urlStr.includes("/api/v1/waitpoints/tokens/") && urlStr.includes("/complete")) {
946
+ completeWaitpointCalled = true;
947
+ return new Response(JSON.stringify({ success: true }), {
948
+ status: 200,
949
+ headers: { "content-type": "application/json" },
950
+ });
951
+ }
952
+ if (urlStr.includes("/realtime/v1/streams/")) {
953
+ const chunks = [
954
+ ...sampleChunks,
955
+ { type: "finish", id: "part-1" },
956
+ controlChunk,
957
+ ];
958
+ return new Response(createSSEStream(sseEncode(chunks)), {
959
+ status: 200,
960
+ headers: {
961
+ "content-type": "text/event-stream",
962
+ "X-Stream-Version": "v1",
963
+ },
964
+ });
965
+ }
966
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
967
+ });
968
+ const transport = new TriggerChatTransport({
969
+ task: "my-task",
970
+ accessToken: async () => {
971
+ tokenCallCount++;
972
+ await new Promise((r) => setTimeout(r, 1));
973
+ return `async-wp-token-${tokenCallCount}`;
974
+ },
975
+ baseURL: "https://api.test.trigger.dev",
976
+ });
977
+ // First message — triggers a new run (calls async token)
978
+ const stream1 = await transport.sendMessages({
979
+ trigger: "submit-message",
980
+ chatId: "chat-async-wp",
981
+ messageId: undefined,
982
+ messages: [createUserMessage("Hello")],
983
+ abortSignal: undefined,
984
+ });
985
+ const reader1 = stream1.getReader();
986
+ while (true) {
987
+ const { done } = await reader1.read();
988
+ if (done)
989
+ break;
990
+ }
991
+ const firstTokenCount = tokenCallCount;
992
+ // Second message — should complete waitpoint (does NOT call async token)
993
+ const stream2 = await transport.sendMessages({
994
+ trigger: "submit-message",
995
+ chatId: "chat-async-wp",
996
+ messageId: undefined,
997
+ messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("More")],
998
+ abortSignal: undefined,
999
+ });
1000
+ const reader2 = stream2.getReader();
1001
+ while (true) {
1002
+ const { done } = await reader2.read();
1003
+ if (done)
1004
+ break;
1005
+ }
1006
+ // Token function should NOT have been called again for the waitpoint path
1007
+ expect(tokenCallCount).toBe(firstTokenCount);
1008
+ expect(completeWaitpointCalled).toBe(true);
1009
+ });
1010
+ });
1011
+ describe("single-run mode (waitpoint loop)", () => {
1012
+ it("should store waitpoint token from control chunk and not forward it to consumer", async () => {
1013
+ const controlChunk = {
1014
+ type: "__trigger_waitpoint_ready",
1015
+ tokenId: "wp_token_123",
1016
+ publicAccessToken: "wp_access_abc",
1017
+ };
1018
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1019
+ const urlStr = typeof url === "string" ? url : url.toString();
1020
+ if (urlStr.includes("/trigger")) {
1021
+ return new Response(JSON.stringify({ id: "run_single" }), {
1022
+ status: 200,
1023
+ headers: {
1024
+ "content-type": "application/json",
1025
+ "x-trigger-jwt": "pub_token",
1026
+ },
1027
+ });
1028
+ }
1029
+ if (urlStr.includes("/realtime/v1/streams/")) {
1030
+ const chunks = [
1031
+ ...sampleChunks,
1032
+ { type: "finish", id: "part-1" },
1033
+ controlChunk,
1034
+ ];
1035
+ return new Response(createSSEStream(sseEncode(chunks)), {
1036
+ status: 200,
1037
+ headers: {
1038
+ "content-type": "text/event-stream",
1039
+ "X-Stream-Version": "v1",
1040
+ },
1041
+ });
1042
+ }
1043
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
1044
+ });
1045
+ const transport = new TriggerChatTransport({
1046
+ task: "my-task",
1047
+ accessToken: "token",
1048
+ baseURL: "https://api.test.trigger.dev",
1049
+ });
1050
+ const stream = await transport.sendMessages({
1051
+ trigger: "submit-message",
1052
+ chatId: "chat-single",
1053
+ messageId: undefined,
1054
+ messages: [createUserMessage("Hello")],
1055
+ abortSignal: undefined,
1056
+ });
1057
+ // Read all chunks — the control chunk should NOT appear
1058
+ const reader = stream.getReader();
1059
+ const receivedChunks = [];
1060
+ while (true) {
1061
+ const { done, value } = await reader.read();
1062
+ if (done)
1063
+ break;
1064
+ receivedChunks.push(value);
1065
+ }
1066
+ // All AI SDK chunks should be forwarded
1067
+ expect(receivedChunks.length).toBe(sampleChunks.length + 1); // +1 for the finish chunk
1068
+ // Control chunk should not be in the output
1069
+ expect(receivedChunks.every((c) => c.type !== "__trigger_waitpoint_ready")).toBe(true);
1070
+ });
1071
+ it("should complete waitpoint token on second message instead of triggering a new run", async () => {
1072
+ const controlChunk = {
1073
+ type: "__trigger_waitpoint_ready",
1074
+ tokenId: "wp_token_456",
1075
+ publicAccessToken: "wp_access_def",
1076
+ };
1077
+ let triggerCallCount = 0;
1078
+ let completeWaitpointCalled = false;
1079
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
1080
+ const urlStr = typeof url === "string" ? url : url.toString();
1081
+ if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) {
1082
+ triggerCallCount++;
1083
+ return new Response(JSON.stringify({ id: "run_resume" }), {
1084
+ status: 200,
1085
+ headers: {
1086
+ "content-type": "application/json",
1087
+ "x-trigger-jwt": "pub_token",
1088
+ },
1089
+ });
1090
+ }
1091
+ // Handle waitpoint token completion
1092
+ if (urlStr.includes("/api/v1/waitpoints/tokens/") && urlStr.includes("/complete")) {
1093
+ completeWaitpointCalled = true;
1094
+ return new Response(JSON.stringify({ success: true }), {
1095
+ status: 200,
1096
+ headers: { "content-type": "application/json" },
1097
+ });
1098
+ }
1099
+ if (urlStr.includes("/realtime/v1/streams/")) {
1100
+ const chunks = [
1101
+ ...sampleChunks,
1102
+ { type: "finish", id: "part-1" },
1103
+ controlChunk,
1104
+ ];
1105
+ return new Response(createSSEStream(sseEncode(chunks)), {
1106
+ status: 200,
1107
+ headers: {
1108
+ "content-type": "text/event-stream",
1109
+ "X-Stream-Version": "v1",
1110
+ },
1111
+ });
1112
+ }
1113
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
1114
+ });
1115
+ const transport = new TriggerChatTransport({
1116
+ task: "my-task",
1117
+ accessToken: "token",
1118
+ baseURL: "https://api.test.trigger.dev",
1119
+ });
1120
+ // First message — triggers a new run
1121
+ const stream1 = await transport.sendMessages({
1122
+ trigger: "submit-message",
1123
+ chatId: "chat-resume",
1124
+ messageId: undefined,
1125
+ messages: [createUserMessage("Hello")],
1126
+ abortSignal: undefined,
1127
+ });
1128
+ // Consume stream to capture the control chunk
1129
+ const reader1 = stream1.getReader();
1130
+ while (true) {
1131
+ const { done } = await reader1.read();
1132
+ if (done)
1133
+ break;
1134
+ }
1135
+ expect(triggerCallCount).toBe(1);
1136
+ // Second message — should complete the waitpoint instead of triggering
1137
+ const stream2 = await transport.sendMessages({
1138
+ trigger: "submit-message",
1139
+ chatId: "chat-resume",
1140
+ messageId: undefined,
1141
+ messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("How are you?")],
1142
+ abortSignal: undefined,
1143
+ });
1144
+ // Consume second stream
1145
+ const reader2 = stream2.getReader();
1146
+ while (true) {
1147
+ const { done } = await reader2.read();
1148
+ if (done)
1149
+ break;
1150
+ }
1151
+ // Should NOT have triggered a second run
1152
+ expect(triggerCallCount).toBe(1);
1153
+ // Should have completed the waitpoint
1154
+ expect(completeWaitpointCalled).toBe(true);
1155
+ });
1156
+ it("should fall back to triggering a new run if stream closes without control chunk", async () => {
1157
+ let triggerCallCount = 0;
1158
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1159
+ const urlStr = typeof url === "string" ? url : url.toString();
1160
+ if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) {
1161
+ triggerCallCount++;
1162
+ return new Response(JSON.stringify({ id: `run_fallback_${triggerCallCount}` }), {
1163
+ status: 200,
1164
+ headers: {
1165
+ "content-type": "application/json",
1166
+ "x-trigger-jwt": "pub_token",
1167
+ },
1168
+ });
1169
+ }
1170
+ if (urlStr.includes("/realtime/v1/streams/")) {
1171
+ // No control chunk — stream just ends after the finish
1172
+ const chunks = [
1173
+ { type: "text-start", id: "p1" },
1174
+ { type: "text-delta", id: "p1", delta: "Hello" },
1175
+ { type: "text-end", id: "p1" },
1176
+ ];
1177
+ return new Response(createSSEStream(sseEncode(chunks)), {
1178
+ status: 200,
1179
+ headers: {
1180
+ "content-type": "text/event-stream",
1181
+ "X-Stream-Version": "v1",
1182
+ },
1183
+ });
1184
+ }
1185
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
1186
+ });
1187
+ const transport = new TriggerChatTransport({
1188
+ task: "my-task",
1189
+ accessToken: "token",
1190
+ baseURL: "https://api.test.trigger.dev",
1191
+ });
1192
+ // First message
1193
+ const stream1 = await transport.sendMessages({
1194
+ trigger: "submit-message",
1195
+ chatId: "chat-fallback",
1196
+ messageId: undefined,
1197
+ messages: [createUserMessage("Hello")],
1198
+ abortSignal: undefined,
1199
+ });
1200
+ const reader1 = stream1.getReader();
1201
+ while (true) {
1202
+ const { done } = await reader1.read();
1203
+ if (done)
1204
+ break;
1205
+ }
1206
+ expect(triggerCallCount).toBe(1);
1207
+ // Second message — no waitpoint token stored, should trigger a new run
1208
+ await transport.sendMessages({
1209
+ trigger: "submit-message",
1210
+ chatId: "chat-fallback",
1211
+ messageId: undefined,
1212
+ messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("Again")],
1213
+ abortSignal: undefined,
1214
+ });
1215
+ // Should have triggered a second run
1216
+ expect(triggerCallCount).toBe(2);
1217
+ });
1218
+ it("should fall back to new run when completing waitpoint fails", async () => {
1219
+ const controlChunk = {
1220
+ type: "__trigger_waitpoint_ready",
1221
+ tokenId: "wp_token_fail",
1222
+ publicAccessToken: "wp_access_fail",
1223
+ };
1224
+ let triggerCallCount = 0;
1225
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1226
+ const urlStr = typeof url === "string" ? url : url.toString();
1227
+ if (urlStr.includes("/api/v1/tasks/") && urlStr.includes("/trigger")) {
1228
+ triggerCallCount++;
1229
+ return new Response(JSON.stringify({ id: `run_fail_${triggerCallCount}` }), {
1230
+ status: 200,
1231
+ headers: {
1232
+ "content-type": "application/json",
1233
+ "x-trigger-jwt": "pub_token",
1234
+ },
1235
+ });
1236
+ }
1237
+ // Waitpoint completion fails
1238
+ if (urlStr.includes("/api/v1/waitpoints/tokens/") && urlStr.includes("/complete")) {
1239
+ return new Response(JSON.stringify({ error: "Token expired" }), {
1240
+ status: 400,
1241
+ headers: { "content-type": "application/json" },
1242
+ });
1243
+ }
1244
+ if (urlStr.includes("/realtime/v1/streams/")) {
1245
+ // First call has control chunk, subsequent calls don't
1246
+ const chunks = [
1247
+ ...sampleChunks,
1248
+ { type: "finish", id: "part-1" },
1249
+ ];
1250
+ if (triggerCallCount <= 1) {
1251
+ chunks.push(controlChunk);
1252
+ }
1253
+ return new Response(createSSEStream(sseEncode(chunks)), {
1254
+ status: 200,
1255
+ headers: {
1256
+ "content-type": "text/event-stream",
1257
+ "X-Stream-Version": "v1",
1258
+ },
1259
+ });
1260
+ }
1261
+ throw new Error(`Unexpected fetch URL: ${urlStr}`);
1262
+ });
1263
+ const transport = new TriggerChatTransport({
1264
+ task: "my-task",
1265
+ accessToken: "token",
1266
+ baseURL: "https://api.test.trigger.dev",
1267
+ });
1268
+ // First message
1269
+ const stream1 = await transport.sendMessages({
1270
+ trigger: "submit-message",
1271
+ chatId: "chat-fail",
1272
+ messageId: undefined,
1273
+ messages: [createUserMessage("Hello")],
1274
+ abortSignal: undefined,
1275
+ });
1276
+ const reader1 = stream1.getReader();
1277
+ while (true) {
1278
+ const { done } = await reader1.read();
1279
+ if (done)
1280
+ break;
1281
+ }
1282
+ expect(triggerCallCount).toBe(1);
1283
+ // Second message — waitpoint completion will fail, should fall back to new run
1284
+ const stream2 = await transport.sendMessages({
1285
+ trigger: "submit-message",
1286
+ chatId: "chat-fail",
1287
+ messageId: undefined,
1288
+ messages: [createUserMessage("Hello"), createAssistantMessage("Hi!"), createUserMessage("Again")],
1289
+ abortSignal: undefined,
1290
+ });
1291
+ const reader2 = stream2.getReader();
1292
+ while (true) {
1293
+ const { done } = await reader2.read();
1294
+ if (done)
1295
+ break;
1296
+ }
1297
+ // Should have triggered a second run as fallback
1298
+ expect(triggerCallCount).toBe(2);
1299
+ });
1300
+ });
1301
+ });
1302
+ //# sourceMappingURL=chat.test.js.map