@trigger.dev/sdk 4.5.14 → 4.5.15

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 (120) hide show
  1. package/dist/commonjs/v3/ai-shared.d.ts +7 -0
  2. package/dist/commonjs/v3/ai-shared.js +15 -0
  3. package/dist/commonjs/v3/ai-shared.js.map +1 -1
  4. package/dist/commonjs/v3/ai.d.ts +11 -1
  5. package/dist/commonjs/v3/ai.js +11 -3
  6. package/dist/commonjs/v3/ai.js.map +1 -1
  7. package/dist/commonjs/v3/chat-client.js +1 -1
  8. package/dist/commonjs/v3/chat-client.js.map +1 -1
  9. package/dist/commonjs/v3/chat-server.d.ts +2 -1
  10. package/dist/commonjs/v3/chat-server.js +3 -3
  11. package/dist/commonjs/v3/chat-server.js.map +1 -1
  12. package/dist/commonjs/v3/sessions.d.ts +42 -13
  13. package/dist/commonjs/v3/sessions.js +85 -16
  14. package/dist/commonjs/v3/sessions.js.map +1 -1
  15. package/dist/commonjs/version.js +1 -1
  16. package/dist/esm/v3/ai-shared.d.ts +7 -0
  17. package/dist/esm/v3/ai-shared.js +14 -0
  18. package/dist/esm/v3/ai-shared.js.map +1 -1
  19. package/dist/esm/v3/ai.d.ts +11 -1
  20. package/dist/esm/v3/ai.js +12 -4
  21. package/dist/esm/v3/ai.js.map +1 -1
  22. package/dist/esm/v3/chat-client.js +2 -2
  23. package/dist/esm/v3/chat-client.js.map +1 -1
  24. package/dist/esm/v3/chat-server.d.ts +2 -1
  25. package/dist/esm/v3/chat-server.js +3 -3
  26. package/dist/esm/v3/chat-server.js.map +1 -1
  27. package/dist/esm/v3/sessions.d.ts +42 -13
  28. package/dist/esm/v3/sessions.js +85 -16
  29. package/dist/esm/v3/sessions.js.map +1 -1
  30. package/dist/esm/version.js +1 -1
  31. package/docs/ai-chat/reference.mdx +1 -1
  32. package/docs/ai-chat/sessions.mdx +1 -1
  33. package/docs/ai-chat/side-channels.mdx +172 -0
  34. package/docs/mcp-tools.mdx +37 -0
  35. package/docs/realtime/react-hooks/session-stream.mdx +17 -0
  36. package/package.json +5 -2
  37. package/dist/commonjs/v3/auth.test.d.ts +0 -1
  38. package/dist/commonjs/v3/auth.test.js +0 -149
  39. package/dist/commonjs/v3/auth.test.js.map +0 -1
  40. package/dist/commonjs/v3/batchDebounce.test.d.ts +0 -1
  41. package/dist/commonjs/v3/batchDebounce.test.js +0 -189
  42. package/dist/commonjs/v3/batchDebounce.test.js.map +0 -1
  43. package/dist/commonjs/v3/chat-server.test.d.ts +0 -1
  44. package/dist/commonjs/v3/chat-server.test.js +0 -713
  45. package/dist/commonjs/v3/chat-server.test.js.map +0 -1
  46. package/dist/commonjs/v3/chat-tab-coordinator.test.d.ts +0 -1
  47. package/dist/commonjs/v3/chat-tab-coordinator.test.js +0 -140
  48. package/dist/commonjs/v3/chat-tab-coordinator.test.js.map +0 -1
  49. package/dist/commonjs/v3/chat.test.d.ts +0 -1
  50. package/dist/commonjs/v3/chat.test.js +0 -1838
  51. package/dist/commonjs/v3/chat.test.js.map +0 -1
  52. package/dist/commonjs/v3/createStartSessionAction.test.d.ts +0 -1
  53. package/dist/commonjs/v3/createStartSessionAction.test.js +0 -176
  54. package/dist/commonjs/v3/createStartSessionAction.test.js.map +0 -1
  55. package/dist/commonjs/v3/runs-bulk.test.d.ts +0 -1
  56. package/dist/commonjs/v3/runs-bulk.test.js +0 -141
  57. package/dist/commonjs/v3/runs-bulk.test.js.map +0 -1
  58. package/dist/commonjs/v3/schedules/index.test.d.ts +0 -1
  59. package/dist/commonjs/v3/schedules/index.test.js +0 -49
  60. package/dist/commonjs/v3/schedules/index.test.js.map +0 -1
  61. package/dist/commonjs/v3/schedules/index.types.test.d.ts +0 -1
  62. package/dist/commonjs/v3/schedules/index.types.test.js +0 -93
  63. package/dist/commonjs/v3/schedules/index.types.test.js.map +0 -1
  64. package/dist/commonjs/v3/sessions.test.d.ts +0 -1
  65. package/dist/commonjs/v3/sessions.test.js +0 -154
  66. package/dist/commonjs/v3/sessions.test.js.map +0 -1
  67. package/dist/commonjs/v3/shared.test.d.ts +0 -1
  68. package/dist/commonjs/v3/shared.test.js +0 -236
  69. package/dist/commonjs/v3/shared.test.js.map +0 -1
  70. package/dist/commonjs/v3/streams.test.d.ts +0 -1
  71. package/dist/commonjs/v3/streams.test.js +0 -51
  72. package/dist/commonjs/v3/streams.test.js.map +0 -1
  73. package/dist/commonjs/v3/triggerClient.test.d.ts +0 -1
  74. package/dist/commonjs/v3/triggerClient.test.js +0 -226
  75. package/dist/commonjs/v3/triggerClient.test.js.map +0 -1
  76. package/dist/commonjs/v3/triggerClient.types.test.d.ts +0 -1
  77. package/dist/commonjs/v3/triggerClient.types.test.js +0 -123
  78. package/dist/commonjs/v3/triggerClient.types.test.js.map +0 -1
  79. package/dist/esm/v3/auth.test.d.ts +0 -1
  80. package/dist/esm/v3/auth.test.js +0 -147
  81. package/dist/esm/v3/auth.test.js.map +0 -1
  82. package/dist/esm/v3/batchDebounce.test.d.ts +0 -1
  83. package/dist/esm/v3/batchDebounce.test.js +0 -187
  84. package/dist/esm/v3/batchDebounce.test.js.map +0 -1
  85. package/dist/esm/v3/chat-server.test.d.ts +0 -1
  86. package/dist/esm/v3/chat-server.test.js +0 -711
  87. package/dist/esm/v3/chat-server.test.js.map +0 -1
  88. package/dist/esm/v3/chat-tab-coordinator.test.d.ts +0 -1
  89. package/dist/esm/v3/chat-tab-coordinator.test.js +0 -138
  90. package/dist/esm/v3/chat-tab-coordinator.test.js.map +0 -1
  91. package/dist/esm/v3/chat.test.d.ts +0 -1
  92. package/dist/esm/v3/chat.test.js +0 -1836
  93. package/dist/esm/v3/chat.test.js.map +0 -1
  94. package/dist/esm/v3/createStartSessionAction.test.d.ts +0 -1
  95. package/dist/esm/v3/createStartSessionAction.test.js +0 -174
  96. package/dist/esm/v3/createStartSessionAction.test.js.map +0 -1
  97. package/dist/esm/v3/runs-bulk.test.d.ts +0 -1
  98. package/dist/esm/v3/runs-bulk.test.js +0 -139
  99. package/dist/esm/v3/runs-bulk.test.js.map +0 -1
  100. package/dist/esm/v3/schedules/index.test.d.ts +0 -1
  101. package/dist/esm/v3/schedules/index.test.js +0 -47
  102. package/dist/esm/v3/schedules/index.test.js.map +0 -1
  103. package/dist/esm/v3/schedules/index.types.test.d.ts +0 -1
  104. package/dist/esm/v3/schedules/index.types.test.js +0 -91
  105. package/dist/esm/v3/schedules/index.types.test.js.map +0 -1
  106. package/dist/esm/v3/sessions.test.d.ts +0 -1
  107. package/dist/esm/v3/sessions.test.js +0 -152
  108. package/dist/esm/v3/sessions.test.js.map +0 -1
  109. package/dist/esm/v3/shared.test.d.ts +0 -1
  110. package/dist/esm/v3/shared.test.js +0 -234
  111. package/dist/esm/v3/shared.test.js.map +0 -1
  112. package/dist/esm/v3/streams.test.d.ts +0 -1
  113. package/dist/esm/v3/streams.test.js +0 -49
  114. package/dist/esm/v3/streams.test.js.map +0 -1
  115. package/dist/esm/v3/triggerClient.test.d.ts +0 -1
  116. package/dist/esm/v3/triggerClient.test.js +0 -224
  117. package/dist/esm/v3/triggerClient.test.js.map +0 -1
  118. package/dist/esm/v3/triggerClient.types.test.d.ts +0 -1
  119. package/dist/esm/v3/triggerClient.types.test.js +0 -88
  120. package/dist/esm/v3/triggerClient.types.test.js.map +0 -1
