@trigger.dev/sdk 4.4.6 → 4.5.0-rc.1

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 (161) hide show
  1. package/dist/commonjs/v3/agentSkillsRuntime.d.ts +28 -0
  2. package/dist/commonjs/v3/agentSkillsRuntime.js +163 -0
  3. package/dist/commonjs/v3/agentSkillsRuntime.js.map +1 -0
  4. package/dist/commonjs/v3/ai-shared.d.ts +173 -0
  5. package/dist/commonjs/v3/ai-shared.js +25 -0
  6. package/dist/commonjs/v3/ai-shared.js.map +1 -0
  7. package/dist/commonjs/v3/ai.d.ts +2823 -5
  8. package/dist/commonjs/v3/ai.js +6197 -13
  9. package/dist/commonjs/v3/ai.js.map +1 -1
  10. package/dist/commonjs/v3/auth.d.ts +9 -0
  11. package/dist/commonjs/v3/auth.js.map +1 -1
  12. package/dist/commonjs/v3/chat-client.d.ts +301 -0
  13. package/dist/commonjs/v3/chat-client.js +624 -0
  14. package/dist/commonjs/v3/chat-client.js.map +1 -0
  15. package/dist/commonjs/v3/chat-react.d.ts +155 -0
  16. package/dist/commonjs/v3/chat-react.js +330 -0
  17. package/dist/commonjs/v3/chat-react.js.map +1 -0
  18. package/dist/commonjs/v3/chat-server.d.ts +206 -0
  19. package/dist/commonjs/v3/chat-server.js +737 -0
  20. package/dist/commonjs/v3/chat-server.js.map +1 -0
  21. package/dist/commonjs/v3/chat-server.test.d.ts +1 -0
  22. package/dist/commonjs/v3/chat-server.test.js +518 -0
  23. package/dist/commonjs/v3/chat-server.test.js.map +1 -0
  24. package/dist/commonjs/v3/chat-tab-coordinator.d.ts +65 -0
  25. package/dist/commonjs/v3/chat-tab-coordinator.js +235 -0
  26. package/dist/commonjs/v3/chat-tab-coordinator.js.map +1 -0
  27. package/dist/commonjs/v3/chat-tab-coordinator.test.d.ts +1 -0
  28. package/dist/commonjs/v3/chat-tab-coordinator.test.js +140 -0
  29. package/dist/commonjs/v3/chat-tab-coordinator.test.js.map +1 -0
  30. package/dist/commonjs/v3/chat.d.ts +437 -0
  31. package/dist/commonjs/v3/chat.js +968 -0
  32. package/dist/commonjs/v3/chat.js.map +1 -0
  33. package/dist/commonjs/v3/chat.test.d.ts +1 -0
  34. package/dist/commonjs/v3/chat.test.js +1180 -0
  35. package/dist/commonjs/v3/chat.test.js.map +1 -0
  36. package/dist/commonjs/v3/createStartSessionAction.test.d.ts +1 -0
  37. package/dist/commonjs/v3/createStartSessionAction.test.js +113 -0
  38. package/dist/commonjs/v3/createStartSessionAction.test.js.map +1 -0
  39. package/dist/commonjs/v3/deployments.d.ts +26 -0
  40. package/dist/commonjs/v3/deployments.js +37 -0
  41. package/dist/commonjs/v3/deployments.js.map +1 -0
  42. package/dist/commonjs/v3/index.d.ts +6 -3
  43. package/dist/commonjs/v3/index.js +7 -1
  44. package/dist/commonjs/v3/index.js.map +1 -1
  45. package/dist/commonjs/v3/runs.d.ts +22 -7
  46. package/dist/commonjs/v3/runs.js +1 -0
  47. package/dist/commonjs/v3/runs.js.map +1 -1
  48. package/dist/commonjs/v3/sessions.d.ts +228 -0
  49. package/dist/commonjs/v3/sessions.js +664 -0
  50. package/dist/commonjs/v3/sessions.js.map +1 -0
  51. package/dist/commonjs/v3/sessions.test.d.ts +1 -0
  52. package/dist/commonjs/v3/sessions.test.js +154 -0
  53. package/dist/commonjs/v3/sessions.test.js.map +1 -0
  54. package/dist/commonjs/v3/shared.d.ts +24 -2
  55. package/dist/commonjs/v3/shared.js +189 -1
  56. package/dist/commonjs/v3/shared.js.map +1 -1
  57. package/dist/commonjs/v3/skill.d.ts +99 -0
  58. package/dist/commonjs/v3/skill.js +155 -0
  59. package/dist/commonjs/v3/skill.js.map +1 -0
  60. package/dist/commonjs/v3/skills.d.ts +2 -0
  61. package/dist/commonjs/v3/skills.js +6 -0
  62. package/dist/commonjs/v3/skills.js.map +1 -0
  63. package/dist/commonjs/v3/streams.js +127 -19
  64. package/dist/commonjs/v3/streams.js.map +1 -1
  65. package/dist/commonjs/v3/tasks.d.ts +2 -1
  66. package/dist/commonjs/v3/tasks.js +1 -0
  67. package/dist/commonjs/v3/tasks.js.map +1 -1
  68. package/dist/commonjs/v3/test/index.d.ts +3 -0
  69. package/dist/commonjs/v3/test/index.js +18 -0
  70. package/dist/commonjs/v3/test/index.js.map +1 -0
  71. package/dist/commonjs/v3/test/mock-chat-agent.d.ts +259 -0
  72. package/dist/commonjs/v3/test/mock-chat-agent.js +468 -0
  73. package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -0
  74. package/dist/commonjs/v3/test/setup-catalog.d.ts +1 -0
  75. package/dist/commonjs/v3/test/setup-catalog.js +18 -0
  76. package/dist/commonjs/v3/test/setup-catalog.js.map +1 -0
  77. package/dist/commonjs/v3/test/test-session-handle.d.ts +53 -0
  78. package/dist/commonjs/v3/test/test-session-handle.js +256 -0
  79. package/dist/commonjs/v3/test/test-session-handle.js.map +1 -0
  80. package/dist/commonjs/version.js +1 -1
  81. package/dist/esm/v3/agentSkillsRuntime.d.ts +28 -0
  82. package/dist/esm/v3/agentSkillsRuntime.js +136 -0
  83. package/dist/esm/v3/agentSkillsRuntime.js.map +1 -0
  84. package/dist/esm/v3/ai-shared.d.ts +173 -0
  85. package/dist/esm/v3/ai-shared.js +22 -0
  86. package/dist/esm/v3/ai-shared.js.map +1 -0
  87. package/dist/esm/v3/ai.d.ts +2823 -5
  88. package/dist/esm/v3/ai.js +6187 -14
  89. package/dist/esm/v3/ai.js.map +1 -1
  90. package/dist/esm/v3/auth.d.ts +9 -0
  91. package/dist/esm/v3/auth.js.map +1 -1
  92. package/dist/esm/v3/chat-client.d.ts +301 -0
  93. package/dist/esm/v3/chat-client.js +619 -0
  94. package/dist/esm/v3/chat-client.js.map +1 -0
  95. package/dist/esm/v3/chat-react.d.ts +155 -0
  96. package/dist/esm/v3/chat-react.js +325 -0
  97. package/dist/esm/v3/chat-react.js.map +1 -0
  98. package/dist/esm/v3/chat-server.d.ts +206 -0
  99. package/dist/esm/v3/chat-server.js +734 -0
  100. package/dist/esm/v3/chat-server.js.map +1 -0
  101. package/dist/esm/v3/chat-server.test.d.ts +1 -0
  102. package/dist/esm/v3/chat-server.test.js +516 -0
  103. package/dist/esm/v3/chat-server.test.js.map +1 -0
  104. package/dist/esm/v3/chat-tab-coordinator.d.ts +65 -0
  105. package/dist/esm/v3/chat-tab-coordinator.js +231 -0
  106. package/dist/esm/v3/chat-tab-coordinator.js.map +1 -0
  107. package/dist/esm/v3/chat-tab-coordinator.test.d.ts +1 -0
  108. package/dist/esm/v3/chat-tab-coordinator.test.js +138 -0
  109. package/dist/esm/v3/chat-tab-coordinator.test.js.map +1 -0
  110. package/dist/esm/v3/chat.d.ts +437 -0
  111. package/dist/esm/v3/chat.js +961 -0
  112. package/dist/esm/v3/chat.js.map +1 -0
  113. package/dist/esm/v3/chat.test.d.ts +1 -0
  114. package/dist/esm/v3/chat.test.js +1178 -0
  115. package/dist/esm/v3/chat.test.js.map +1 -0
  116. package/dist/esm/v3/createStartSessionAction.test.d.ts +1 -0
  117. package/dist/esm/v3/createStartSessionAction.test.js +111 -0
  118. package/dist/esm/v3/createStartSessionAction.test.js.map +1 -0
  119. package/dist/esm/v3/deployments.d.ts +26 -0
  120. package/dist/esm/v3/deployments.js +34 -0
  121. package/dist/esm/v3/deployments.js.map +1 -0
  122. package/dist/esm/v3/index.d.ts +6 -3
  123. package/dist/esm/v3/index.js +4 -1
  124. package/dist/esm/v3/index.js.map +1 -1
  125. package/dist/esm/v3/runs.d.ts +15 -0
  126. package/dist/esm/v3/runs.js +1 -0
  127. package/dist/esm/v3/runs.js.map +1 -1
  128. package/dist/esm/v3/sessions.d.ts +228 -0
  129. package/dist/esm/v3/sessions.js +656 -0
  130. package/dist/esm/v3/sessions.js.map +1 -0
  131. package/dist/esm/v3/sessions.test.d.ts +1 -0
  132. package/dist/esm/v3/sessions.test.js +152 -0
  133. package/dist/esm/v3/sessions.test.js.map +1 -0
  134. package/dist/esm/v3/shared.d.ts +24 -2
  135. package/dist/esm/v3/shared.js +188 -1
  136. package/dist/esm/v3/shared.js.map +1 -1
  137. package/dist/esm/v3/skill.d.ts +99 -0
  138. package/dist/esm/v3/skill.js +128 -0
  139. package/dist/esm/v3/skill.js.map +1 -0
  140. package/dist/esm/v3/skills.d.ts +2 -0
  141. package/dist/esm/v3/skills.js +2 -0
  142. package/dist/esm/v3/skills.js.map +1 -0
  143. package/dist/esm/v3/streams.js +127 -20
  144. package/dist/esm/v3/streams.js.map +1 -1
  145. package/dist/esm/v3/tasks.d.ts +2 -1
  146. package/dist/esm/v3/tasks.js +2 -1
  147. package/dist/esm/v3/tasks.js.map +1 -1
  148. package/dist/esm/v3/test/index.d.ts +3 -0
  149. package/dist/esm/v3/test/index.js +13 -0
  150. package/dist/esm/v3/test/index.js.map +1 -0
  151. package/dist/esm/v3/test/mock-chat-agent.d.ts +259 -0
  152. package/dist/esm/v3/test/mock-chat-agent.js +465 -0
  153. package/dist/esm/v3/test/mock-chat-agent.js.map +1 -0
  154. package/dist/esm/v3/test/setup-catalog.d.ts +1 -0
  155. package/dist/esm/v3/test/setup-catalog.js +16 -0
  156. package/dist/esm/v3/test/setup-catalog.js.map +1 -0
  157. package/dist/esm/v3/test/test-session-handle.d.ts +53 -0
  158. package/dist/esm/v3/test/test-session-handle.js +251 -0
  159. package/dist/esm/v3/test/test-session-handle.js.map +1 -0
  160. package/dist/esm/version.js +1 -1
  161. package/package.json +87 -6