@@ -1,1836 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { TriggerChatTransport, createChatTransport } from "./chat.js";
3
- // ───────────────────────────────────────────────────────────────────────────
4
- // Test helpers
5
- // ───────────────────────────────────────────────────────────────────────────
6
- /**
7
- * Encode chunks as SSE text. The runtime SSE parser
8
- * ({@link SSEStreamSubscription}) auto-parses the `data:` field via
9
- * `safeParseJSON` and yields it as `value.chunk`, so each `data:` line
10
- * just needs to contain the JSON-encoded chunk directly.
11
- *
12
- * In production the session backend sends the raw S2 record body as the
13
- * `data:` field — that body is itself a JSON string (the transport
14
- * round-trips through `JSON.stringify`/`JSON.parse`). The transport's
15
- * SSE reader handles both shapes (`typeof value.chunk === "string"` →
16
- * parse-once, `=== "object"` → use as-is). We pick the object form
17
- * here for test simplicity.
18
- */
19
- /**
20
- * Encode test chunks as a session-stream v2 SSE batch event. Each chunk
21
- * becomes one S2 record; chunks of shape `{type: "trigger:turn-complete"}`
22
- * or `{type: "trigger:upgrade-required"}` are translated into header-form
23
- * control records (empty body, `trigger-control` header) to match the
24
- * production wire shape.
25
- */
26
- function sseEncode(chunks) {
27
- let nextSeq = 1;
28
- const records = chunks.map((chunk, i) => {
29
- const partId = `p-${i}`;
30
- const type = chunk.type;
31
- if (type === "trigger:turn-complete") {
32
- const headers = [["trigger-control", "turn-complete"]];
33
- const token = chunk.publicAccessToken;
34
- if (token)
35
- headers.push(["public-access-token", token]);
36
- return {
37
- body: "",
38
- seq_num: nextSeq++,
39
- timestamp: 1700000000000 + i,
40
- headers,
41
- };
42
- }
43
- if (type === "trigger:upgrade-required") {
44
- return {
45
- body: "",
46
- seq_num: nextSeq++,
47
- timestamp: 1700000000000 + i,
48
- headers: [["trigger-control", "upgrade-required"]],
49
- };
50
- }
51
- return {
52
- body: JSON.stringify({ data: chunk, id: partId }),
53
- seq_num: nextSeq++,
54
- timestamp: 1700000000000 + i,
55
- headers: [],
56
- };
57
- });
58
- return `event: batch\ndata: ${JSON.stringify({ records })}\n\n`;
59
- }
60
- function createSSEStream(sseText) {
61
- const encoder = new TextEncoder();
62
- return new ReadableStream({
63
- start(controller) {
64
- controller.enqueue(encoder.encode(sseText));
65
- controller.close();
66
- },
67
- });
68
- }
69
- let messageIdCounter = 0;
70
- function createUserMessage(text) {
71
- return {
72
- id: `msg-user-${++messageIdCounter}`,
73
- role: "user",
74
- parts: [{ type: "text", text }],
75
- };
76
- }
77
- const sampleChunks = [
78
- { type: "text-start", id: "part-1" },
79
- { type: "text-delta", id: "part-1", delta: "Hello" },
80
- { type: "text-delta", id: "part-1", delta: " world" },
81
- { type: "text-delta", id: "part-1", delta: "!" },
82
- { type: "text-end", id: "part-1" },
83
- ];
84
- const sampleChunksWithTurnComplete = [
85
- ...sampleChunks,
86
- { type: "trigger:turn-complete" },
87
- ];
88
- // URL predicates
89
- function isSessionCreateUrl(urlStr) {
90
- return urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/");
91
- }
92
- function isSessionOutSubscribeUrl(urlStr) {
93
- return /\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr);
94
- }
95
- function isSessionStreamAppendUrl(urlStr) {
96
- return /\/realtime\/v1\/sessions\/[^/]+\/(in|out)\/append$/.test(urlStr);
97
- }
98
- function chatIdFromUrl(urlStr) {
99
- const m = urlStr.match(/\/realtime\/v1\/sessions\/([^/]+)\//);
100
- return m?.[1];
101
- }
102
- const _DEFAULT_RUN_ID = "run_default";
103
- const _DEFAULT_SESSION_ID = "session_default";
104
- const _DEFAULT_SESSION_PAT = "pat_session_default";
105
- function defaultAppendResponse() {
106
- return new Response(JSON.stringify({ ok: true }), {
107
- status: 200,
108
- headers: { "content-type": "application/json" },
109
- });
110
- }
111
- function defaultSseResponse(chunks = sampleChunksWithTurnComplete) {
112
- return new Response(createSSEStream(sseEncode(chunks)), {
113
- status: 200,
114
- headers: {
115
- "content-type": "text/event-stream",
116
- // Session streams are always v2 in production — batch format
117
- // with one S2 record per SSE event. The legacy v1 path is for
118
- // run-scoped Redis streams.
119
- "X-Stream-Version": "v2",
120
- },
121
- });
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
- }
154
- function authError(status = 401) {
155
- return new Response(JSON.stringify({ error: "Unauthorized", name: "TriggerApiError", status }), {
156
- status,
157
- headers: { "content-type": "application/json" },
158
- });
159
- }
160
- /**
161
- * Drains a UIMessageChunk stream into an array. Used to assert what
162
- * the transport surfaced after filtering control chunks.
163
- */
164
- async function drainChunks(stream) {
165
- const reader = stream.getReader();
166
- const out = [];
167
- try {
168
- while (true) {
169
- const { done, value } = await reader.read();
170
- if (done)
171
- break;
172
- out.push(value);
173
- }
174
- }
175
- finally {
176
- reader.releaseLock();
177
- }
178
- return out;
179
- }
180
- // ───────────────────────────────────────────────────────────────────────────
181
- // Tests
182
- // ───────────────────────────────────────────────────────────────────────────
183
- describe("TriggerChatTransport", () => {
184
- let originalFetch;
185
- beforeEach(() => {
186
- originalFetch = global.fetch;
187
- });
188
- afterEach(() => {
189
- global.fetch = originalFetch;
190
- vi.restoreAllMocks();
191
- });
192
- describe("constructor", () => {
193
- it("creates with required options", () => {
194
- const transport = new TriggerChatTransport({
195
- task: "my-chat-task",
196
- accessToken: () => "pat",
197
- });
198
- expect(transport).toBeInstanceOf(TriggerChatTransport);
199
- });
200
- it("createChatTransport returns a TriggerChatTransport", () => {
201
- const transport = createChatTransport({
202
- task: "my-chat-task",
203
- accessToken: () => "pat",
204
- });
205
- expect(transport).toBeInstanceOf(TriggerChatTransport);
206
- });
207
- it("hydrates sessions from options.sessions", () => {
208
- const transport = new TriggerChatTransport({
209
- task: "my-chat-task",
210
- accessToken: () => "pat",
211
- sessions: {
212
- "chat-1": {
213
- publicAccessToken: "hydrated-pat",
214
- lastEventId: "42",
215
- activeInputSeq: 41,
216
- isStreaming: false,
217
- },
218
- },
219
- });
220
- const session = transport.getSession("chat-1");
221
- expect(session).toEqual({
222
- publicAccessToken: "hydrated-pat",
223
- lastEventId: "42",
224
- activeInputSeq: 41,
225
- isStreaming: false,
226
- });
227
- });
228
- it("returns undefined for unknown chatIds", () => {
229
- const transport = new TriggerChatTransport({
230
- task: "my-chat-task",
231
- accessToken: () => "pat",
232
- });
233
- expect(transport.getSession("unknown")).toBeUndefined();
234
- });
235
- });
236
- describe("setSession / setOnSessionChange", () => {
237
- it("setSession installs persisted state and notifies", () => {
238
- const onSessionChange = vi.fn();
239
- const transport = new TriggerChatTransport({
240
- task: "my-chat-task",
241
- accessToken: () => "pat",
242
- onSessionChange,
243
- });
244
- transport.setSession("chat-x", {
245
- publicAccessToken: "tok",
246
- lastEventId: "10",
247
- activeInputSeq: 9,
248
- });
249
- expect(transport.getSession("chat-x")).toMatchObject({
250
- publicAccessToken: "tok",
251
- lastEventId: "10",
252
- activeInputSeq: 9,
253
- });
254
- expect(onSessionChange).toHaveBeenCalledWith("chat-x", expect.objectContaining({
255
- publicAccessToken: "tok",
256
- lastEventId: "10",
257
- activeInputSeq: 9,
258
- }));
259
- });
260
- it("setOnSessionChange swaps the callback at runtime", () => {
261
- const transport = new TriggerChatTransport({
262
- task: "my-chat-task",
263
- accessToken: () => "pat",
264
- });
265
- const cb1 = vi.fn();
266
- const cb2 = vi.fn();
267
- transport.setOnSessionChange(cb1);
268
- transport.setSession("c", { publicAccessToken: "t1" });
269
- expect(cb1).toHaveBeenCalledTimes(1);
270
- transport.setOnSessionChange(cb2);
271
- transport.setSession("c", { publicAccessToken: "t2" });
272
- expect(cb1).toHaveBeenCalledTimes(1);
273
- expect(cb2).toHaveBeenCalledTimes(1);
274
- });
275
- });
276
- describe("start", () => {
277
- it("calls the customer's startSession callback and caches the returned PAT", async () => {
278
- const startSession = vi.fn().mockResolvedValue({ publicAccessToken: "session-pat-1" });
279
- const transport = new TriggerChatTransport({
280
- task: "my-chat-task",
281
- accessToken: () => "should-not-be-called",
282
- startSession,
283
- });
284
- const result = await transport.start("chat-1");
285
- expect(startSession).toHaveBeenCalledWith({
286
- taskId: "my-chat-task",
287
- chatId: "chat-1",
288
- clientData: {},
289
- });
290
- expect(result.publicAccessToken).toBe("session-pat-1");
291
- expect(transport.getSession("chat-1")?.publicAccessToken).toBe("session-pat-1");
292
- });
293
- it("is idempotent — second call returns the cached state without re-invoking startSession", async () => {
294
- const startSession = vi.fn().mockResolvedValue({ publicAccessToken: "session-pat-2" });
295
- const transport = new TriggerChatTransport({
296
- task: "my-chat-task",
297
- accessToken: () => "pat",
298
- startSession,
299
- });
300
- await transport.start("chat-2");
301
- await transport.start("chat-2");
302
- expect(startSession).toHaveBeenCalledTimes(1);
303
- });
304
- it("dedupes concurrent calls via an in-flight promise", async () => {
305
- let resolveStart;
306
- const startPromise = new Promise((resolve) => {
307
- resolveStart = resolve;
308
- });
309
- const startSession = vi.fn().mockReturnValue(startPromise);
310
- const transport = new TriggerChatTransport({
311
- task: "my-chat-task",
312
- accessToken: () => "pat",
313
- startSession,
314
- });
315
- const a = transport.start("chat-3");
316
- const b = transport.start("chat-3");
317
- resolveStart({ publicAccessToken: "session-pat-3" });
318
- await Promise.all([a, b]);
319
- expect(startSession).toHaveBeenCalledTimes(1);
320
- });
321
- it("preload() is an alias for start()", async () => {
322
- const startSession = vi.fn().mockResolvedValue({ publicAccessToken: "session-pat-pre" });
323
- const transport = new TriggerChatTransport({
324
- task: "my-chat-task",
325
- accessToken: () => "pat",
326
- startSession,
327
- });
328
- await transport.preload("chat-pre");
329
- expect(startSession).toHaveBeenCalledTimes(1);
330
- expect(transport.getSession("chat-pre")?.publicAccessToken).toBe("session-pat-pre");
331
- });
332
- it("throws a clear error when start() is called without startSession configured", async () => {
333
- const transport = new TriggerChatTransport({
334
- task: "my-chat-task",
335
- accessToken: () => "pat",
336
- });
337
- await expect(transport.start("chat-no-start")).rejects.toThrow(/startSession/);
338
- });
339
- it("threads the transport's `clientData` through to startSession", async () => {
340
- const startSession = vi.fn().mockResolvedValue({ publicAccessToken: "session-pat-cd" });
341
- const transport = new TriggerChatTransport({
342
- task: "my-chat-task",
343
- accessToken: () => "pat",
344
- startSession,
345
- clientData: { userId: "u-1", model: "claude-sonnet-4-6" },
346
- });
347
- await transport.start("chat-cd");
348
- expect(startSession).toHaveBeenCalledWith({
349
- taskId: "my-chat-task",
350
- chatId: "chat-cd",
351
- clientData: { userId: "u-1", model: "claude-sonnet-4-6" },
352
- });
353
- });
354
- it("setClientData updates the value passed to subsequent startSession calls", async () => {
355
- const startSession = vi.fn().mockResolvedValue({ publicAccessToken: "session-pat-set" });
356
- const transport = new TriggerChatTransport({
357
- task: "my-chat-task",
358
- accessToken: () => "pat",
359
- startSession,
360
- clientData: { userId: "old" },
361
- });
362
- transport.setClientData({ userId: "new" });
363
- await transport.start("chat-set");
364
- expect(startSession).toHaveBeenCalledWith({
365
- taskId: "my-chat-task",
366
- chatId: "chat-set",
367
- clientData: { userId: "new" },
368
- });
369
- });
370
- });
371
- describe("ensureSessionState (lazy start on first sendMessage)", () => {
372
- it("calls startSession lazily on first sendMessage when no PAT is hydrated", async () => {
373
- const startSession = vi.fn().mockResolvedValue({ publicAccessToken: "lazy-session-pat" });
374
- global.fetch = vi.fn().mockImplementation(async (url) => {
375
- const urlStr = typeof url === "string" ? url : url.toString();
376
- if (isSessionStreamAppendUrl(urlStr))
377
- return defaultAppendResponse();
378
- if (isSessionOutSubscribeUrl(urlStr))
379
- return defaultSseResponse();
380
- throw new Error(`Unexpected URL: ${urlStr}`);
381
- });
382
- const transport = new TriggerChatTransport({
383
- task: "my-chat-task",
384
- accessToken: () => "should-not-be-called",
385
- startSession,
386
- baseURL: "https://api.test.trigger.dev",
387
- });
388
- const stream = await transport.sendMessages({
389
- trigger: "submit-message",
390
- chatId: "chat-lazy",
391
- messageId: undefined,
392
- messages: [createUserMessage("hi")],
393
- abortSignal: undefined,
394
- });
395
- await drainChunks(stream);
396
- expect(startSession).toHaveBeenCalledTimes(1);
397
- expect(startSession).toHaveBeenCalledWith({
398
- taskId: "my-chat-task",
399
- chatId: "chat-lazy",
400
- clientData: {},
401
- });
402
- expect(transport.getSession("chat-lazy")?.publicAccessToken).toBe("lazy-session-pat");
403
- });
404
- it("falls back to accessToken when no startSession is configured (out-of-band session create)", async () => {
405
- const accessToken = vi.fn().mockResolvedValue("server-mediated-pat");
406
- global.fetch = vi.fn().mockImplementation(async (url) => {
407
- const urlStr = typeof url === "string" ? url : url.toString();
408
- if (isSessionStreamAppendUrl(urlStr))
409
- return defaultAppendResponse();
410
- if (isSessionOutSubscribeUrl(urlStr))
411
- return defaultSseResponse();
412
- throw new Error(`Unexpected URL: ${urlStr}`);
413
- });
414
- const transport = new TriggerChatTransport({
415
- task: "my-chat-task",
416
- accessToken,
417
- baseURL: "https://api.test.trigger.dev",
418
- });
419
- const stream = await transport.sendMessages({
420
- trigger: "submit-message",
421
- chatId: "chat-server",
422
- messageId: undefined,
423
- messages: [createUserMessage("hi")],
424
- abortSignal: undefined,
425
- });
426
- await drainChunks(stream);
427
- expect(accessToken).toHaveBeenCalledTimes(1);
428
- expect(accessToken).toHaveBeenCalledWith({ chatId: "chat-server" });
429
- });
430
- it("does not call accessToken when a PAT is hydrated", async () => {
431
- const accessToken = vi.fn().mockResolvedValue("should-not-be-called");
432
- global.fetch = vi.fn().mockImplementation(async (url) => {
433
- const urlStr = typeof url === "string" ? url : url.toString();
434
- if (isSessionStreamAppendUrl(urlStr))
435
- return defaultAppendResponse();
436
- if (isSessionOutSubscribeUrl(urlStr))
437
- return defaultSseResponse();
438
- throw new Error(`Unexpected URL: ${urlStr}`);
439
- });
440
- const transport = new TriggerChatTransport({
441
- task: "my-chat-task",
442
- accessToken,
443
- sessions: {
444
- "chat-h": { publicAccessToken: "hydrated-pat" },
445
- },
446
- });
447
- const stream = await transport.sendMessages({
448
- trigger: "submit-message",
449
- chatId: "chat-h",
450
- messageId: undefined,
451
- messages: [createUserMessage("hi")],
452
- abortSignal: undefined,
453
- });
454
- await drainChunks(stream);
455
- expect(accessToken).not.toHaveBeenCalled();
456
- });
457
- });
458
- describe("sendMessages", () => {
459
- it("posts the user message to .in/append and streams chunks from .out", async () => {
460
- const requests = [];
461
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
462
- const urlStr = typeof url === "string" ? url : url.toString();
463
- requests.push({ url: urlStr, init });
464
- if (isSessionStreamAppendUrl(urlStr))
465
- return defaultAppendResponse();
466
- if (isSessionOutSubscribeUrl(urlStr))
467
- return defaultSseResponse();
468
- throw new Error(`Unexpected URL: ${urlStr}`);
469
- });
470
- const transport = new TriggerChatTransport({
471
- task: "my-chat-task",
472
- accessToken: () => "pat",
473
- baseURL: "https://api.test.trigger.dev",
474
- sessions: { "chat-1": { publicAccessToken: "p" } },
475
- });
476
- const stream = await transport.sendMessages({
477
- trigger: "submit-message",
478
- chatId: "chat-1",
479
- messageId: "m1",
480
- messages: [createUserMessage("Hello")],
481
- abortSignal: undefined,
482
- });
483
- const chunks = await drainChunks(stream);
484
- // Five UI chunks pass through; trigger:turn-complete is filtered.
485
- expect(chunks).toHaveLength(sampleChunks.length);
486
- expect(chunks[0]).toEqual(sampleChunks[0]);
487
- const append = requests.find((r) => isSessionStreamAppendUrl(r.url) && r.url.endsWith("/in/append"));
488
- expect(append).toBeDefined();
489
- expect(chatIdFromUrl(append.url)).toBe("chat-1");
490
- // Body is the serialized ChatInputChunk.
491
- const body = JSON.parse(append.init.body);
492
- expect(body.kind).toBe("message");
493
- expect(body.payload.chatId).toBe("chat-1");
494
- expect(body.payload.trigger).toBe("submit-message");
495
- });
496
- it("addresses .out SSE by chatId (not by sessionId)", async () => {
497
- const requests = [];
498
- global.fetch = vi.fn().mockImplementation(async (url) => {
499
- const urlStr = typeof url === "string" ? url : url.toString();
500
- requests.push(urlStr);
501
- if (isSessionStreamAppendUrl(urlStr))
502
- return defaultAppendResponse();
503
- if (isSessionOutSubscribeUrl(urlStr))
504
- return defaultSseResponse();
505
- throw new Error(`Unexpected URL: ${urlStr}`);
506
- });
507
- const transport = new TriggerChatTransport({
508
- task: "my-chat-task",
509
- accessToken: () => "pat",
510
- baseURL: "https://api.test.trigger.dev",
511
- sessions: { "chat-by-chatid": { publicAccessToken: "p" } },
512
- });
513
- const stream = await transport.sendMessages({
514
- trigger: "submit-message",
515
- chatId: "chat-by-chatid",
516
- messageId: undefined,
517
- messages: [createUserMessage("Hi")],
518
- abortSignal: undefined,
519
- });
520
- await drainChunks(stream);
521
- const subscribe = requests.find(isSessionOutSubscribeUrl);
522
- expect(subscribe).toBeDefined();
523
- expect(subscribe).toContain("/realtime/v1/sessions/chat-by-chatid/out");
524
- });
525
- it("functional baseURL dispatches per endpoint (in vs out)", async () => {
526
- const requests = [];
527
- global.fetch = vi.fn().mockImplementation(async (url) => {
528
- const urlStr = typeof url === "string" ? url : url.toString();
529
- requests.push({ url: urlStr, ctxEndpoint: undefined });
530
- if (isSessionStreamAppendUrl(urlStr))
531
- return defaultAppendResponse();
532
- if (isSessionOutSubscribeUrl(urlStr))
533
- return defaultSseResponse();
534
- throw new Error(`Unexpected URL: ${urlStr}`);
535
- });
536
- const baseURLFn = vi.fn(({ endpoint }) => endpoint === "out" ? "https://stream.example.com" : "https://api.example.com");
537
- const transport = new TriggerChatTransport({
538
- task: "my-chat-task",
539
- accessToken: () => "pat",
540
- baseURL: baseURLFn,
541
- sessions: { "chat-fn": { publicAccessToken: "p" } },
542
- });
543
- const stream = await transport.sendMessages({
544
- trigger: "submit-message",
545
- chatId: "chat-fn",
546
- messageId: undefined,
547
- messages: [createUserMessage("Hi")],
548
- abortSignal: undefined,
549
- });
550
- await drainChunks(stream);
551
- const appendCalls = baseURLFn.mock.calls.filter((c) => c[0].endpoint === "in");
552
- const outCalls = baseURLFn.mock.calls.filter((c) => c[0].endpoint === "out");
553
- expect(appendCalls.length).toBeGreaterThanOrEqual(1);
554
- expect(outCalls.length).toBeGreaterThanOrEqual(1);
555
- expect(appendCalls[0][0].chatId).toBe("chat-fn");
556
- expect(outCalls[0][0].chatId).toBe("chat-fn");
557
- const append = requests.find((r) => isSessionStreamAppendUrl(r.url));
558
- const subscribe = requests.find((r) => isSessionOutSubscribeUrl(r.url));
559
- expect(append.url.startsWith("https://api.example.com/")).toBe(true);
560
- expect(subscribe.url.startsWith("https://stream.example.com/")).toBe(true);
561
- });
562
- it("fetch override is invoked for both .in/append and .out SSE with endpoint ctx", async () => {
563
- const fetchCalls = [];
564
- const customFetch = vi.fn(async (url, init, ctx) => {
565
- fetchCalls.push({ url, endpoint: ctx.endpoint, chatId: ctx.chatId });
566
- if (isSessionStreamAppendUrl(url))
567
- return defaultAppendResponse();
568
- if (isSessionOutSubscribeUrl(url))
569
- return defaultSseResponse();
570
- throw new Error(`Unexpected URL: ${url}`);
571
- });
572
- global.fetch = vi.fn().mockRejectedValue(new Error("global fetch should not be called"));
573
- const transport = new TriggerChatTransport({
574
- task: "my-chat-task",
575
- accessToken: () => "pat",
576
- baseURL: "https://api.test.trigger.dev",
577
- fetch: customFetch,
578
- sessions: { "chat-fetch": { publicAccessToken: "p" } },
579
- });
580
- const stream = await transport.sendMessages({
581
- trigger: "submit-message",
582
- chatId: "chat-fetch",
583
- messageId: undefined,
584
- messages: [createUserMessage("Hi")],
585
- abortSignal: undefined,
586
- });
587
- await drainChunks(stream);
588
- const inCalls = fetchCalls.filter((c) => c.endpoint === "in");
589
- const outCalls = fetchCalls.filter((c) => c.endpoint === "out");
590
- expect(inCalls.length).toBeGreaterThanOrEqual(1);
591
- expect(outCalls.length).toBeGreaterThanOrEqual(1);
592
- expect(inCalls[0].chatId).toBe("chat-fetch");
593
- expect(outCalls[0].chatId).toBe("chat-fetch");
594
- });
595
- it("routes .out SSE through streamBaseURL while appends stay on baseURL", async () => {
596
- const requests = [];
597
- global.fetch = vi.fn().mockImplementation(async (url) => {
598
- const urlStr = typeof url === "string" ? url : url.toString();
599
- requests.push(urlStr);
600
- if (isSessionStreamAppendUrl(urlStr))
601
- return defaultAppendResponse();
602
- if (isSessionOutSubscribeUrl(urlStr))
603
- return defaultSseResponse();
604
- throw new Error(`Unexpected URL: ${urlStr}`);
605
- });
606
- const transport = new TriggerChatTransport({
607
- task: "my-chat-task",
608
- accessToken: () => "pat",
609
- baseURL: "https://api.test.trigger.dev",
610
- streamBaseURL: "https://chat-proxy.example.com",
611
- sessions: { "chat-split": { publicAccessToken: "p" } },
612
- });
613
- const stream = await transport.sendMessages({
614
- trigger: "submit-message",
615
- chatId: "chat-split",
616
- messageId: undefined,
617
- messages: [createUserMessage("Hi")],
618
- abortSignal: undefined,
619
- });
620
- await drainChunks(stream);
621
- const append = requests.find(isSessionStreamAppendUrl);
622
- const subscribe = requests.find(isSessionOutSubscribeUrl);
623
- expect(append.startsWith("https://api.test.trigger.dev/")).toBe(true);
624
- expect(subscribe.startsWith("https://chat-proxy.example.com/")).toBe(true);
625
- expect(subscribe).toContain("/realtime/v1/sessions/chat-split/out");
626
- });
627
- it("for submit-message, only the latest message is delivered to .in", async () => {
628
- // Slim wire: each `.in/append` carries at most ONE new message in
629
- // `payload.message` (singular). Even if the caller hands sendMessages
630
- // an array of three, only the last element flows to the wire — the
631
- // agent rebuilds prior history at run boot from snapshot + replay.
632
- let appendBody;
633
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
634
- const urlStr = typeof url === "string" ? url : url.toString();
635
- if (isSessionStreamAppendUrl(urlStr)) {
636
- appendBody = JSON.parse(init.body);
637
- return defaultAppendResponse();
638
- }
639
- if (isSessionOutSubscribeUrl(urlStr))
640
- return defaultSseResponse();
641
- throw new Error(`Unexpected URL: ${urlStr}`);
642
- });
643
- const transport = new TriggerChatTransport({
644
- task: "my-chat-task",
645
- accessToken: () => "pat",
646
- sessions: { "chat-slice": { publicAccessToken: "p" } },
647
- });
648
- const stream = await transport.sendMessages({
649
- trigger: "submit-message",
650
- chatId: "chat-slice",
651
- messageId: undefined,
652
- messages: [
653
- createUserMessage("first"),
654
- createUserMessage("second"),
655
- createUserMessage("third"),
656
- ],
657
- abortSignal: undefined,
658
- });
659
- await drainChunks(stream);
660
- expect(appendBody.payload.message).toBeDefined();
661
- expect(appendBody.payload.message.parts[0].text).toBe("third");
662
- expect(appendBody.payload.messages).toBeUndefined();
663
- });
664
- it("for regenerate-message, no message is delivered to .in (server slices its own tail)", async () => {
665
- // Slim wire: the regenerate trigger ships NO message — the agent
666
- // trims the trailing assistant from its accumulator and re-runs from
667
- // the prior user turn. The wire payload only carries the trigger
668
- // discriminator + chatId + metadata.
669
- let appendBody;
670
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
671
- const urlStr = typeof url === "string" ? url : url.toString();
672
- if (isSessionStreamAppendUrl(urlStr)) {
673
- appendBody = JSON.parse(init.body);
674
- return defaultAppendResponse();
675
- }
676
- if (isSessionOutSubscribeUrl(urlStr))
677
- return defaultSseResponse();
678
- throw new Error(`Unexpected URL: ${urlStr}`);
679
- });
680
- const transport = new TriggerChatTransport({
681
- task: "my-chat-task",
682
- accessToken: () => "pat",
683
- sessions: { "chat-regen": { publicAccessToken: "p" } },
684
- });
685
- const stream = await transport.sendMessages({
686
- trigger: "regenerate-message",
687
- chatId: "chat-regen",
688
- messageId: undefined,
689
- messages: [createUserMessage("a"), createUserMessage("b")],
690
- abortSignal: undefined,
691
- });
692
- await drainChunks(stream);
693
- expect(appendBody.payload.trigger).toBe("regenerate-message");
694
- expect(appendBody.payload.message).toBeUndefined();
695
- expect(appendBody.payload.messages).toBeUndefined();
696
- });
697
- it("merges transport-level clientData into per-call metadata (per-call wins)", async () => {
698
- let appendBody;
699
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
700
- const urlStr = typeof url === "string" ? url : url.toString();
701
- if (isSessionStreamAppendUrl(urlStr)) {
702
- appendBody = JSON.parse(init.body);
703
- return defaultAppendResponse();
704
- }
705
- if (isSessionOutSubscribeUrl(urlStr))
706
- return defaultSseResponse();
707
- throw new Error(`Unexpected URL: ${urlStr}`);
708
- });
709
- const transport = new TriggerChatTransport({
710
- task: "my-chat-task",
711
- accessToken: () => "pat",
712
- clientData: { userId: "u1", scope: "default" },
713
- sessions: { "chat-md": { publicAccessToken: "p" } },
714
- });
715
- const stream = await transport.sendMessages({
716
- trigger: "submit-message",
717
- chatId: "chat-md",
718
- messageId: undefined,
719
- messages: [createUserMessage("hi")],
720
- abortSignal: undefined,
721
- metadata: { scope: "request" },
722
- });
723
- await drainChunks(stream);
724
- expect(appendBody.payload.metadata).toEqual({ userId: "u1", scope: "request" });
725
- });
726
- it("filters trigger:upgrade-required and continues reading", async () => {
727
- const chunks = [
728
- ...sampleChunks.slice(0, 2),
729
- { type: "trigger:upgrade-required" },
730
- ...sampleChunks.slice(2),
731
- { type: "trigger:turn-complete" },
732
- ];
733
- global.fetch = vi.fn().mockImplementation(async (url) => {
734
- const urlStr = typeof url === "string" ? url : url.toString();
735
- if (isSessionStreamAppendUrl(urlStr))
736
- return defaultAppendResponse();
737
- if (isSessionOutSubscribeUrl(urlStr))
738
- return defaultSseResponse(chunks);
739
- throw new Error(`Unexpected URL: ${urlStr}`);
740
- });
741
- const transport = new TriggerChatTransport({
742
- task: "my-chat-task",
743
- accessToken: () => "pat",
744
- sessions: { "chat-up": { publicAccessToken: "p" } },
745
- });
746
- const stream = await transport.sendMessages({
747
- trigger: "submit-message",
748
- chatId: "chat-up",
749
- messageId: undefined,
750
- messages: [createUserMessage("hi")],
751
- abortSignal: undefined,
752
- });
753
- const surfaced = await drainChunks(stream);
754
- // Both control chunks are filtered.
755
- expect(surfaced).toHaveLength(sampleChunks.length);
756
- expect(surfaced.find((c) => c.type === "trigger:upgrade-required")).toBeUndefined();
757
- expect(surfaced.find((c) => c.type === "trigger:turn-complete")).toBeUndefined();
758
- });
759
- it("clears isStreaming on turn-complete and notifies", async () => {
760
- const onSessionChange = vi.fn();
761
- global.fetch = vi.fn().mockImplementation(async (url) => {
762
- const urlStr = typeof url === "string" ? url : url.toString();
763
- if (isSessionStreamAppendUrl(urlStr))
764
- return defaultAppendResponse();
765
- if (isSessionOutSubscribeUrl(urlStr))
766
- return defaultSseResponse();
767
- throw new Error(`Unexpected URL: ${urlStr}`);
768
- });
769
- const transport = new TriggerChatTransport({
770
- task: "my-chat-task",
771
- accessToken: () => "pat",
772
- onSessionChange,
773
- sessions: { "chat-tc": { publicAccessToken: "p" } },
774
- });
775
- const stream = await transport.sendMessages({
776
- trigger: "submit-message",
777
- chatId: "chat-tc",
778
- messageId: undefined,
779
- messages: [createUserMessage("hi")],
780
- abortSignal: undefined,
781
- });
782
- await drainChunks(stream);
783
- const lastIsStreamingFalse = onSessionChange.mock.calls
784
- .map((call) => call[1])
785
- .reverse()
786
- .find((s) => s !== null && s.isStreaming === false);
787
- expect(lastIsStreamingFalse).toBeDefined();
788
- });
789
- });
790
- describe("auth retry on 401", () => {
791
- it("refreshes the PAT via accessToken and retries the .in/append once", async () => {
792
- const accessToken = vi.fn().mockResolvedValue("fresh-pat");
793
- let appendCount = 0;
794
- let appendAuth = null;
795
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
796
- const urlStr = typeof url === "string" ? url : url.toString();
797
- if (isSessionStreamAppendUrl(urlStr)) {
798
- appendCount++;
799
- if (appendCount === 1)
800
- return authError(401);
801
- appendAuth = new Headers(init?.headers).get("Authorization");
802
- return defaultAppendResponse();
803
- }
804
- if (isSessionOutSubscribeUrl(urlStr))
805
- return defaultSseResponse();
806
- throw new Error(`Unexpected URL: ${urlStr}`);
807
- });
808
- const transport = new TriggerChatTransport({
809
- task: "my-chat-task",
810
- accessToken,
811
- sessions: { "chat-401": { publicAccessToken: "stale-pat" } },
812
- });
813
- const stream = await transport.sendMessages({
814
- trigger: "submit-message",
815
- chatId: "chat-401",
816
- messageId: undefined,
817
- messages: [createUserMessage("hi")],
818
- abortSignal: undefined,
819
- });
820
- await drainChunks(stream);
821
- expect(accessToken).toHaveBeenCalledWith({ chatId: "chat-401" });
822
- expect(appendCount).toBe(2);
823
- expect(appendAuth).toBe("Bearer fresh-pat");
824
- expect(transport.getSession("chat-401")?.publicAccessToken).toBe("fresh-pat");
825
- });
826
- });
827
- describe("stopGeneration", () => {
828
- it("posts {kind: stop} to .in/append and returns true", async () => {
829
- let stopBody;
830
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
831
- const urlStr = typeof url === "string" ? url : url.toString();
832
- if (isSessionStreamAppendUrl(urlStr)) {
833
- stopBody = JSON.parse(init.body);
834
- return defaultAppendResponse();
835
- }
836
- throw new Error(`Unexpected URL: ${urlStr}`);
837
- });
838
- const transport = new TriggerChatTransport({
839
- task: "my-chat-task",
840
- accessToken: () => "pat",
841
- sessions: { "chat-stop": { publicAccessToken: "p" } },
842
- });
843
- const ok = await transport.stopGeneration("chat-stop");
844
- expect(ok).toBe(true);
845
- expect(stopBody).toEqual({ kind: "stop" });
846
- });
847
- it("returns false when there is no session for the chatId", async () => {
848
- const transport = new TriggerChatTransport({
849
- task: "my-chat-task",
850
- accessToken: () => "pat",
851
- });
852
- const ok = await transport.stopGeneration("never-started");
853
- expect(ok).toBe(false);
854
- });
855
- });
856
- describe("sendAction", () => {
857
- it("posts an action chunk to .in/append and subscribes to .out", async () => {
858
- let actionBody;
859
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
860
- const urlStr = typeof url === "string" ? url : url.toString();
861
- if (isSessionStreamAppendUrl(urlStr)) {
862
- actionBody = JSON.parse(init.body);
863
- return defaultAppendResponse();
864
- }
865
- if (isSessionOutSubscribeUrl(urlStr))
866
- return defaultSseResponse();
867
- throw new Error(`Unexpected URL: ${urlStr}`);
868
- });
869
- const transport = new TriggerChatTransport({
870
- task: "my-chat-task",
871
- accessToken: () => "pat",
872
- sessions: { "chat-act": { publicAccessToken: "p" } },
873
- });
874
- const stream = await transport.sendAction("chat-act", { type: "undo" });
875
- await drainChunks(stream);
876
- expect(actionBody.kind).toBe("message");
877
- expect(actionBody.payload.trigger).toBe("action");
878
- expect(actionBody.payload.action).toEqual({ type: "undo" });
879
- });
880
- it("marks the session streaming and notifies before subscribing", async () => {
881
- global.fetch = vi.fn().mockImplementation(async (url) => {
882
- const urlStr = typeof url === "string" ? url : url.toString();
883
- if (isSessionStreamAppendUrl(urlStr)) {
884
- return new Response(JSON.stringify({ ok: true, seq: 7 }), { status: 200 });
885
- }
886
- if (isSessionOutSubscribeUrl(urlStr))
887
- return defaultSseResponse();
888
- throw new Error(`Unexpected URL: ${urlStr}`);
889
- });
890
- const onSessionChange = vi.fn();
891
- const transport = new TriggerChatTransport({
892
- task: "my-chat-task",
893
- accessToken: () => "pat",
894
- onSessionChange,
895
- sessions: { "chat-act-stream": { publicAccessToken: "p" } },
896
- });
897
- const stream = await transport.sendAction("chat-act-stream", { type: "undo" });
898
- // isStreaming:true must be observed during the action — otherwise a reload
899
- // mid-action sees a persisted isStreaming:false and never resumes.
900
- expect(onSessionChange.mock.calls.some(([, session]) => session && session.isStreaming === true && session.activeInputSeq === 7)).toBe(true);
901
- await drainChunks(stream);
902
- });
903
- });
904
- describe("append idempotency header", () => {
905
- it("the per-append X-Part-Id wins over a transport-wide headers override", async () => {
906
- let appendPartId;
907
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
908
- const urlStr = typeof url === "string" ? url : url.toString();
909
- if (isSessionStreamAppendUrl(urlStr)) {
910
- appendPartId = init.headers["X-Part-Id"];
911
- return defaultAppendResponse();
912
- }
913
- if (isSessionOutSubscribeUrl(urlStr))
914
- return defaultSseResponse();
915
- throw new Error(`Unexpected URL: ${urlStr}`);
916
- });
917
- const transport = new TriggerChatTransport({
918
- task: "my-chat-task",
919
- accessToken: () => "pat",
920
- headers: { "X-Part-Id": "STATIC-OVERRIDE" },
921
- sessions: { "chat-hdr": { publicAccessToken: "p" } },
922
- });
923
- const stream = await transport.sendMessages({
924
- trigger: "submit-message",
925
- chatId: "chat-hdr",
926
- messageId: undefined,
927
- messages: [createUserMessage("hi")],
928
- abortSignal: undefined,
929
- });
930
- await drainChunks(stream);
931
- expect(appendPartId).toBeDefined();
932
- expect(appendPartId).not.toBe("STATIC-OVERRIDE");
933
- });
934
- });
935
- describe("reconnectToStream", () => {
936
- it("returns null when no session exists", async () => {
937
- const transport = new TriggerChatTransport({
938
- task: "my-chat-task",
939
- accessToken: () => "pat",
940
- });
941
- const result = await transport.reconnectToStream({ chatId: "missing" });
942
- expect(result).toBeNull();
943
- });
944
- it("returns null when the session is hydrated with isStreaming=false", async () => {
945
- const transport = new TriggerChatTransport({
946
- task: "my-chat-task",
947
- accessToken: () => "pat",
948
- sessions: {
949
- "chat-rc": { publicAccessToken: "p", isStreaming: false },
950
- },
951
- });
952
- const result = await transport.reconnectToStream({ chatId: "chat-rc" });
953
- expect(result).toBeNull();
954
- });
955
- it("resumes in watch mode when the session is hydrated with isStreaming=false", async () => {
956
- let subscribeCount = 0;
957
- global.fetch = vi.fn().mockImplementation(async (url) => {
958
- const urlStr = typeof url === "string" ? url : url.toString();
959
- if (isSessionOutSubscribeUrl(urlStr)) {
960
- subscribeCount++;
961
- const response = defaultSseResponse([{ type: "text-delta", id: "p1", delta: "turn2" }]);
962
- const headers = new Headers(response.headers);
963
- headers.set("X-Session-Settled", "true");
964
- return new Response(response.body, { status: 200, headers });
965
- }
966
- throw new Error(`Unexpected URL: ${urlStr}`);
967
- });
968
- const transport = new TriggerChatTransport({
969
- task: "my-chat-task",
970
- accessToken: () => "pat",
971
- watch: true,
972
- sessions: {
973
- "chat-rc-watch": { publicAccessToken: "p", isStreaming: false },
974
- },
975
- });
976
- const stream = await transport.reconnectToStream({ chatId: "chat-rc-watch" });
977
- expect(stream).not.toBeNull();
978
- const chunks = await drainChunks(stream);
979
- expect(subscribeCount).toBe(1);
980
- expect(chunks).toEqual([{ type: "text-delta", id: "p1", delta: "turn2" }]);
981
- });
982
- it("opens an SSE subscription with the X-Peek-Settled header set", async () => {
983
- let subscribeHeaders;
984
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
985
- const urlStr = typeof url === "string" ? url : url.toString();
986
- if (isSessionOutSubscribeUrl(urlStr)) {
987
- subscribeHeaders = new Headers(init?.headers);
988
- return defaultSseResponse();
989
- }
990
- throw new Error(`Unexpected URL: ${urlStr}`);
991
- });
992
- const transport = new TriggerChatTransport({
993
- task: "my-chat-task",
994
- accessToken: () => "pat",
995
- sessions: {
996
- "chat-rc-on": { publicAccessToken: "p", isStreaming: true },
997
- },
998
- });
999
- const stream = await transport.reconnectToStream({ chatId: "chat-rc-on" });
1000
- expect(stream).not.toBeNull();
1001
- await drainChunks(stream);
1002
- expect(subscribeHeaders?.get("X-Peek-Settled")).toBe("1");
1003
- });
1004
- });
1005
- describe("stream body ends mid-turn", () => {
1006
- it("resubscribes from the last event id when the close was not settled", async () => {
1007
- const subscribeHeaders = [];
1008
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
1009
- const urlStr = typeof url === "string" ? url : url.toString();
1010
- if (isSessionStreamAppendUrl(urlStr))
1011
- return defaultAppendResponse();
1012
- if (isSessionOutSubscribeUrl(urlStr)) {
1013
- subscribeHeaders.push(new Headers(init?.headers));
1014
- // First connection ends mid-turn: one chunk, no turn-complete,
1015
- // no `X-Session-Settled`.
1016
- return subscribeHeaders.length === 1
1017
- ? defaultSseResponse([{ type: "text-start", id: "part-1" }])
1018
- : defaultSseResponse([
1019
- { type: "text-delta", id: "part-1", delta: "resumed" },
1020
- { type: "trigger:turn-complete" },
1021
- ]);
1022
- }
1023
- throw new Error(`Unexpected URL: ${urlStr}`);
1024
- });
1025
- const transport = new TriggerChatTransport({
1026
- task: "my-chat-task",
1027
- accessToken: () => "pat",
1028
- sessions: { "chat-eof": { publicAccessToken: "p" } },
1029
- });
1030
- const stream = await transport.sendMessages({
1031
- trigger: "submit-message",
1032
- chatId: "chat-eof",
1033
- messageId: undefined,
1034
- messages: [createUserMessage("hi")],
1035
- abortSignal: undefined,
1036
- });
1037
- const chunks = await drainChunks(stream);
1038
- expect(subscribeHeaders).toHaveLength(2);
1039
- expect(subscribeHeaders[1]?.get("Last-Event-ID")).toBe("1");
1040
- expect(chunks).toEqual([
1041
- { type: "text-start", id: "part-1" },
1042
- { type: "text-delta", id: "part-1", delta: "resumed" },
1043
- ]);
1044
- expect(transport.getSession("chat-eof")?.isStreaming).toBe(false);
1045
- });
1046
- it("stops and clears isStreaming when the close was settled", async () => {
1047
- let subscribeCount = 0;
1048
- global.fetch = vi.fn().mockImplementation(async (url) => {
1049
- const urlStr = typeof url === "string" ? url : url.toString();
1050
- if (isSessionStreamAppendUrl(urlStr))
1051
- return defaultAppendResponse();
1052
- if (isSessionOutSubscribeUrl(urlStr)) {
1053
- subscribeCount++;
1054
- const response = defaultSseResponse([{ type: "text-start", id: "part-1" }]);
1055
- const headers = new Headers(response.headers);
1056
- headers.set("X-Session-Settled", "true");
1057
- return new Response(response.body, { status: 200, headers });
1058
- }
1059
- throw new Error(`Unexpected URL: ${urlStr}`);
1060
- });
1061
- const onSessionChange = vi.fn();
1062
- const transport = new TriggerChatTransport({
1063
- task: "my-chat-task",
1064
- accessToken: () => "pat",
1065
- onSessionChange,
1066
- sessions: { "chat-settled": { publicAccessToken: "p" } },
1067
- });
1068
- const stream = await transport.sendMessages({
1069
- trigger: "submit-message",
1070
- chatId: "chat-settled",
1071
- messageId: undefined,
1072
- messages: [createUserMessage("hi")],
1073
- abortSignal: undefined,
1074
- });
1075
- await drainChunks(stream);
1076
- expect(subscribeCount).toBe(1);
1077
- expect(transport.getSession("chat-settled")?.isStreaming).toBe(false);
1078
- expect(onSessionChange.mock.calls.some(([, session]) => session && session.isStreaming === false)).toBe(true);
1079
- });
1080
- it("keeps streaming when every window delivers a single record", async () => {
1081
- // One record per window arrives via `primed` on the resumed connection —
1082
- // it must still re-earn the budget, otherwise a slow turn is truncated.
1083
- const WINDOWS = 8;
1084
- let subscribeCount = 0;
1085
- global.fetch = vi.fn().mockImplementation(async (url) => {
1086
- const urlStr = typeof url === "string" ? url : url.toString();
1087
- if (isSessionStreamAppendUrl(urlStr))
1088
- return defaultAppendResponse();
1089
- if (isSessionOutSubscribeUrl(urlStr)) {
1090
- subscribeCount++;
1091
- return subscribeCount > WINDOWS
1092
- ? defaultSseResponse([{ type: "trigger:turn-complete" }])
1093
- : defaultSseResponse([
1094
- { type: "text-delta", id: "part-1", delta: `d${subscribeCount}` },
1095
- ]);
1096
- }
1097
- throw new Error(`Unexpected URL: ${urlStr}`);
1098
- });
1099
- const transport = new TriggerChatTransport({
1100
- task: "my-chat-task",
1101
- accessToken: () => "pat",
1102
- sessions: { "chat-slow": { publicAccessToken: "p" } },
1103
- });
1104
- const stream = await transport.sendMessages({
1105
- trigger: "submit-message",
1106
- chatId: "chat-slow",
1107
- messageId: undefined,
1108
- messages: [createUserMessage("hi")],
1109
- abortSignal: undefined,
1110
- });
1111
- const chunks = await drainChunks(stream);
1112
- expect(subscribeCount).toBe(WINDOWS + 1);
1113
- expect(chunks).toHaveLength(WINDOWS);
1114
- expect(transport.getSession("chat-slow")?.isStreaming).toBe(false);
1115
- });
1116
- it("surfaces an error after the resubscribe budget is exhausted", async () => {
1117
- // Fake timers so the 100ms..1.6s backoffs don't cost real seconds.
1118
- vi.useFakeTimers();
1119
- try {
1120
- let subscribeCount = 0;
1121
- global.fetch = vi.fn().mockImplementation(async (url) => {
1122
- const urlStr = typeof url === "string" ? url : url.toString();
1123
- if (isSessionStreamAppendUrl(urlStr))
1124
- return defaultAppendResponse();
1125
- if (isSessionOutSubscribeUrl(urlStr)) {
1126
- subscribeCount++;
1127
- // Never any records, never settled — the pathological case.
1128
- return defaultSseResponse([]);
1129
- }
1130
- throw new Error(`Unexpected URL: ${urlStr}`);
1131
- });
1132
- const transport = new TriggerChatTransport({
1133
- task: "my-chat-task",
1134
- accessToken: () => "pat",
1135
- sessions: { "chat-empty": { publicAccessToken: "p" } },
1136
- });
1137
- const stream = await transport.sendMessages({
1138
- trigger: "submit-message",
1139
- chatId: "chat-empty",
1140
- messageId: undefined,
1141
- messages: [createUserMessage("hi")],
1142
- abortSignal: undefined,
1143
- });
1144
- // A cut-off turn surfaces an error rather than reading as complete.
1145
- // Attach the rejection assertion before advancing timers so the
1146
- // rejection is never unhandled.
1147
- const drained = drainChunks(stream);
1148
- const rejects = expect(drained).rejects.toThrow(/reconnect budget exhausted/i);
1149
- await vi.advanceTimersByTimeAsync(10_000);
1150
- await rejects;
1151
- // One initial connect plus the five-attempt resubscribe budget.
1152
- expect(subscribeCount).toBe(6);
1153
- // State is cleared before the throw, so a reload won't reopen a
1154
- // doomed subscription.
1155
- expect(transport.getSession("chat-empty")?.isStreaming).toBe(false);
1156
- }
1157
- finally {
1158
- vi.useRealTimers();
1159
- }
1160
- });
1161
- });
1162
- describe("watch mode across long-poll window boundaries", () => {
1163
- function settled(response) {
1164
- const headers = new Headers(response.headers);
1165
- headers.set("X-Session-Settled", "true");
1166
- return new Response(response.body, { status: 200, headers });
1167
- }
1168
- it("resubscribes after a completed turn and receives a later wake", async () => {
1169
- let subscribeCount = 0;
1170
- global.fetch = vi.fn().mockImplementation(async (url) => {
1171
- const urlStr = typeof url === "string" ? url : url.toString();
1172
- if (isSessionOutSubscribeUrl(urlStr)) {
1173
- subscribeCount++;
1174
- // Window 1: a turn completes, then the body EOFs with no
1175
- // settled header — the quiet long-poll boundary.
1176
- return subscribeCount === 1
1177
- ? defaultSseResponse([
1178
- { type: "text-delta", id: "p1", delta: "turn1" },
1179
- { type: "trigger:turn-complete" },
1180
- ])
1181
- : settled(defaultSseResponse([{ type: "text-delta", id: "p2", delta: "wake" }]));
1182
- }
1183
- throw new Error(`Unexpected URL: ${urlStr}`);
1184
- });
1185
- const transport = new TriggerChatTransport({
1186
- task: "my-chat-task",
1187
- accessToken: () => "pat",
1188
- watch: true,
1189
- sessions: { "chat-watch-eof": { publicAccessToken: "p", isStreaming: true } },
1190
- });
1191
- const stream = await transport.reconnectToStream({ chatId: "chat-watch-eof" });
1192
- const chunks = await drainChunks(stream);
1193
- expect(subscribeCount).toBe(2);
1194
- expect(chunks).toEqual([
1195
- { type: "text-delta", id: "p1", delta: "turn1" },
1196
- { type: "text-delta", id: "p2", delta: "wake" },
1197
- ]);
1198
- });
1199
- it("does not peek-settle an idle resubscribe, so the next turn is delivered", async () => {
1200
- // Watch mode must NOT send X-Peek-Settled between turns: a settled peek
1201
- // while no turn is in flight closes the standing subscription and the
1202
- // viewer never sees turn 2. This mock plays the server's peek shortcut —
1203
- // a peek request with nothing in flight settles — to prove the transport
1204
- // long-polls instead.
1205
- const subscribeHeaders = [];
1206
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
1207
- const urlStr = typeof url === "string" ? url : url.toString();
1208
- if (isSessionOutSubscribeUrl(urlStr)) {
1209
- subscribeHeaders.push(new Headers(init?.headers));
1210
- const n = subscribeHeaders.length;
1211
- if (n === 1) {
1212
- // Turn 1 completes, then the body EOFs (no settled header).
1213
- return defaultSseResponse([
1214
- { type: "text-delta", id: "p1", delta: "turn1" },
1215
- { type: "trigger:turn-complete" },
1216
- ]);
1217
- }
1218
- if (n === 2) {
1219
- // Idle resubscribe. If it peeked, the server settles and the
1220
- // subscription would close before turn 2; a long-poll delivers it.
1221
- if (init && new Headers(init.headers).get("X-Peek-Settled")) {
1222
- return settled(defaultSseResponse([]));
1223
- }
1224
- return defaultSseResponse([
1225
- { type: "text-delta", id: "p2", delta: "turn2" },
1226
- { type: "trigger:turn-complete" },
1227
- ]);
1228
- }
1229
- // Turn 2 done — end the watch cleanly.
1230
- return settled(defaultSseResponse([]));
1231
- }
1232
- throw new Error(`Unexpected URL: ${urlStr}`);
1233
- });
1234
- const transport = new TriggerChatTransport({
1235
- task: "my-chat-task",
1236
- accessToken: () => "pat",
1237
- watch: true,
1238
- sessions: { "chat-watch-turn2": { publicAccessToken: "p", isStreaming: true } },
1239
- });
1240
- const stream = await transport.reconnectToStream({ chatId: "chat-watch-turn2" });
1241
- const chunks = await drainChunks(stream);
1242
- expect(subscribeHeaders[1]?.get("X-Peek-Settled")).toBeNull();
1243
- expect(chunks).toEqual([
1244
- { type: "text-delta", id: "p1", delta: "turn1" },
1245
- { type: "text-delta", id: "p2", delta: "turn2" },
1246
- ]);
1247
- });
1248
- it("cancelling the reader stops the resubscribe loop", async () => {
1249
- // A consumer that stops reading without aborting must not leak the
1250
- // resubscribe loop — the stream's cancel() aborts it.
1251
- vi.useFakeTimers();
1252
- try {
1253
- let subscribeCount = 0;
1254
- global.fetch = vi.fn().mockImplementation(async (url) => {
1255
- const urlStr = typeof url === "string" ? url : url.toString();
1256
- if (isSessionOutSubscribeUrl(urlStr)) {
1257
- subscribeCount++;
1258
- // Quiet: EOF, no records, never settled — watch keeps resubscribing.
1259
- return defaultSseResponse([]);
1260
- }
1261
- throw new Error(`Unexpected URL: ${urlStr}`);
1262
- });
1263
- const events = [];
1264
- const transport = new TriggerChatTransport({
1265
- task: "my-chat-task",
1266
- accessToken: () => "pat",
1267
- watch: true,
1268
- onEvent: (e) => events.push(e),
1269
- sessions: { "chat-watch-cancel": { publicAccessToken: "p", isStreaming: true } },
1270
- });
1271
- const stream = await transport.reconnectToStream({ chatId: "chat-watch-cancel" });
1272
- const reader = stream.getReader();
1273
- await vi.advanceTimersByTimeAsync(10_000);
1274
- expect(subscribeCount).toBeGreaterThan(1);
1275
- const countAtCancel = subscribeCount;
1276
- await reader.cancel();
1277
- await vi.advanceTimersByTimeAsync(10_000);
1278
- expect(subscribeCount).toBe(countAtCancel);
1279
- // A clean cancel must not surface a spurious stream-error (an
1280
- // unguarded controller.close() after cancel would throw "Invalid
1281
- // state" and leak it onto the telemetry channel).
1282
- expect(events.some((e) => e.type === "stream-error")).toBe(false);
1283
- }
1284
- finally {
1285
- vi.useRealTimers();
1286
- }
1287
- });
1288
- it("stops when the server says the session settled", async () => {
1289
- let subscribeCount = 0;
1290
- global.fetch = vi.fn().mockImplementation(async (url) => {
1291
- const urlStr = typeof url === "string" ? url : url.toString();
1292
- if (isSessionOutSubscribeUrl(urlStr)) {
1293
- subscribeCount++;
1294
- return settled(defaultSseResponse([
1295
- { type: "text-delta", id: "p1", delta: "last" },
1296
- { type: "trigger:turn-complete" },
1297
- ]));
1298
- }
1299
- throw new Error(`Unexpected URL: ${urlStr}`);
1300
- });
1301
- const transport = new TriggerChatTransport({
1302
- task: "my-chat-task",
1303
- accessToken: () => "pat",
1304
- watch: true,
1305
- sessions: { "chat-watch-settled": { publicAccessToken: "p", isStreaming: true } },
1306
- });
1307
- const stream = await transport.reconnectToStream({ chatId: "chat-watch-settled" });
1308
- const chunks = await drainChunks(stream);
1309
- expect(subscribeCount).toBe(1);
1310
- expect(chunks).toHaveLength(1);
1311
- expect(transport.getSession("chat-watch-settled")?.isStreaming).toBe(false);
1312
- });
1313
- it("stops promptly when aborted during backoff", async () => {
1314
- vi.useFakeTimers();
1315
- try {
1316
- let subscribeCount = 0;
1317
- global.fetch = vi.fn().mockImplementation(async (url) => {
1318
- const urlStr = typeof url === "string" ? url : url.toString();
1319
- if (isSessionStreamAppendUrl(urlStr))
1320
- return defaultAppendResponse();
1321
- if (isSessionOutSubscribeUrl(urlStr)) {
1322
- subscribeCount++;
1323
- // Every window is quiet: EOF with no records, never settled.
1324
- return defaultSseResponse([]);
1325
- }
1326
- throw new Error(`Unexpected URL: ${urlStr}`);
1327
- });
1328
- const abortController = new AbortController();
1329
- const transport = new TriggerChatTransport({
1330
- task: "my-chat-task",
1331
- accessToken: () => "pat",
1332
- watch: true,
1333
- sessions: { "chat-watch-abort": { publicAccessToken: "p", isStreaming: true } },
1334
- });
1335
- const stream = await transport.reconnectToStream({
1336
- chatId: "chat-watch-abort",
1337
- abortSignal: abortController.signal,
1338
- });
1339
- const drained = drainChunks(stream);
1340
- await vi.advanceTimersByTimeAsync(10_000);
1341
- // The budget doesn't apply in watch mode, so it is still reconnecting.
1342
- expect(subscribeCount).toBeGreaterThan(6);
1343
- const countAtAbort = subscribeCount;
1344
- abortController.abort();
1345
- await drained;
1346
- await vi.advanceTimersByTimeAsync(10_000);
1347
- expect(subscribeCount).toBe(countAtAbort);
1348
- }
1349
- finally {
1350
- vi.useRealTimers();
1351
- }
1352
- });
1353
- });
1354
- describe("reconnectToStream stop-on-abort ownership (TRI-13070)", () => {
1355
- // A quiet stream: EOF, no records, never settled — the subscription
1356
- // stays alive (watch mode) so an abort mid-flight exercises the stop path.
1357
- function quietWatchTransport() {
1358
- let appendCount = 0;
1359
- global.fetch = vi.fn().mockImplementation(async (url) => {
1360
- const urlStr = typeof url === "string" ? url : url.toString();
1361
- if (isSessionStreamAppendUrl(urlStr)) {
1362
- appendCount++;
1363
- return defaultAppendResponse();
1364
- }
1365
- if (isSessionOutSubscribeUrl(urlStr))
1366
- return defaultSseResponse([]);
1367
- throw new Error(`Unexpected URL: ${urlStr}`);
1368
- });
1369
- const transport = new TriggerChatTransport({
1370
- task: "my-chat-task",
1371
- accessToken: () => "pat",
1372
- watch: true,
1373
- sessions: { "chat-own": { publicAccessToken: "p", isStreaming: true } },
1374
- });
1375
- return { transport, appends: () => appendCount };
1376
- }
1377
- it("passive subscriber aborting writes no stop chunk to .in", async () => {
1378
- vi.useFakeTimers();
1379
- try {
1380
- const { transport, appends } = quietWatchTransport();
1381
- const abort = new AbortController();
1382
- const stream = await transport.reconnectToStream({
1383
- chatId: "chat-own",
1384
- abortSignal: abort.signal,
1385
- });
1386
- const drained = drainChunks(stream);
1387
- await vi.advanceTimersByTimeAsync(1_000);
1388
- abort.abort();
1389
- await drained;
1390
- await vi.advanceTimersByTimeAsync(1_000);
1391
- expect(appends()).toBe(0);
1392
- }
1393
- finally {
1394
- vi.useRealTimers();
1395
- }
1396
- });
1397
- it("owning subscriber with stopOnAbort:true sends a stop chunk on abort", async () => {
1398
- vi.useFakeTimers();
1399
- try {
1400
- const { transport, appends } = quietWatchTransport();
1401
- const abort = new AbortController();
1402
- const stream = await transport.reconnectToStream({
1403
- chatId: "chat-own",
1404
- abortSignal: abort.signal,
1405
- stopOnAbort: true,
1406
- });
1407
- const drained = drainChunks(stream);
1408
- await vi.advanceTimersByTimeAsync(1_000);
1409
- abort.abort();
1410
- await drained;
1411
- await vi.advanceTimersByTimeAsync(1_000);
1412
- expect(appends()).toBe(1);
1413
- }
1414
- finally {
1415
- vi.useRealTimers();
1416
- }
1417
- });
1418
- it("abortSignal presence alone (stopOnAbort unset) sends no stop", async () => {
1419
- vi.useFakeTimers();
1420
- try {
1421
- const { transport, appends } = quietWatchTransport();
1422
- const abort = new AbortController();
1423
- const stream = await transport.reconnectToStream({
1424
- chatId: "chat-own",
1425
- abortSignal: abort.signal,
1426
- });
1427
- const drained = drainChunks(stream);
1428
- await vi.advanceTimersByTimeAsync(1_000);
1429
- abort.abort();
1430
- await drained;
1431
- await vi.advanceTimersByTimeAsync(1_000);
1432
- expect(appends()).toBe(0);
1433
- }
1434
- finally {
1435
- vi.useRealTimers();
1436
- }
1437
- });
1438
- });
1439
- describe("superseded stream teardown", () => {
1440
- it("keeps the successor's controller registered when the aborted stream tears down", async () => {
1441
- vi.useFakeTimers();
1442
- try {
1443
- let appendCount = 0;
1444
- global.fetch = vi.fn().mockImplementation(async (url) => {
1445
- const urlStr = typeof url === "string" ? url : url.toString();
1446
- if (isSessionStreamAppendUrl(urlStr)) {
1447
- appendCount++;
1448
- return defaultAppendResponse();
1449
- }
1450
- // Quiet stream: EOF, no records, never settled — watch keeps it open.
1451
- if (isSessionOutSubscribeUrl(urlStr))
1452
- return defaultSseResponse([]);
1453
- throw new Error(`Unexpected URL: ${urlStr}`);
1454
- });
1455
- const transport = new TriggerChatTransport({
1456
- task: "my-chat-task",
1457
- accessToken: () => "pat",
1458
- watch: true,
1459
- sessions: { "chat-race": { publicAccessToken: "p", isStreaming: true } },
1460
- });
1461
- const send = () => transport.sendMessages({
1462
- trigger: "submit-message",
1463
- chatId: "chat-race",
1464
- messageId: undefined,
1465
- messages: [createUserMessage("hi")],
1466
- abortSignal: undefined,
1467
- });
1468
- const first = drainChunks(await send());
1469
- await vi.advanceTimersByTimeAsync(1_000);
1470
- // Supersede: the new stream registers its controller synchronously,
1471
- // the aborted one tears down a microtask later.
1472
- const second = await send();
1473
- let secondClosed = false;
1474
- const secondDrain = drainChunks(second).then(() => {
1475
- secondClosed = true;
1476
- });
1477
- await first;
1478
- await vi.advanceTimersByTimeAsync(1_000);
1479
- // stopGeneration posts the stop chunk either way — only the
1480
- // closing assertion proves it found the successor to abort.
1481
- appendCount = 0;
1482
- expect(await transport.stopGeneration("chat-race")).toBe(true);
1483
- await vi.advanceTimersByTimeAsync(1_000);
1484
- expect(appendCount).toBe(1);
1485
- expect(secondClosed).toBe(true);
1486
- transport.dispose();
1487
- await secondDrain;
1488
- }
1489
- finally {
1490
- vi.useRealTimers();
1491
- }
1492
- });
1493
- it("keeps the tab claim the successor took (multi-tab)", async () => {
1494
- vi.useFakeTimers();
1495
- try {
1496
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
1497
- const urlStr = typeof url === "string" ? url : url.toString();
1498
- if (isSessionStreamAppendUrl(urlStr))
1499
- return defaultAppendResponse();
1500
- // Open SSE that only ends when the subscription is aborted, so
1501
- // the superseded stream tears down while the successor is live.
1502
- if (isSessionOutSubscribeUrl(urlStr))
1503
- return openSseResponse(init?.signal);
1504
- throw new Error(`Unexpected URL: ${urlStr}`);
1505
- });
1506
- const transport = new TriggerChatTransport({
1507
- task: "my-chat-task",
1508
- accessToken: () => "pat",
1509
- multiTab: true,
1510
- sessions: { "chat-race-tab": { publicAccessToken: "p", isStreaming: true } },
1511
- });
1512
- const send = () => transport.sendMessages({
1513
- trigger: "submit-message",
1514
- chatId: "chat-race-tab",
1515
- messageId: undefined,
1516
- messages: [createUserMessage("hi")],
1517
- abortSignal: undefined,
1518
- });
1519
- const first = drainChunks(await send());
1520
- await vi.advanceTimersByTimeAsync(1_000);
1521
- const secondDrain = drainChunks(await send());
1522
- await first;
1523
- await vi.advanceTimersByTimeAsync(1_000);
1524
- // The superseded stream must not release the claim its successor
1525
- // holds — otherwise this tab flips to read-only mid-turn.
1526
- expect(transport.hasClaim("chat-race-tab")).toBe(true);
1527
- transport.dispose();
1528
- await secondDrain;
1529
- }
1530
- finally {
1531
- vi.useRealTimers();
1532
- }
1533
- });
1534
- it("releases the tab claim when the user stops generation (multi-tab)", async () => {
1535
- vi.useFakeTimers();
1536
- try {
1537
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
1538
- const urlStr = typeof url === "string" ? url : url.toString();
1539
- if (isSessionStreamAppendUrl(urlStr))
1540
- return defaultAppendResponse();
1541
- if (isSessionOutSubscribeUrl(urlStr))
1542
- return openSseResponse(init?.signal);
1543
- throw new Error(`Unexpected URL: ${urlStr}`);
1544
- });
1545
- const transport = new TriggerChatTransport({
1546
- task: "my-chat-task",
1547
- accessToken: () => "pat",
1548
- multiTab: true,
1549
- sessions: { "chat-stop-tab": { publicAccessToken: "p", isStreaming: true } },
1550
- });
1551
- const drain = drainChunks(await transport.sendMessages({
1552
- trigger: "submit-message",
1553
- chatId: "chat-stop-tab",
1554
- messageId: undefined,
1555
- messages: [createUserMessage("hi")],
1556
- abortSignal: undefined,
1557
- }));
1558
- await vi.advanceTimersByTimeAsync(1_000);
1559
- expect(transport.hasClaim("chat-stop-tab")).toBe(true);
1560
- expect(await transport.stopGeneration("chat-stop-tab")).toBe(true);
1561
- await vi.advanceTimersByTimeAsync(1_000);
1562
- // The turn ends here with no successor stream, so the claim must be
1563
- // freed or other tabs stay read-only until this one closes.
1564
- expect(transport.hasClaim("chat-stop-tab")).toBe(false);
1565
- transport.dispose();
1566
- await drain;
1567
- }
1568
- finally {
1569
- vi.useRealTimers();
1570
- }
1571
- });
1572
- });
1573
- describe("multi-tab coordination", () => {
1574
- it("isReadOnly defaults to false when multiTab is disabled", () => {
1575
- const transport = new TriggerChatTransport({
1576
- task: "my-chat-task",
1577
- accessToken: () => "pat",
1578
- });
1579
- expect(transport.isReadOnly("any-chat")).toBe(false);
1580
- expect(transport.hasClaim("any-chat")).toBe(false);
1581
- });
1582
- });
1583
- describe("endpoint (chat.handover routing)", () => {
1584
- /**
1585
- * Encode UIMessageChunks the same way the chat-server.ts handler
1586
- * does: `data: <JSON>\n\n` per chunk. The transport's
1587
- * `parseUIMessageSseTransform` parses this back into chunk objects.
1588
- */
1589
- function handoverSseBody(chunks) {
1590
- const encoder = new TextEncoder();
1591
- return new ReadableStream({
1592
- start(controller) {
1593
- for (const chunk of chunks) {
1594
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
1595
- }
1596
- controller.close();
1597
- },
1598
- });
1599
- }
1600
- function handoverResponse(args) {
1601
- return new Response(handoverSseBody(args.chunks), {
1602
- status: 200,
1603
- headers: {
1604
- "content-type": "text/event-stream",
1605
- "X-Trigger-Chat-Id": args.chatId,
1606
- "X-Trigger-Chat-Access-Token": args.accessToken,
1607
- },
1608
- });
1609
- }
1610
- it("first-turn POSTs the wire payload to endpoint when no session exists", async () => {
1611
- const requests = [];
1612
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
1613
- const urlStr = typeof url === "string" ? url : url.toString();
1614
- requests.push({ url: urlStr, init });
1615
- if (urlStr === "https://my-app.example/api/chat") {
1616
- return handoverResponse({
1617
- chatId: "chat-handover-1",
1618
- accessToken: "handover-pat-1",
1619
- chunks: sampleChunks,
1620
- });
1621
- }
1622
- throw new Error(`Unexpected URL: ${urlStr}`);
1623
- });
1624
- const transport = new TriggerChatTransport({
1625
- task: "my-chat-task",
1626
- accessToken: () => "pat",
1627
- headStart: "https://my-app.example/api/chat",
1628
- });
1629
- const stream = await transport.sendMessages({
1630
- trigger: "submit-message",
1631
- chatId: "chat-handover-1",
1632
- messageId: "m1",
1633
- messages: [createUserMessage("hello")],
1634
- abortSignal: undefined,
1635
- });
1636
- const chunks = await drainChunks(stream);
1637
- // Chunks were forwarded from the handler's SSE body unchanged.
1638
- expect(chunks).toEqual(sampleChunks);
1639
- // Only the endpoint was called — no /api/v1/sessions, no .in/append,
1640
- // no .out subscribe. The handler owns first-turn end-to-end.
1641
- const endpointPosts = requests.filter((r) => r.url === "https://my-app.example/api/chat");
1642
- expect(endpointPosts).toHaveLength(1);
1643
- expect(requests.some((r) => isSessionCreateUrl(r.url))).toBe(false);
1644
- expect(requests.some((r) => isSessionStreamAppendUrl(r.url))).toBe(false);
1645
- expect(requests.some((r) => isSessionOutSubscribeUrl(r.url))).toBe(false);
1646
- // Body shape: head-start wire payload. Full UIMessage history is
1647
- // shipped via `headStartMessages` (this is the one path that still
1648
- // ships full history — the route handler runs against the customer's
1649
- // own HTTP endpoint, not /in/append, so the 512 KiB cap doesn't
1650
- // apply). The `message` field is omitted on this path.
1651
- const body = JSON.parse(endpointPosts[0].init.body);
1652
- expect(body.chatId).toBe("chat-handover-1");
1653
- expect(body.trigger).toBe("submit-message");
1654
- expect(body.messageId).toBe("m1");
1655
- expect(body.headStartMessages).toHaveLength(1);
1656
- expect(body.message).toBeUndefined();
1657
- expect(body.messages).toBeUndefined();
1658
- });
1659
- it("hydrates session state from response headers so subsequent turns bypass the endpoint", async () => {
1660
- const requests = [];
1661
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
1662
- const urlStr = typeof url === "string" ? url : url.toString();
1663
- requests.push({ url: urlStr, init });
1664
- if (urlStr === "https://my-app.example/api/chat") {
1665
- return handoverResponse({
1666
- chatId: "chat-handover-2",
1667
- accessToken: "handover-pat-2",
1668
- chunks: sampleChunks,
1669
- });
1670
- }
1671
- if (isSessionStreamAppendUrl(urlStr))
1672
- return defaultAppendResponse();
1673
- if (isSessionOutSubscribeUrl(urlStr))
1674
- return defaultSseResponse();
1675
- throw new Error(`Unexpected URL: ${urlStr}`);
1676
- });
1677
- const onSessionChange = vi.fn();
1678
- const transport = new TriggerChatTransport({
1679
- task: "my-chat-task",
1680
- accessToken: () => "fallback-pat",
1681
- headStart: "https://my-app.example/api/chat",
1682
- onSessionChange,
1683
- });
1684
- // Turn 1 — POSTs to endpoint, hydrates session.
1685
- await drainChunks(await transport.sendMessages({
1686
- trigger: "submit-message",
1687
- chatId: "chat-handover-2",
1688
- messageId: "m1",
1689
- messages: [createUserMessage("first")],
1690
- abortSignal: undefined,
1691
- }));
1692
- const hydrated = transport.getSession("chat-handover-2");
1693
- expect(hydrated).toBeDefined();
1694
- expect(hydrated.publicAccessToken).toBe("handover-pat-2");
1695
- expect(onSessionChange).toHaveBeenCalledWith("chat-handover-2", expect.objectContaining({ publicAccessToken: "handover-pat-2" }));
1696
- // Turn 2 — bypass endpoint, write directly to .in.
1697
- requests.length = 0;
1698
- const turn2Stream = await transport.sendMessages({
1699
- trigger: "submit-message",
1700
- chatId: "chat-handover-2",
1701
- messageId: "m2",
1702
- messages: [createUserMessage("second")],
1703
- abortSignal: undefined,
1704
- });
1705
- expect(requests.some((r) => r.url === "https://my-app.example/api/chat")).toBe(false);
1706
- const append = requests.find((r) => isSessionStreamAppendUrl(r.url) && r.url.endsWith("/in/append"));
1707
- expect(append).toBeDefined();
1708
- expect(chatIdFromUrl(append.url)).toBe("chat-handover-2");
1709
- // Drain after asserting append — `.out` is subscribed lazily when the
1710
- // returned stream is read.
1711
- await drainChunks(turn2Stream);
1712
- const subscribe = requests.find((r) => isSessionOutSubscribeUrl(r.url));
1713
- expect(subscribe).toBeDefined();
1714
- });
1715
- it("bypasses endpoint when a session is already hydrated (page reload after first turn)", async () => {
1716
- const requests = [];
1717
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
1718
- const urlStr = typeof url === "string" ? url : url.toString();
1719
- requests.push({ url: urlStr, init });
1720
- if (isSessionStreamAppendUrl(urlStr))
1721
- return defaultAppendResponse();
1722
- if (isSessionOutSubscribeUrl(urlStr))
1723
- return defaultSseResponse();
1724
- throw new Error(`Unexpected URL: ${urlStr}`);
1725
- });
1726
- const transport = new TriggerChatTransport({
1727
- task: "my-chat-task",
1728
- accessToken: () => "pat",
1729
- headStart: "https://my-app.example/api/chat",
1730
- sessions: {
1731
- "chat-resumed": { publicAccessToken: "persisted-pat" },
1732
- },
1733
- });
1734
- await drainChunks(await transport.sendMessages({
1735
- trigger: "submit-message",
1736
- chatId: "chat-resumed",
1737
- messageId: undefined,
1738
- messages: [createUserMessage("hi again")],
1739
- abortSignal: undefined,
1740
- }));
1741
- expect(requests.some((r) => r.url === "https://my-app.example/api/chat")).toBe(false);
1742
- expect(requests.some((r) => isSessionStreamAppendUrl(r.url))).toBe(true);
1743
- });
1744
- it("propagates a non-2xx response from the endpoint as an error", async () => {
1745
- global.fetch = vi.fn().mockImplementation(async (url) => {
1746
- const urlStr = typeof url === "string" ? url : url.toString();
1747
- if (urlStr === "https://my-app.example/api/chat") {
1748
- return new Response(null, { status: 500, statusText: "Internal Server Error" });
1749
- }
1750
- throw new Error(`Unexpected URL: ${urlStr}`);
1751
- });
1752
- const transport = new TriggerChatTransport({
1753
- task: "my-chat-task",
1754
- accessToken: () => "pat",
1755
- headStart: "https://my-app.example/api/chat",
1756
- });
1757
- await expect(transport.sendMessages({
1758
- trigger: "submit-message",
1759
- chatId: "chat-handover-err",
1760
- messageId: undefined,
1761
- messages: [createUserMessage("oops")],
1762
- abortSignal: undefined,
1763
- })).rejects.toThrow(/500/);
1764
- });
1765
- it("leaves the legacy direct-trigger path unchanged when endpoint is unset", async () => {
1766
- const requests = [];
1767
- global.fetch = vi.fn().mockImplementation(async (url) => {
1768
- const urlStr = typeof url === "string" ? url : url.toString();
1769
- requests.push(urlStr);
1770
- if (isSessionStreamAppendUrl(urlStr))
1771
- return defaultAppendResponse();
1772
- if (isSessionOutSubscribeUrl(urlStr))
1773
- return defaultSseResponse();
1774
- throw new Error(`Unexpected URL: ${urlStr}`);
1775
- });
1776
- const transport = new TriggerChatTransport({
1777
- task: "my-chat-task",
1778
- accessToken: () => "pat",
1779
- // endpoint NOT set
1780
- sessions: { "chat-legacy": { publicAccessToken: "p" } },
1781
- });
1782
- await drainChunks(await transport.sendMessages({
1783
- trigger: "submit-message",
1784
- chatId: "chat-legacy",
1785
- messageId: undefined,
1786
- messages: [createUserMessage("legacy")],
1787
- abortSignal: undefined,
1788
- }));
1789
- // No POST to /api/chat anywhere.
1790
- expect(requests.some((u) => u.endsWith("/api/chat"))).toBe(false);
1791
- expect(requests.some(isSessionStreamAppendUrl)).toBe(true);
1792
- expect(requests.some(isSessionOutSubscribeUrl)).toBe(true);
1793
- });
1794
- });
1795
- describe("watch mode", () => {
1796
- it("keeps the SSE open across trigger:turn-complete (multi-turn watch)", async () => {
1797
- const turn1 = [
1798
- { type: "text-delta", id: "p1", delta: "Hi" },
1799
- { type: "trigger:turn-complete" },
1800
- { type: "text-delta", id: "p2", delta: "Again" },
1801
- { type: "trigger:turn-complete" },
1802
- ];
1803
- let subscribeCount = 0;
1804
- global.fetch = vi.fn().mockImplementation(async (url) => {
1805
- const urlStr = typeof url === "string" ? url : url.toString();
1806
- if (isSessionOutSubscribeUrl(urlStr)) {
1807
- subscribeCount++;
1808
- if (subscribeCount === 1)
1809
- return defaultSseResponse(turn1);
1810
- // Watch mode reconnects past the body EOF; settle so the drain ends.
1811
- const response = defaultSseResponse([]);
1812
- const headers = new Headers(response.headers);
1813
- headers.set("X-Session-Settled", "true");
1814
- return new Response(response.body, { status: 200, headers });
1815
- }
1816
- throw new Error(`Unexpected URL: ${urlStr}`);
1817
- });
1818
- const transport = new TriggerChatTransport({
1819
- task: "my-chat-task",
1820
- accessToken: () => "pat",
1821
- watch: true,
1822
- sessions: {
1823
- "chat-watch": { publicAccessToken: "p", isStreaming: true },
1824
- },
1825
- });
1826
- const stream = await transport.reconnectToStream({ chatId: "chat-watch" });
1827
- const surfaced = await drainChunks(stream);
1828
- // Both trigger:turn-complete control chunks filtered; both
1829
- // text-deltas surfaced because watch mode kept the loop alive
1830
- // through the first turn-complete.
1831
- const textChunks = surfaced.filter((c) => c.type === "text-delta");
1832
- expect(textChunks).toHaveLength(2);
1833
- });
1834
- });
1835
- });
1836
- //# sourceMappingURL=chat.test.js.map