@@ -0,0 +1,1178 @@
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 createSessionResponseBody(options) {
106
+ const externalId = options?.externalId ?? null;
107
+ return JSON.stringify({
108
+ id: options?.sessionId ?? DEFAULT_SESSION_ID,
109
+ externalId,
110
+ type: "chat.agent",
111
+ taskIdentifier: "my-chat-task",
112
+ triggerConfig: { basePayload: { chatId: externalId ?? "" } },
113
+ currentRunId: options?.runId ?? DEFAULT_RUN_ID,
114
+ runId: options?.runId ?? DEFAULT_RUN_ID,
115
+ publicAccessToken: options?.publicAccessToken ?? DEFAULT_SESSION_PAT,
116
+ tags: [],
117
+ metadata: null,
118
+ closedAt: null,
119
+ closedReason: null,
120
+ expiresAt: null,
121
+ createdAt: new Date(0).toISOString(),
122
+ updatedAt: new Date(0).toISOString(),
123
+ isCached: false,
124
+ });
125
+ }
126
+ function defaultSessionCreateResponse(options) {
127
+ return new Response(createSessionResponseBody(options), {
128
+ status: 200,
129
+ headers: { "content-type": "application/json" },
130
+ });
131
+ }
132
+ function defaultAppendResponse() {
133
+ return new Response(JSON.stringify({ ok: true }), {
134
+ status: 200,
135
+ headers: { "content-type": "application/json" },
136
+ });
137
+ }
138
+ function defaultSseResponse(chunks = sampleChunksWithTurnComplete) {
139
+ return new Response(createSSEStream(sseEncode(chunks)), {
140
+ status: 200,
141
+ headers: {
142
+ "content-type": "text/event-stream",
143
+ // Session streams are always v2 in production — batch format
144
+ // with one S2 record per SSE event. The legacy v1 path is for
145
+ // run-scoped Redis streams.
146
+ "X-Stream-Version": "v2",
147
+ },
148
+ });
149
+ }
150
+ function authError(status = 401) {
151
+ return new Response(JSON.stringify({ error: "Unauthorized", name: "TriggerApiError", status }), {
152
+ status,
153
+ headers: { "content-type": "application/json" },
154
+ });
155
+ }
156
+ /**
157
+ * Drains a UIMessageChunk stream into an array. Used to assert what
158
+ * the transport surfaced after filtering control chunks.
159
+ */
160
+ async function drainChunks(stream) {
161
+ const reader = stream.getReader();
162
+ const out = [];
163
+ try {
164
+ while (true) {
165
+ const { done, value } = await reader.read();
166
+ if (done)
167
+ break;
168
+ out.push(value);
169
+ }
170
+ }
171
+ finally {
172
+ reader.releaseLock();
173
+ }
174
+ return out;
175
+ }
176
+ // ───────────────────────────────────────────────────────────────────────────
177
+ // Tests
178
+ // ───────────────────────────────────────────────────────────────────────────
179
+ describe("TriggerChatTransport", () => {
180
+ let originalFetch;
181
+ beforeEach(() => {
182
+ originalFetch = global.fetch;
183
+ });
184
+ afterEach(() => {
185
+ global.fetch = originalFetch;
186
+ vi.restoreAllMocks();
187
+ });
188
+ describe("constructor", () => {
189
+ it("creates with required options", () => {
190
+ const transport = new TriggerChatTransport({
191
+ task: "my-chat-task",
192
+ accessToken: () => "pat",
193
+ });
194
+ expect(transport).toBeInstanceOf(TriggerChatTransport);
195
+ });
196
+ it("createChatTransport returns a TriggerChatTransport", () => {
197
+ const transport = createChatTransport({
198
+ task: "my-chat-task",
199
+ accessToken: () => "pat",
200
+ });
201
+ expect(transport).toBeInstanceOf(TriggerChatTransport);
202
+ });
203
+ it("hydrates sessions from options.sessions", () => {
204
+ const transport = new TriggerChatTransport({
205
+ task: "my-chat-task",
206
+ accessToken: () => "pat",
207
+ sessions: {
208
+ "chat-1": {
209
+ publicAccessToken: "hydrated-pat",
210
+ lastEventId: "42",
211
+ isStreaming: false,
212
+ },
213
+ },
214
+ });
215
+ const session = transport.getSession("chat-1");
216
+ expect(session).toEqual({
217
+ publicAccessToken: "hydrated-pat",
218
+ lastEventId: "42",
219
+ isStreaming: false,
220
+ });
221
+ });
222
+ it("returns undefined for unknown chatIds", () => {
223
+ const transport = new TriggerChatTransport({
224
+ task: "my-chat-task",
225
+ accessToken: () => "pat",
226
+ });
227
+ expect(transport.getSession("unknown")).toBeUndefined();
228
+ });
229
+ });
230
+ describe("setSession / setOnSessionChange", () => {
231
+ it("setSession installs persisted state and notifies", () => {
232
+ const onSessionChange = vi.fn();
233
+ const transport = new TriggerChatTransport({
234
+ task: "my-chat-task",
235
+ accessToken: () => "pat",
236
+ onSessionChange,
237
+ });
238
+ transport.setSession("chat-x", {
239
+ publicAccessToken: "tok",
240
+ lastEventId: "10",
241
+ });
242
+ expect(transport.getSession("chat-x")).toMatchObject({
243
+ publicAccessToken: "tok",
244
+ lastEventId: "10",
245
+ });
246
+ expect(onSessionChange).toHaveBeenCalledWith("chat-x", expect.objectContaining({ publicAccessToken: "tok", lastEventId: "10" }));
247
+ });
248
+ it("setOnSessionChange swaps the callback at runtime", () => {
249
+ const transport = new TriggerChatTransport({
250
+ task: "my-chat-task",
251
+ accessToken: () => "pat",
252
+ });
253
+ const cb1 = vi.fn();
254
+ const cb2 = vi.fn();
255
+ transport.setOnSessionChange(cb1);
256
+ transport.setSession("c", { publicAccessToken: "t1" });
257
+ expect(cb1).toHaveBeenCalledTimes(1);
258
+ transport.setOnSessionChange(cb2);
259
+ transport.setSession("c", { publicAccessToken: "t2" });
260
+ expect(cb1).toHaveBeenCalledTimes(1);
261
+ expect(cb2).toHaveBeenCalledTimes(1);
262
+ });
263
+ });
264
+ describe("start", () => {
265
+ it("calls the customer's startSession callback and caches the returned PAT", async () => {
266
+ const startSession = vi.fn().mockResolvedValue({ publicAccessToken: "session-pat-1" });
267
+ const transport = new TriggerChatTransport({
268
+ task: "my-chat-task",
269
+ accessToken: () => "should-not-be-called",
270
+ startSession,
271
+ });
272
+ const result = await transport.start("chat-1");
273
+ expect(startSession).toHaveBeenCalledWith({
274
+ taskId: "my-chat-task",
275
+ chatId: "chat-1",
276
+ clientData: {},
277
+ });
278
+ expect(result.publicAccessToken).toBe("session-pat-1");
279
+ expect(transport.getSession("chat-1")?.publicAccessToken).toBe("session-pat-1");
280
+ });
281
+ it("is idempotent — second call returns the cached state without re-invoking startSession", async () => {
282
+ const startSession = vi
283
+ .fn()
284
+ .mockResolvedValue({ publicAccessToken: "session-pat-2" });
285
+ const transport = new TriggerChatTransport({
286
+ task: "my-chat-task",
287
+ accessToken: () => "pat",
288
+ startSession,
289
+ });
290
+ await transport.start("chat-2");
291
+ await transport.start("chat-2");
292
+ expect(startSession).toHaveBeenCalledTimes(1);
293
+ });
294
+ it("dedupes concurrent calls via an in-flight promise", async () => {
295
+ let resolveStart;
296
+ const startPromise = new Promise((resolve) => {
297
+ resolveStart = resolve;
298
+ });
299
+ const startSession = vi.fn().mockReturnValue(startPromise);
300
+ const transport = new TriggerChatTransport({
301
+ task: "my-chat-task",
302
+ accessToken: () => "pat",
303
+ startSession,
304
+ });
305
+ const a = transport.start("chat-3");
306
+ const b = transport.start("chat-3");
307
+ resolveStart({ publicAccessToken: "session-pat-3" });
308
+ await Promise.all([a, b]);
309
+ expect(startSession).toHaveBeenCalledTimes(1);
310
+ });
311
+ it("preload() is an alias for start()", async () => {
312
+ const startSession = vi
313
+ .fn()
314
+ .mockResolvedValue({ publicAccessToken: "session-pat-pre" });
315
+ const transport = new TriggerChatTransport({
316
+ task: "my-chat-task",
317
+ accessToken: () => "pat",
318
+ startSession,
319
+ });
320
+ await transport.preload("chat-pre");
321
+ expect(startSession).toHaveBeenCalledTimes(1);
322
+ expect(transport.getSession("chat-pre")?.publicAccessToken).toBe("session-pat-pre");
323
+ });
324
+ it("throws a clear error when start() is called without startSession configured", async () => {
325
+ const transport = new TriggerChatTransport({
326
+ task: "my-chat-task",
327
+ accessToken: () => "pat",
328
+ });
329
+ await expect(transport.start("chat-no-start")).rejects.toThrow(/startSession/);
330
+ });
331
+ it("threads the transport's `clientData` through to startSession", async () => {
332
+ const startSession = vi
333
+ .fn()
334
+ .mockResolvedValue({ publicAccessToken: "session-pat-cd" });
335
+ const transport = new TriggerChatTransport({
336
+ task: "my-chat-task",
337
+ accessToken: () => "pat",
338
+ startSession,
339
+ clientData: { userId: "u-1", model: "claude-sonnet-4-6" },
340
+ });
341
+ await transport.start("chat-cd");
342
+ expect(startSession).toHaveBeenCalledWith({
343
+ taskId: "my-chat-task",
344
+ chatId: "chat-cd",
345
+ clientData: { userId: "u-1", model: "claude-sonnet-4-6" },
346
+ });
347
+ });
348
+ it("setClientData updates the value passed to subsequent startSession calls", async () => {
349
+ const startSession = vi
350
+ .fn()
351
+ .mockResolvedValue({ publicAccessToken: "session-pat-set" });
352
+ const transport = new TriggerChatTransport({
353
+ task: "my-chat-task",
354
+ accessToken: () => "pat",
355
+ startSession,
356
+ clientData: { userId: "old" },
357
+ });
358
+ transport.setClientData({ userId: "new" });
359
+ await transport.start("chat-set");
360
+ expect(startSession).toHaveBeenCalledWith({
361
+ taskId: "my-chat-task",
362
+ chatId: "chat-set",
363
+ clientData: { userId: "new" },
364
+ });
365
+ });
366
+ });
367
+ describe("ensureSessionState (lazy start on first sendMessage)", () => {
368
+ it("calls startSession lazily on first sendMessage when no PAT is hydrated", async () => {
369
+ const startSession = vi
370
+ .fn()
371
+ .mockResolvedValue({ publicAccessToken: "lazy-session-pat" });
372
+ global.fetch = vi.fn().mockImplementation(async (url) => {
373
+ const urlStr = typeof url === "string" ? url : url.toString();
374
+ if (isSessionStreamAppendUrl(urlStr))
375
+ return defaultAppendResponse();
376
+ if (isSessionOutSubscribeUrl(urlStr))
377
+ return defaultSseResponse();
378
+ throw new Error(`Unexpected URL: ${urlStr}`);
379
+ });
380
+ const transport = new TriggerChatTransport({
381
+ task: "my-chat-task",
382
+ accessToken: () => "should-not-be-called",
383
+ startSession,
384
+ baseURL: "https://api.test.trigger.dev",
385
+ });
386
+ const stream = await transport.sendMessages({
387
+ trigger: "submit-message",
388
+ chatId: "chat-lazy",
389
+ messageId: undefined,
390
+ messages: [createUserMessage("hi")],
391
+ abortSignal: undefined,
392
+ });
393
+ await drainChunks(stream);
394
+ expect(startSession).toHaveBeenCalledTimes(1);
395
+ expect(startSession).toHaveBeenCalledWith({
396
+ taskId: "my-chat-task",
397
+ chatId: "chat-lazy",
398
+ clientData: {},
399
+ });
400
+ expect(transport.getSession("chat-lazy")?.publicAccessToken).toBe("lazy-session-pat");
401
+ });
402
+ it("falls back to accessToken when no startSession is configured (out-of-band session create)", async () => {
403
+ const accessToken = vi.fn().mockResolvedValue("server-mediated-pat");
404
+ global.fetch = vi.fn().mockImplementation(async (url) => {
405
+ const urlStr = typeof url === "string" ? url : url.toString();
406
+ if (isSessionStreamAppendUrl(urlStr))
407
+ return defaultAppendResponse();
408
+ if (isSessionOutSubscribeUrl(urlStr))
409
+ return defaultSseResponse();
410
+ throw new Error(`Unexpected URL: ${urlStr}`);
411
+ });
412
+ const transport = new TriggerChatTransport({
413
+ task: "my-chat-task",
414
+ accessToken,
415
+ baseURL: "https://api.test.trigger.dev",
416
+ });
417
+ const stream = await transport.sendMessages({
418
+ trigger: "submit-message",
419
+ chatId: "chat-server",
420
+ messageId: undefined,
421
+ messages: [createUserMessage("hi")],
422
+ abortSignal: undefined,
423
+ });
424
+ await drainChunks(stream);
425
+ expect(accessToken).toHaveBeenCalledTimes(1);
426
+ expect(accessToken).toHaveBeenCalledWith({ chatId: "chat-server" });
427
+ });
428
+ it("does not call accessToken when a PAT is hydrated", async () => {
429
+ const accessToken = vi.fn().mockResolvedValue("should-not-be-called");
430
+ global.fetch = vi.fn().mockImplementation(async (url) => {
431
+ const urlStr = typeof url === "string" ? url : url.toString();
432
+ if (isSessionStreamAppendUrl(urlStr))
433
+ return defaultAppendResponse();
434
+ if (isSessionOutSubscribeUrl(urlStr))
435
+ return defaultSseResponse();
436
+ throw new Error(`Unexpected URL: ${urlStr}`);
437
+ });
438
+ const transport = new TriggerChatTransport({
439
+ task: "my-chat-task",
440
+ accessToken,
441
+ sessions: {
442
+ "chat-h": { publicAccessToken: "hydrated-pat" },
443
+ },
444
+ });
445
+ const stream = await transport.sendMessages({
446
+ trigger: "submit-message",
447
+ chatId: "chat-h",
448
+ messageId: undefined,
449
+ messages: [createUserMessage("hi")],
450
+ abortSignal: undefined,
451
+ });
452
+ await drainChunks(stream);
453
+ expect(accessToken).not.toHaveBeenCalled();
454
+ });
455
+ });
456
+ describe("sendMessages", () => {
457
+ it("posts the user message to .in/append and streams chunks from .out", async () => {
458
+ const requests = [];
459
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
460
+ const urlStr = typeof url === "string" ? url : url.toString();
461
+ requests.push({ url: urlStr, init });
462
+ if (isSessionStreamAppendUrl(urlStr))
463
+ return defaultAppendResponse();
464
+ if (isSessionOutSubscribeUrl(urlStr))
465
+ return defaultSseResponse();
466
+ throw new Error(`Unexpected URL: ${urlStr}`);
467
+ });
468
+ const transport = new TriggerChatTransport({
469
+ task: "my-chat-task",
470
+ accessToken: () => "pat",
471
+ baseURL: "https://api.test.trigger.dev",
472
+ sessions: { "chat-1": { publicAccessToken: "p" } },
473
+ });
474
+ const stream = await transport.sendMessages({
475
+ trigger: "submit-message",
476
+ chatId: "chat-1",
477
+ messageId: "m1",
478
+ messages: [createUserMessage("Hello")],
479
+ abortSignal: undefined,
480
+ });
481
+ const chunks = await drainChunks(stream);
482
+ // Five UI chunks pass through; trigger:turn-complete is filtered.
483
+ expect(chunks).toHaveLength(sampleChunks.length);
484
+ expect(chunks[0]).toEqual(sampleChunks[0]);
485
+ const append = requests.find((r) => isSessionStreamAppendUrl(r.url) && r.url.endsWith("/in/append"));
486
+ expect(append).toBeDefined();
487
+ expect(chatIdFromUrl(append.url)).toBe("chat-1");
488
+ // Body is the serialized ChatInputChunk.
489
+ const body = JSON.parse(append.init.body);
490
+ expect(body.kind).toBe("message");
491
+ expect(body.payload.chatId).toBe("chat-1");
492
+ expect(body.payload.trigger).toBe("submit-message");
493
+ });
494
+ it("addresses .out SSE by chatId (not by sessionId)", async () => {
495
+ const requests = [];
496
+ global.fetch = vi.fn().mockImplementation(async (url) => {
497
+ const urlStr = typeof url === "string" ? url : url.toString();
498
+ requests.push(urlStr);
499
+ if (isSessionStreamAppendUrl(urlStr))
500
+ return defaultAppendResponse();
501
+ if (isSessionOutSubscribeUrl(urlStr))
502
+ return defaultSseResponse();
503
+ throw new Error(`Unexpected URL: ${urlStr}`);
504
+ });
505
+ const transport = new TriggerChatTransport({
506
+ task: "my-chat-task",
507
+ accessToken: () => "pat",
508
+ baseURL: "https://api.test.trigger.dev",
509
+ sessions: { "chat-by-chatid": { publicAccessToken: "p" } },
510
+ });
511
+ const stream = await transport.sendMessages({
512
+ trigger: "submit-message",
513
+ chatId: "chat-by-chatid",
514
+ messageId: undefined,
515
+ messages: [createUserMessage("Hi")],
516
+ abortSignal: undefined,
517
+ });
518
+ await drainChunks(stream);
519
+ const subscribe = requests.find(isSessionOutSubscribeUrl);
520
+ expect(subscribe).toBeDefined();
521
+ expect(subscribe).toContain("/realtime/v1/sessions/chat-by-chatid/out");
522
+ });
523
+ it("functional baseURL dispatches per endpoint (in vs out)", async () => {
524
+ const requests = [];
525
+ global.fetch = vi.fn().mockImplementation(async (url) => {
526
+ const urlStr = typeof url === "string" ? url : url.toString();
527
+ requests.push({ url: urlStr, ctxEndpoint: undefined });
528
+ if (isSessionStreamAppendUrl(urlStr))
529
+ return defaultAppendResponse();
530
+ if (isSessionOutSubscribeUrl(urlStr))
531
+ return defaultSseResponse();
532
+ throw new Error(`Unexpected URL: ${urlStr}`);
533
+ });
534
+ const baseURLFn = vi.fn(({ endpoint }) => endpoint === "out"
535
+ ? "https://stream.example.com"
536
+ : "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
+ });
881
+ describe("reconnectToStream", () => {
882
+ it("returns null when no session exists", async () => {
883
+ const transport = new TriggerChatTransport({
884
+ task: "my-chat-task",
885
+ accessToken: () => "pat",
886
+ });
887
+ const result = await transport.reconnectToStream({ chatId: "missing" });
888
+ expect(result).toBeNull();
889
+ });
890
+ it("returns null when the session is hydrated with isStreaming=false", async () => {
891
+ const transport = new TriggerChatTransport({
892
+ task: "my-chat-task",
893
+ accessToken: () => "pat",
894
+ sessions: {
895
+ "chat-rc": { publicAccessToken: "p", isStreaming: false },
896
+ },
897
+ });
898
+ const result = await transport.reconnectToStream({ chatId: "chat-rc" });
899
+ expect(result).toBeNull();
900
+ });
901
+ it("opens an SSE subscription with the X-Peek-Settled header set", async () => {
902
+ let subscribeHeaders;
903
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
904
+ const urlStr = typeof url === "string" ? url : url.toString();
905
+ if (isSessionOutSubscribeUrl(urlStr)) {
906
+ subscribeHeaders = new Headers(init?.headers);
907
+ return defaultSseResponse();
908
+ }
909
+ throw new Error(`Unexpected URL: ${urlStr}`);
910
+ });
911
+ const transport = new TriggerChatTransport({
912
+ task: "my-chat-task",
913
+ accessToken: () => "pat",
914
+ sessions: {
915
+ "chat-rc-on": { publicAccessToken: "p", isStreaming: true },
916
+ },
917
+ });
918
+ const stream = await transport.reconnectToStream({ chatId: "chat-rc-on" });
919
+ expect(stream).not.toBeNull();
920
+ await drainChunks(stream);
921
+ expect(subscribeHeaders?.get("X-Peek-Settled")).toBe("1");
922
+ });
923
+ });
924
+ describe("multi-tab coordination", () => {
925
+ it("isReadOnly defaults to false when multiTab is disabled", () => {
926
+ const transport = new TriggerChatTransport({
927
+ task: "my-chat-task",
928
+ accessToken: () => "pat",
929
+ });
930
+ expect(transport.isReadOnly("any-chat")).toBe(false);
931
+ expect(transport.hasClaim("any-chat")).toBe(false);
932
+ });
933
+ });
934
+ describe("endpoint (chat.handover routing)", () => {
935
+ /**
936
+ * Encode UIMessageChunks the same way the chat-server.ts handler
937
+ * does: `data: <JSON>\n\n` per chunk. The transport's
938
+ * `parseUIMessageSseTransform` parses this back into chunk objects.
939
+ */
940
+ function handoverSseBody(chunks) {
941
+ const encoder = new TextEncoder();
942
+ return new ReadableStream({
943
+ start(controller) {
944
+ for (const chunk of chunks) {
945
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
946
+ }
947
+ controller.close();
948
+ },
949
+ });
950
+ }
951
+ function handoverResponse(args) {
952
+ return new Response(handoverSseBody(args.chunks), {
953
+ status: 200,
954
+ headers: {
955
+ "content-type": "text/event-stream",
956
+ "X-Trigger-Chat-Id": args.chatId,
957
+ "X-Trigger-Chat-Access-Token": args.accessToken,
958
+ },
959
+ });
960
+ }
961
+ it("first-turn POSTs the wire payload to endpoint when no session exists", async () => {
962
+ const requests = [];
963
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
964
+ const urlStr = typeof url === "string" ? url : url.toString();
965
+ requests.push({ url: urlStr, init });
966
+ if (urlStr === "https://my-app.example/api/chat") {
967
+ return handoverResponse({
968
+ chatId: "chat-handover-1",
969
+ accessToken: "handover-pat-1",
970
+ chunks: sampleChunks,
971
+ });
972
+ }
973
+ throw new Error(`Unexpected URL: ${urlStr}`);
974
+ });
975
+ const transport = new TriggerChatTransport({
976
+ task: "my-chat-task",
977
+ accessToken: () => "pat",
978
+ headStart: "https://my-app.example/api/chat",
979
+ });
980
+ const stream = await transport.sendMessages({
981
+ trigger: "submit-message",
982
+ chatId: "chat-handover-1",
983
+ messageId: "m1",
984
+ messages: [createUserMessage("hello")],
985
+ abortSignal: undefined,
986
+ });
987
+ const chunks = await drainChunks(stream);
988
+ // Chunks were forwarded from the handler's SSE body unchanged.
989
+ expect(chunks).toEqual(sampleChunks);
990
+ // Only the endpoint was called — no /api/v1/sessions, no .in/append,
991
+ // no .out subscribe. The handler owns first-turn end-to-end.
992
+ const endpointPosts = requests.filter((r) => r.url === "https://my-app.example/api/chat");
993
+ expect(endpointPosts).toHaveLength(1);
994
+ expect(requests.some((r) => isSessionCreateUrl(r.url))).toBe(false);
995
+ expect(requests.some((r) => isSessionStreamAppendUrl(r.url))).toBe(false);
996
+ expect(requests.some((r) => isSessionOutSubscribeUrl(r.url))).toBe(false);
997
+ // Body shape: head-start wire payload. Full UIMessage history is
998
+ // shipped via `headStartMessages` (this is the one path that still
999
+ // ships full history — the route handler runs against the customer's
1000
+ // own HTTP endpoint, not /in/append, so the 512 KiB cap doesn't
1001
+ // apply). The `message` field is omitted on this path.
1002
+ const body = JSON.parse(endpointPosts[0].init.body);
1003
+ expect(body.chatId).toBe("chat-handover-1");
1004
+ expect(body.trigger).toBe("submit-message");
1005
+ expect(body.messageId).toBe("m1");
1006
+ expect(body.headStartMessages).toHaveLength(1);
1007
+ expect(body.message).toBeUndefined();
1008
+ expect(body.messages).toBeUndefined();
1009
+ });
1010
+ it("hydrates session state from response headers so subsequent turns bypass the endpoint", async () => {
1011
+ const requests = [];
1012
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
1013
+ const urlStr = typeof url === "string" ? url : url.toString();
1014
+ requests.push({ url: urlStr, init });
1015
+ if (urlStr === "https://my-app.example/api/chat") {
1016
+ return handoverResponse({
1017
+ chatId: "chat-handover-2",
1018
+ accessToken: "handover-pat-2",
1019
+ chunks: sampleChunks,
1020
+ });
1021
+ }
1022
+ if (isSessionStreamAppendUrl(urlStr))
1023
+ return defaultAppendResponse();
1024
+ if (isSessionOutSubscribeUrl(urlStr))
1025
+ return defaultSseResponse();
1026
+ throw new Error(`Unexpected URL: ${urlStr}`);
1027
+ });
1028
+ const onSessionChange = vi.fn();
1029
+ const transport = new TriggerChatTransport({
1030
+ task: "my-chat-task",
1031
+ accessToken: () => "fallback-pat",
1032
+ headStart: "https://my-app.example/api/chat",
1033
+ onSessionChange,
1034
+ });
1035
+ // Turn 1 — POSTs to endpoint, hydrates session.
1036
+ await drainChunks(await transport.sendMessages({
1037
+ trigger: "submit-message",
1038
+ chatId: "chat-handover-2",
1039
+ messageId: "m1",
1040
+ messages: [createUserMessage("first")],
1041
+ abortSignal: undefined,
1042
+ }));
1043
+ const hydrated = transport.getSession("chat-handover-2");
1044
+ expect(hydrated).toBeDefined();
1045
+ expect(hydrated.publicAccessToken).toBe("handover-pat-2");
1046
+ expect(onSessionChange).toHaveBeenCalledWith("chat-handover-2", expect.objectContaining({ publicAccessToken: "handover-pat-2" }));
1047
+ // Turn 2 — bypass endpoint, write directly to .in.
1048
+ requests.length = 0;
1049
+ const turn2Stream = await transport.sendMessages({
1050
+ trigger: "submit-message",
1051
+ chatId: "chat-handover-2",
1052
+ messageId: "m2",
1053
+ messages: [createUserMessage("second")],
1054
+ abortSignal: undefined,
1055
+ });
1056
+ expect(requests.some((r) => r.url === "https://my-app.example/api/chat")).toBe(false);
1057
+ const append = requests.find((r) => isSessionStreamAppendUrl(r.url) && r.url.endsWith("/in/append"));
1058
+ expect(append).toBeDefined();
1059
+ expect(chatIdFromUrl(append.url)).toBe("chat-handover-2");
1060
+ // Drain after asserting append — `.out` is subscribed lazily when the
1061
+ // returned stream is read.
1062
+ await drainChunks(turn2Stream);
1063
+ const subscribe = requests.find((r) => isSessionOutSubscribeUrl(r.url));
1064
+ expect(subscribe).toBeDefined();
1065
+ });
1066
+ it("bypasses endpoint when a session is already hydrated (page reload after first turn)", async () => {
1067
+ const requests = [];
1068
+ global.fetch = vi.fn().mockImplementation(async (url, init) => {
1069
+ const urlStr = typeof url === "string" ? url : url.toString();
1070
+ requests.push({ url: urlStr, init });
1071
+ if (isSessionStreamAppendUrl(urlStr))
1072
+ return defaultAppendResponse();
1073
+ if (isSessionOutSubscribeUrl(urlStr))
1074
+ return defaultSseResponse();
1075
+ throw new Error(`Unexpected URL: ${urlStr}`);
1076
+ });
1077
+ const transport = new TriggerChatTransport({
1078
+ task: "my-chat-task",
1079
+ accessToken: () => "pat",
1080
+ headStart: "https://my-app.example/api/chat",
1081
+ sessions: {
1082
+ "chat-resumed": { publicAccessToken: "persisted-pat" },
1083
+ },
1084
+ });
1085
+ await drainChunks(await transport.sendMessages({
1086
+ trigger: "submit-message",
1087
+ chatId: "chat-resumed",
1088
+ messageId: undefined,
1089
+ messages: [createUserMessage("hi again")],
1090
+ abortSignal: undefined,
1091
+ }));
1092
+ expect(requests.some((r) => r.url === "https://my-app.example/api/chat")).toBe(false);
1093
+ expect(requests.some((r) => isSessionStreamAppendUrl(r.url))).toBe(true);
1094
+ });
1095
+ it("propagates a non-2xx response from the endpoint as an error", async () => {
1096
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1097
+ const urlStr = typeof url === "string" ? url : url.toString();
1098
+ if (urlStr === "https://my-app.example/api/chat") {
1099
+ return new Response(null, { status: 500, statusText: "Internal Server Error" });
1100
+ }
1101
+ throw new Error(`Unexpected URL: ${urlStr}`);
1102
+ });
1103
+ const transport = new TriggerChatTransport({
1104
+ task: "my-chat-task",
1105
+ accessToken: () => "pat",
1106
+ headStart: "https://my-app.example/api/chat",
1107
+ });
1108
+ await expect(transport.sendMessages({
1109
+ trigger: "submit-message",
1110
+ chatId: "chat-handover-err",
1111
+ messageId: undefined,
1112
+ messages: [createUserMessage("oops")],
1113
+ abortSignal: undefined,
1114
+ })).rejects.toThrow(/500/);
1115
+ });
1116
+ it("leaves the legacy direct-trigger path unchanged when endpoint is unset", async () => {
1117
+ const requests = [];
1118
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1119
+ const urlStr = typeof url === "string" ? url : url.toString();
1120
+ requests.push(urlStr);
1121
+ if (isSessionStreamAppendUrl(urlStr))
1122
+ return defaultAppendResponse();
1123
+ if (isSessionOutSubscribeUrl(urlStr))
1124
+ return defaultSseResponse();
1125
+ throw new Error(`Unexpected URL: ${urlStr}`);
1126
+ });
1127
+ const transport = new TriggerChatTransport({
1128
+ task: "my-chat-task",
1129
+ accessToken: () => "pat",
1130
+ // endpoint NOT set
1131
+ sessions: { "chat-legacy": { publicAccessToken: "p" } },
1132
+ });
1133
+ await drainChunks(await transport.sendMessages({
1134
+ trigger: "submit-message",
1135
+ chatId: "chat-legacy",
1136
+ messageId: undefined,
1137
+ messages: [createUserMessage("legacy")],
1138
+ abortSignal: undefined,
1139
+ }));
1140
+ // No POST to /api/chat anywhere.
1141
+ expect(requests.some((u) => u.endsWith("/api/chat"))).toBe(false);
1142
+ expect(requests.some(isSessionStreamAppendUrl)).toBe(true);
1143
+ expect(requests.some(isSessionOutSubscribeUrl)).toBe(true);
1144
+ });
1145
+ });
1146
+ describe("watch mode", () => {
1147
+ it("keeps the SSE open across trigger:turn-complete (multi-turn watch)", async () => {
1148
+ const turn1 = [
1149
+ { type: "text-delta", id: "p1", delta: "Hi" },
1150
+ { type: "trigger:turn-complete" },
1151
+ { type: "text-delta", id: "p2", delta: "Again" },
1152
+ { type: "trigger:turn-complete" },
1153
+ ];
1154
+ global.fetch = vi.fn().mockImplementation(async (url) => {
1155
+ const urlStr = typeof url === "string" ? url : url.toString();
1156
+ if (isSessionOutSubscribeUrl(urlStr))
1157
+ return defaultSseResponse(turn1);
1158
+ throw new Error(`Unexpected URL: ${urlStr}`);
1159
+ });
1160
+ const transport = new TriggerChatTransport({
1161
+ task: "my-chat-task",
1162
+ accessToken: () => "pat",
1163
+ watch: true,
1164
+ sessions: {
1165
+ "chat-watch": { publicAccessToken: "p", isStreaming: true },
1166
+ },
1167
+ });
1168
+ const stream = await transport.reconnectToStream({ chatId: "chat-watch" });
1169
+ const surfaced = await drainChunks(stream);
1170
+ // Both trigger:turn-complete control chunks filtered; both
1171
+ // text-deltas surfaced because watch mode kept the loop alive
1172
+ // through the first turn-complete.
1173
+ const textChunks = surfaced.filter((c) => c.type === "text-delta");
1174
+ expect(textChunks).toHaveLength(2);
1175
+ });
1176
+ });
1177
+ });
1178
+ //# sourceMappingURL=chat.test.js.map