@trigger.dev/sdk 4.5.14 → 4.5.16

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 (123) 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/config/extensions/custom.mdx +14 -1
  35. package/docs/github-integration.mdx +1 -1
  36. package/docs/mcp-tools.mdx +37 -0
  37. package/docs/realtime/react-hooks/session-stream.mdx +17 -0
  38. package/docs/troubleshooting.mdx +1 -1
  39. package/package.json +5 -2
  40. package/dist/commonjs/v3/auth.test.d.ts +0 -1
  41. package/dist/commonjs/v3/auth.test.js +0 -149
  42. package/dist/commonjs/v3/auth.test.js.map +0 -1
  43. package/dist/commonjs/v3/batchDebounce.test.d.ts +0 -1
  44. package/dist/commonjs/v3/batchDebounce.test.js +0 -189
  45. package/dist/commonjs/v3/batchDebounce.test.js.map +0 -1
  46. package/dist/commonjs/v3/chat-server.test.d.ts +0 -1
  47. package/dist/commonjs/v3/chat-server.test.js +0 -713
  48. package/dist/commonjs/v3/chat-server.test.js.map +0 -1
  49. package/dist/commonjs/v3/chat-tab-coordinator.test.d.ts +0 -1
  50. package/dist/commonjs/v3/chat-tab-coordinator.test.js +0 -140
  51. package/dist/commonjs/v3/chat-tab-coordinator.test.js.map +0 -1
  52. package/dist/commonjs/v3/chat.test.d.ts +0 -1
  53. package/dist/commonjs/v3/chat.test.js +0 -1838
  54. package/dist/commonjs/v3/chat.test.js.map +0 -1
  55. package/dist/commonjs/v3/createStartSessionAction.test.d.ts +0 -1
  56. package/dist/commonjs/v3/createStartSessionAction.test.js +0 -176
  57. package/dist/commonjs/v3/createStartSessionAction.test.js.map +0 -1
  58. package/dist/commonjs/v3/runs-bulk.test.d.ts +0 -1
  59. package/dist/commonjs/v3/runs-bulk.test.js +0 -141
  60. package/dist/commonjs/v3/runs-bulk.test.js.map +0 -1
  61. package/dist/commonjs/v3/schedules/index.test.d.ts +0 -1
  62. package/dist/commonjs/v3/schedules/index.test.js +0 -49
  63. package/dist/commonjs/v3/schedules/index.test.js.map +0 -1
  64. package/dist/commonjs/v3/schedules/index.types.test.d.ts +0 -1
  65. package/dist/commonjs/v3/schedules/index.types.test.js +0 -93
  66. package/dist/commonjs/v3/schedules/index.types.test.js.map +0 -1
  67. package/dist/commonjs/v3/sessions.test.d.ts +0 -1
  68. package/dist/commonjs/v3/sessions.test.js +0 -154
  69. package/dist/commonjs/v3/sessions.test.js.map +0 -1
  70. package/dist/commonjs/v3/shared.test.d.ts +0 -1
  71. package/dist/commonjs/v3/shared.test.js +0 -236
  72. package/dist/commonjs/v3/shared.test.js.map +0 -1
  73. package/dist/commonjs/v3/streams.test.d.ts +0 -1
  74. package/dist/commonjs/v3/streams.test.js +0 -51
  75. package/dist/commonjs/v3/streams.test.js.map +0 -1
  76. package/dist/commonjs/v3/triggerClient.test.d.ts +0 -1
  77. package/dist/commonjs/v3/triggerClient.test.js +0 -226
  78. package/dist/commonjs/v3/triggerClient.test.js.map +0 -1
  79. package/dist/commonjs/v3/triggerClient.types.test.d.ts +0 -1
  80. package/dist/commonjs/v3/triggerClient.types.test.js +0 -123
  81. package/dist/commonjs/v3/triggerClient.types.test.js.map +0 -1
  82. package/dist/esm/v3/auth.test.d.ts +0 -1
  83. package/dist/esm/v3/auth.test.js +0 -147
  84. package/dist/esm/v3/auth.test.js.map +0 -1
  85. package/dist/esm/v3/batchDebounce.test.d.ts +0 -1
  86. package/dist/esm/v3/batchDebounce.test.js +0 -187
  87. package/dist/esm/v3/batchDebounce.test.js.map +0 -1
  88. package/dist/esm/v3/chat-server.test.d.ts +0 -1
  89. package/dist/esm/v3/chat-server.test.js +0 -711
  90. package/dist/esm/v3/chat-server.test.js.map +0 -1
  91. package/dist/esm/v3/chat-tab-coordinator.test.d.ts +0 -1
  92. package/dist/esm/v3/chat-tab-coordinator.test.js +0 -138
  93. package/dist/esm/v3/chat-tab-coordinator.test.js.map +0 -1
  94. package/dist/esm/v3/chat.test.d.ts +0 -1
  95. package/dist/esm/v3/chat.test.js +0 -1836
  96. package/dist/esm/v3/chat.test.js.map +0 -1
  97. package/dist/esm/v3/createStartSessionAction.test.d.ts +0 -1
  98. package/dist/esm/v3/createStartSessionAction.test.js +0 -174
  99. package/dist/esm/v3/createStartSessionAction.test.js.map +0 -1
  100. package/dist/esm/v3/runs-bulk.test.d.ts +0 -1
  101. package/dist/esm/v3/runs-bulk.test.js +0 -139
  102. package/dist/esm/v3/runs-bulk.test.js.map +0 -1
  103. package/dist/esm/v3/schedules/index.test.d.ts +0 -1
  104. package/dist/esm/v3/schedules/index.test.js +0 -47
  105. package/dist/esm/v3/schedules/index.test.js.map +0 -1
  106. package/dist/esm/v3/schedules/index.types.test.d.ts +0 -1
  107. package/dist/esm/v3/schedules/index.types.test.js +0 -91
  108. package/dist/esm/v3/schedules/index.types.test.js.map +0 -1
  109. package/dist/esm/v3/sessions.test.d.ts +0 -1
  110. package/dist/esm/v3/sessions.test.js +0 -152
  111. package/dist/esm/v3/sessions.test.js.map +0 -1
  112. package/dist/esm/v3/shared.test.d.ts +0 -1
  113. package/dist/esm/v3/shared.test.js +0 -234
  114. package/dist/esm/v3/shared.test.js.map +0 -1
  115. package/dist/esm/v3/streams.test.d.ts +0 -1
  116. package/dist/esm/v3/streams.test.js +0 -49
  117. package/dist/esm/v3/streams.test.js.map +0 -1
  118. package/dist/esm/v3/triggerClient.test.d.ts +0 -1
  119. package/dist/esm/v3/triggerClient.test.js +0 -224
  120. package/dist/esm/v3/triggerClient.test.js.map +0 -1
  121. package/dist/esm/v3/triggerClient.types.test.d.ts +0 -1
  122. package/dist/esm/v3/triggerClient.types.test.js +0 -88
  123. package/dist/esm/v3/triggerClient.types.test.js.map +0 -1
@@ -1,711 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { simulateReadableStream, streamText } from "ai";
3
- import { MockLanguageModelV3 } from "ai/test";
4
- // Stub `SessionStreamInstance` so the handler's S2 tee is a no-op
5
- // instead of trying to reach a real S2 endpoint. The real one calls
6
- // `apiClient.initializeSessionStream` then pipes via S2 — both are
7
- // out of scope for handler-shape tests.
8
- vi.mock("@trigger.dev/core/v3", async (importActual) => {
9
- const actual = (await importActual());
10
- class StubSessionStreamInstance {
11
- constructor(opts) {
12
- // Drain the source so the upstream tee doesn't backpressure-stall
13
- // the SSE half. We don't keep the chunks — durability/resume is
14
- // out of scope here.
15
- void (async () => {
16
- const reader = opts.source.getReader();
17
- try {
18
- while (true) {
19
- const { done } = await reader.read();
20
- if (done)
21
- break;
22
- }
23
- }
24
- finally {
25
- reader.releaseLock();
26
- }
27
- })();
28
- }
29
- async wait() {
30
- return { written: 0 };
31
- }
32
- }
33
- return { ...actual, SessionStreamInstance: StubSessionStreamInstance };
34
- });
35
- // Import AFTER the mock so chat-server picks up the stubbed class.
36
- import { chat } from "./chat-server.js";
37
- import { apiClientManager } from "@trigger.dev/core/v3";
38
- // ── Helpers ────────────────────────────────────────────────────────────
39
- function textStream(text) {
40
- return simulateReadableStream({
41
- chunks: [
42
- { type: "text-start", id: "t1" },
43
- { type: "text-delta", id: "t1", delta: text },
44
- { type: "text-end", id: "t1" },
45
- {
46
- type: "finish",
47
- finishReason: { unified: "stop", raw: "stop" },
48
- usage: {
49
- inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined },
50
- outputTokens: { total: 5, text: 5, reasoning: undefined },
51
- },
52
- },
53
- ],
54
- });
55
- }
56
- function toolCallStream() {
57
- return simulateReadableStream({
58
- chunks: [
59
- {
60
- type: "tool-call",
61
- toolCallId: "tc-1",
62
- toolName: "weather",
63
- input: JSON.stringify({ city: "tokyo" }),
64
- },
65
- {
66
- type: "finish",
67
- finishReason: { unified: "tool-calls", raw: "tool-calls" },
68
- usage: {
69
- inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined },
70
- outputTokens: { total: 5, text: 0, reasoning: undefined },
71
- },
72
- },
73
- ],
74
- });
75
- }
76
- function makeRequest(body) {
77
- return new Request("https://my-app.example/api/chat", {
78
- method: "POST",
79
- headers: { "content-type": "application/json" },
80
- body: JSON.stringify(body),
81
- });
82
- }
83
- const SESSION_PAT = "tr_session_pat_for_handover";
84
- function createSessionResponse(externalId) {
85
- return new Response(JSON.stringify({
86
- id: "session_test",
87
- externalId,
88
- type: "chat.agent",
89
- taskIdentifier: "test-agent",
90
- triggerConfig: {
91
- basePayload: { chatId: externalId, trigger: "handover-prepare" },
92
- idleTimeoutInSeconds: 60,
93
- },
94
- currentRunId: "run_test",
95
- runId: "run_test",
96
- publicAccessToken: SESSION_PAT,
97
- tags: [],
98
- metadata: null,
99
- closedAt: null,
100
- closedReason: null,
101
- expiresAt: null,
102
- createdAt: new Date(0).toISOString(),
103
- updatedAt: new Date(0).toISOString(),
104
- isCached: false,
105
- }), {
106
- status: 200,
107
- headers: { "content-type": "application/json" },
108
- });
109
- }
110
- function appendOkResponse() {
111
- return new Response(JSON.stringify({ ok: true }), {
112
- status: 200,
113
- headers: { "content-type": "application/json" },
114
- });
115
- }
116
- async function readSSEBodyToChunks(res) {
117
- const text = await res.text();
118
- return text
119
- .split("\n\n")
120
- .filter((b) => b.startsWith("data: "))
121
- .map((b) => JSON.parse(b.slice(6)));
122
- }
123
- async function withApiContext(fn) {
124
- return apiClientManager.runWithConfig({
125
- baseURL: "https://api.test.trigger.dev",
126
- secretKey: "tr_test_secret",
127
- }, fn);
128
- }
129
- // ── Tests ──────────────────────────────────────────────────────────────
130
- describe("chat.headStart (route handler)", () => {
131
- let originalFetch;
132
- beforeEach(() => {
133
- originalFetch = global.fetch;
134
- });
135
- afterEach(() => {
136
- global.fetch = originalFetch;
137
- vi.restoreAllMocks();
138
- });
139
- it("creates the session with handover-prepare in basePayload and returns the session PAT in headers", async () => {
140
- const requests = [];
141
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
142
- const urlStr = typeof url === "string" ? url : url.toString();
143
- requests.push({ url: urlStr, init });
144
- if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
145
- return createSessionResponse("chat-1");
146
- }
147
- if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
148
- return appendOkResponse();
149
- }
150
- throw new Error(`Unexpected URL: ${urlStr}`);
151
- });
152
- const handler = chat.headStart({
153
- agentId: "test-agent",
154
- run: async ({ chat: chatHelper }) => {
155
- return streamText({
156
- ...chatHelper.toStreamTextOptions(),
157
- model: new MockLanguageModelV3({
158
- doStream: async () => ({ stream: textStream("hi back") }),
159
- }),
160
- });
161
- },
162
- });
163
- const res = await withApiContext(() => handler(makeRequest({
164
- chatId: "chat-1",
165
- trigger: "submit-message",
166
- headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
167
- })));
168
- expect(res.status).toBe(200);
169
- expect(res.headers.get("X-Trigger-Chat-Id")).toBe("chat-1");
170
- expect(res.headers.get("X-Trigger-Chat-Access-Token")).toBe(SESSION_PAT);
171
- expect(res.headers.get("Content-Type")).toMatch(/text\/event-stream/);
172
- const sessionCreate = requests.find((r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/"));
173
- expect(sessionCreate).toBeDefined();
174
- const body = JSON.parse(sessionCreate.init.body);
175
- expect(body.type).toBe("chat.agent");
176
- expect(body.externalId).toBe("chat-1");
177
- expect(body.taskIdentifier).toBe("test-agent");
178
- // The trigger payload is rewritten to handover-prepare even though the
179
- // browser sent submit-message — the agent boots into the handover wait branch.
180
- expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
181
- expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
182
- expect(body.triggerConfig.basePayload.idleTimeoutInSeconds).toBe(60);
183
- });
184
- it("merges triggerConfig tags and queue into createSession", async () => {
185
- const requests = [];
186
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
187
- const urlStr = typeof url === "string" ? url : url.toString();
188
- requests.push({ url: urlStr, init });
189
- if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
190
- return createSessionResponse("chat-1");
191
- }
192
- if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
193
- return appendOkResponse();
194
- }
195
- if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
196
- return new Response(new ReadableStream({
197
- start(c) {
198
- c.close();
199
- },
200
- }), {
201
- status: 200,
202
- headers: { "content-type": "text/event-stream" },
203
- });
204
- }
205
- throw new Error(`Unexpected URL: ${urlStr}`);
206
- });
207
- const handler = chat.headStart({
208
- agentId: "test-agent",
209
- triggerConfig: {
210
- tags: ["org:acme", "agentic-run:xyz"],
211
- queue: "my-queue",
212
- },
213
- run: async ({ chat: chatHelper }) => {
214
- return streamText({
215
- ...chatHelper.toStreamTextOptions(),
216
- model: new MockLanguageModelV3({
217
- doStream: async () => ({ stream: textStream("hi back") }),
218
- }),
219
- });
220
- },
221
- });
222
- await withApiContext(() => handler(makeRequest({
223
- chatId: "chat-1",
224
- trigger: "submit-message",
225
- headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
226
- })));
227
- const sessionCreate = requests.find((r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/"));
228
- expect(sessionCreate).toBeDefined();
229
- const body = JSON.parse(sessionCreate.init.body);
230
- expect(body.triggerConfig.tags).toEqual(["chat:chat-1", "org:acme", "agentic-run:xyz"]);
231
- expect(body.triggerConfig.queue).toBe("my-queue");
232
- expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
233
- expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
234
- });
235
- it("dispatches handover with isFinal=true on pure-text finishReason", async () => {
236
- const requests = [];
237
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
238
- const urlStr = typeof url === "string" ? url : url.toString();
239
- requests.push({ url: urlStr, init });
240
- if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
241
- return createSessionResponse("chat-final");
242
- }
243
- if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
244
- return appendOkResponse();
245
- }
246
- // Stitched response subscribes to `.out` after handover.
247
- if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
248
- return new Response(new ReadableStream({
249
- start(c) {
250
- c.close();
251
- },
252
- }), {
253
- status: 200,
254
- headers: { "content-type": "text/event-stream" },
255
- });
256
- }
257
- throw new Error(`Unexpected URL: ${urlStr}`);
258
- });
259
- const handler = chat.headStart({
260
- agentId: "test-agent",
261
- run: async ({ chat: chatHelper }) => {
262
- return streamText({
263
- ...chatHelper.toStreamTextOptions(),
264
- model: new MockLanguageModelV3({
265
- doStream: async () => ({ stream: textStream("just a text reply") }),
266
- }),
267
- });
268
- },
269
- });
270
- const res = await withApiContext(() => handler(makeRequest({
271
- chatId: "chat-final",
272
- trigger: "submit-message",
273
- // Slim wire: head-start ships full history via `headStartMessages`
274
- // (not `messages` / `message`). The route handler reads that field
275
- // off the request body before invoking the customer's run().
276
- headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }],
277
- })));
278
- // Drain the SSE body so handoverWhenDone observes finishReason.
279
- const chunks = await readSSEBodyToChunks(res);
280
- expect(chunks.some((c) => c.type === "text-delta")).toBe(true);
281
- // Give the deferred handoverWhenDone a tick to dispatch.
282
- await new Promise((r) => setTimeout(r, 30));
283
- const handoverPost = requests.find((r) => r.url.includes("/realtime/v1/sessions/chat-final/in/append") && r.init?.body !== undefined);
284
- expect(handoverPost).toBeDefined();
285
- const body = JSON.parse(handoverPost.init.body);
286
- // Pure-text finishes go through `kind: "handover"` with `isFinal: true`
287
- // so the agent runs hooks (persistence, etc.) without making an LLM call.
288
- expect(body.kind).toBe("handover");
289
- expect(body.isFinal).toBe(true);
290
- // The partial carries the customer's response messages — a single
291
- // assistant message with the streamed text.
292
- expect(Array.isArray(body.partialAssistantMessage)).toBe(true);
293
- const assistant = body.partialAssistantMessage.find((m) => m.role === "assistant");
294
- expect(assistant).toBeDefined();
295
- });
296
- it("dispatches handover with response.messages on tool-call finishReason", async () => {
297
- const requests = [];
298
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
299
- const urlStr = typeof url === "string" ? url : url.toString();
300
- requests.push({ url: urlStr, init });
301
- if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
302
- return createSessionResponse("chat-tool");
303
- }
304
- if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
305
- return appendOkResponse();
306
- }
307
- // Stitched response now subscribes to `.out` after handover to
308
- // pick up agent-side chunks. Return an empty SSE body that
309
- // closes immediately — this test validates dispatch only, not
310
- // the agent-side resume.
311
- if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) {
312
- return new Response(new ReadableStream({
313
- start(c) {
314
- c.close();
315
- },
316
- }), {
317
- status: 200,
318
- headers: { "content-type": "text/event-stream" },
319
- });
320
- }
321
- throw new Error(`Unexpected URL: ${urlStr}`);
322
- });
323
- // Schema-only tool — no execute. The mock model emits a tool-call;
324
- // AI SDK doesn't run it (no execute) and finishes with "tool-calls".
325
- const { tool } = await import("ai");
326
- const { z } = await import("zod");
327
- const weatherTool = tool({
328
- description: "weather",
329
- inputSchema: z.object({ city: z.string() }),
330
- });
331
- const handler = chat.headStart({
332
- agentId: "test-agent",
333
- run: async ({ chat: chatHelper }) => {
334
- return streamText({
335
- ...chatHelper.toStreamTextOptions({ tools: { weather: weatherTool } }),
336
- model: new MockLanguageModelV3({
337
- doStream: async () => ({ stream: toolCallStream() }),
338
- }),
339
- });
340
- },
341
- });
342
- const res = await withApiContext(() => handler(makeRequest({
343
- chatId: "chat-tool",
344
- trigger: "submit-message",
345
- headStartMessages: [
346
- { id: "m1", role: "user", parts: [{ type: "text", text: "weather in tokyo?" }] },
347
- ],
348
- })));
349
- await readSSEBodyToChunks(res);
350
- await new Promise((r) => setTimeout(r, 30));
351
- const handoverPost = requests.find((r) => r.url.includes("/realtime/v1/sessions/chat-tool/in/append") && r.init?.body !== undefined);
352
- expect(handoverPost).toBeDefined();
353
- const body = JSON.parse(handoverPost.init.body);
354
- expect(body.kind).toBe("handover");
355
- expect(body.isFinal).toBe(false); // pending tool-calls — agent runs streamText
356
- expect(Array.isArray(body.partialAssistantMessage)).toBe(true);
357
- // The partial is reshaped into AI SDK's tool-approval round so the
358
- // agent's `streamText` can resume by executing the pending tool-call
359
- // before step 2. Assistant gets a `tool-approval-request` part
360
- // alongside the original `tool-call`; a trailing `tool` message
361
- // carries the `tool-approval-response { approved: true }`.
362
- const assistant = body.partialAssistantMessage.find((m) => m.role === "assistant");
363
- expect(assistant).toBeDefined();
364
- const toolCallPart = assistant.content.find((p) => p.type === "tool-call");
365
- expect(toolCallPart).toBeDefined();
366
- const approvalRequestPart = assistant.content.find((p) => p.type === "tool-approval-request");
367
- expect(approvalRequestPart).toBeDefined();
368
- expect(approvalRequestPart.toolCallId).toBe(toolCallPart.toolCallId);
369
- const trailingTool = body.partialAssistantMessage[body.partialAssistantMessage.length - 1];
370
- expect(trailingTool.role).toBe("tool");
371
- const approvalResponsePart = trailingTool.content.find((p) => p.type === "tool-approval-response");
372
- expect(approvalResponsePart).toBeDefined();
373
- expect(approvalResponsePart.approvalId).toBe(approvalRequestPart.approvalId);
374
- expect(approvalResponsePart.approved).toBe(true);
375
- });
376
- it("rejects requests missing chatId", async () => {
377
- global.fetch = vi.fn().mockResolvedValue(new Response("nope", { status: 500 }));
378
- const handler = chat.headStart({
379
- agentId: "test-agent",
380
- run: async ({ chat: chatHelper }) => {
381
- return streamText({
382
- ...chatHelper.toStreamTextOptions(),
383
- model: new MockLanguageModelV3({
384
- doStream: async () => ({ stream: textStream("x") }),
385
- }),
386
- });
387
- },
388
- });
389
- await expect(withApiContext(() => handler(makeRequest({
390
- // no chatId
391
- trigger: "submit-message",
392
- messages: [],
393
- })))).rejects.toThrow(/chatId/);
394
- });
395
- });
396
- describe("chat.startHeadStart (detached)", () => {
397
- let originalFetch;
398
- beforeEach(() => {
399
- originalFetch = global.fetch;
400
- });
401
- afterEach(() => {
402
- global.fetch = originalFetch;
403
- vi.restoreAllMocks();
404
- });
405
- function wireFetch(requests) {
406
- global.fetch = vi.fn().mockImplementation(async (url, init) => {
407
- const urlStr = typeof url === "string" ? url : url.toString();
408
- requests.push({ url: urlStr, init });
409
- if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) {
410
- return createSessionResponse("chat-1");
411
- }
412
- if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) {
413
- return appendOkResponse();
414
- }
415
- throw new Error(`Unexpected URL: ${urlStr}`);
416
- });
417
- }
418
- const userMessages = [
419
- { id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] },
420
- ];
421
- it("returns { chatId, completion } (no Response) and creates the session with handover-prepare + headStartMessages", async () => {
422
- const requests = [];
423
- wireFetch(requests);
424
- const result = await withApiContext(() => chat.startHeadStart({
425
- agentId: "test-agent",
426
- chatId: "chat-1",
427
- messages: userMessages,
428
- run: async ({ chat: chatHelper }) => streamText({
429
- ...chatHelper.toStreamTextOptions(),
430
- model: new MockLanguageModelV3({
431
- doStream: async () => ({ stream: textStream("hi back") }),
432
- }),
433
- }),
434
- }));
435
- // Shape: a plain object, not a Response.
436
- expect(result.chatId).toBe("chat-1");
437
- expect(typeof result.completion.then).toBe("function");
438
- expect(result).not.toBeInstanceOf(Response);
439
- await result.completion;
440
- const sessionCreate = requests.find((r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/"));
441
- expect(sessionCreate).toBeDefined();
442
- const body = JSON.parse(sessionCreate.init.body);
443
- expect(body.type).toBe("chat.agent");
444
- expect(body.externalId).toBe("chat-1");
445
- expect(body.taskIdentifier).toBe("test-agent");
446
- expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare");
447
- expect(body.triggerConfig.basePayload.chatId).toBe("chat-1");
448
- // Full first-turn history rides on headStartMessages (not /in/append).
449
- expect(body.triggerConfig.basePayload.headStartMessages).toHaveLength(1);
450
- expect(body.triggerConfig.basePayload.headStartMessages[0].id).toBe("m1");
451
- });
452
- it("dispatches a final handover (isFinal: true) on a pure-text step 1", async () => {
453
- const requests = [];
454
- wireFetch(requests);
455
- const { completion } = await withApiContext(() => chat.startHeadStart({
456
- agentId: "test-agent",
457
- chatId: "chat-1",
458
- messages: userMessages,
459
- run: async ({ chat: chatHelper }) => streamText({
460
- ...chatHelper.toStreamTextOptions(),
461
- model: new MockLanguageModelV3({
462
- doStream: async () => ({ stream: textStream("the answer") }),
463
- }),
464
- }),
465
- }));
466
- await completion;
467
- const append = requests.find((r) => r.url.endsWith("/in/append"));
468
- expect(append).toBeDefined();
469
- const appendBody = append.init.body;
470
- expect(appendBody).toContain('"kind":"handover"');
471
- expect(appendBody).toContain('"isFinal":true');
472
- // A stable assistant messageId is carried across the handover boundary.
473
- expect(appendBody).toContain('"messageId":');
474
- });
475
- it("dispatches a non-final handover (isFinal: false) on a tool-call step 1", async () => {
476
- const requests = [];
477
- wireFetch(requests);
478
- const { completion } = await withApiContext(() => chat.startHeadStart({
479
- agentId: "test-agent",
480
- chatId: "chat-1",
481
- messages: userMessages,
482
- run: async ({ chat: chatHelper }) => streamText({
483
- ...chatHelper.toStreamTextOptions(),
484
- model: new MockLanguageModelV3({
485
- doStream: async () => ({ stream: toolCallStream() }),
486
- }),
487
- }),
488
- }));
489
- await completion;
490
- const append = requests.find((r) => r.url.endsWith("/in/append"));
491
- expect(append).toBeDefined();
492
- const appendBody = append.init.body;
493
- expect(appendBody).toContain('"kind":"handover"');
494
- expect(appendBody).toContain('"isFinal":false');
495
- });
496
- it("merges metadata into the handover-prepare run payload (never to the browser)", async () => {
497
- const requests = [];
498
- wireFetch(requests);
499
- const { completion } = await withApiContext(() => chat.startHeadStart({
500
- agentId: "test-agent",
501
- chatId: "chat-1",
502
- messages: userMessages,
503
- metadata: { userActorToken: "tr_uat_secret", projectRef: "proj_x" },
504
- run: async ({ chat: chatHelper }) => streamText({
505
- ...chatHelper.toStreamTextOptions(),
506
- model: new MockLanguageModelV3({
507
- doStream: async () => ({ stream: textStream("ok") }),
508
- }),
509
- }),
510
- }));
511
- await completion;
512
- const sessionCreate = requests.find((r) => r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/"));
513
- const body = JSON.parse(sessionCreate.init.body);
514
- expect(body.triggerConfig.basePayload.metadata.userActorToken).toBe("tr_uat_secret");
515
- expect(body.triggerConfig.basePayload.metadata.projectRef).toBe("proj_x");
516
- });
517
- it("signals handover-skip and rejects completion when the warm step throws", async () => {
518
- const requests = [];
519
- wireFetch(requests);
520
- const { completion } = await withApiContext(() => chat.startHeadStart({
521
- agentId: "test-agent",
522
- chatId: "chat-1",
523
- messages: userMessages,
524
- run: async () => {
525
- throw new Error("warm step boom");
526
- },
527
- }));
528
- await expect(completion).rejects.toThrow("warm step boom");
529
- const append = requests.find((r) => r.url.endsWith("/in/append"));
530
- expect(append).toBeDefined();
531
- expect(append.init.body).toContain('"kind":"handover-skip"');
532
- });
533
- });
534
- describe("chat.toNodeListener", () => {
535
- /**
536
- * Build a fake Node IncomingMessage that yields a JSON body.
537
- * AsyncIterable so the listener can `for await` over it.
538
- */
539
- function fakeNodeRequest(opts) {
540
- const bodyBytes = opts.body ? new TextEncoder().encode(opts.body) : undefined;
541
- const headers = {
542
- host: opts.host ?? "example.com",
543
- ...(opts.body ? { "content-type": "application/json" } : {}),
544
- ...(opts.headers ?? {}),
545
- };
546
- const errorListeners = [];
547
- return {
548
- method: opts.method ?? "POST",
549
- url: opts.url ?? "/api/chat",
550
- headers,
551
- on(event, listener) {
552
- if (event === "error")
553
- errorListeners.push(listener);
554
- return this;
555
- },
556
- async *[Symbol.asyncIterator]() {
557
- if (bodyBytes)
558
- yield bodyBytes;
559
- },
560
- };
561
- }
562
- function fakeNodeResponse() {
563
- const writes = [];
564
- let ended = false;
565
- let endChunk;
566
- const closeListeners = [];
567
- const headers = {};
568
- const obj = {
569
- statusCode: 200,
570
- headersSent: false,
571
- setHeader(name, value) {
572
- headers[name.toLowerCase()] = value;
573
- },
574
- write(chunk) {
575
- if (typeof chunk === "string") {
576
- writes.push(new TextEncoder().encode(chunk));
577
- }
578
- else {
579
- writes.push(chunk);
580
- }
581
- obj.headersSent = true;
582
- return true;
583
- },
584
- end(chunk) {
585
- ended = true;
586
- endChunk = chunk;
587
- },
588
- on(event, listener) {
589
- if (event === "close")
590
- closeListeners.push(listener);
591
- return obj;
592
- },
593
- // test helpers
594
- _written() {
595
- const all = [...writes];
596
- if (typeof endChunk === "string")
597
- all.push(new TextEncoder().encode(endChunk));
598
- else if (endChunk)
599
- all.push(endChunk);
600
- let total = 0;
601
- for (const c of all)
602
- total += c.length;
603
- const merged = new Uint8Array(total);
604
- let offset = 0;
605
- for (const c of all) {
606
- merged.set(c, offset);
607
- offset += c.length;
608
- }
609
- return new TextDecoder().decode(merged);
610
- },
611
- _ended: () => ended,
612
- _headers: () => headers,
613
- _close: () => {
614
- for (const l of closeListeners)
615
- l();
616
- },
617
- };
618
- return obj;
619
- }
620
- it("converts the Node request into a Web Request, calls the handler, and forwards the response", async () => {
621
- const seen = {};
622
- const webHandler = async (req) => {
623
- seen.method = req.method;
624
- seen.url = req.url;
625
- seen.ct = req.headers.get("content-type");
626
- seen.body = await req.text();
627
- return new Response("ok", {
628
- status: 201,
629
- headers: { "x-test": "1", "content-type": "text/plain" },
630
- });
631
- };
632
- const listener = chat.toNodeListener(webHandler);
633
- const req = fakeNodeRequest({ body: '{"hello":"world"}' });
634
- const res = fakeNodeResponse();
635
- await listener(req, res);
636
- expect(seen.method).toBe("POST");
637
- expect(seen.url).toBe("http://example.com/api/chat");
638
- expect(seen.ct).toBe("application/json");
639
- expect(seen.body).toBe('{"hello":"world"}');
640
- expect(res.statusCode).toBe(201);
641
- expect(res._headers()["x-test"]).toBe("1");
642
- expect(res._written()).toBe("ok");
643
- expect(res._ended()).toBe(true);
644
- });
645
- it("streams the Web Response body to the Node response chunk by chunk (no buffering)", async () => {
646
- const chunkOrder = [];
647
- const webHandler = async () => {
648
- const encoder = new TextEncoder();
649
- const stream = new ReadableStream({
650
- async start(controller) {
651
- for (const piece of ["one\n", "two\n", "three\n"]) {
652
- chunkOrder.push("emit-" + piece.trim());
653
- controller.enqueue(encoder.encode(piece));
654
- await new Promise((r) => setTimeout(r, 5));
655
- }
656
- controller.close();
657
- },
658
- });
659
- return new Response(stream, {
660
- status: 200,
661
- headers: { "content-type": "text/event-stream" },
662
- });
663
- };
664
- const listener = chat.toNodeListener(webHandler);
665
- const req = fakeNodeRequest({});
666
- const res = fakeNodeResponse();
667
- await listener(req, res);
668
- expect(res._written()).toBe("one\ntwo\nthree\n");
669
- expect(chunkOrder).toEqual(["emit-one", "emit-two", "emit-three"]);
670
- expect(res._headers()["content-type"]).toBe("text/event-stream");
671
- });
672
- it("propagates client disconnect to the Web handler via AbortSignal", async () => {
673
- let signal;
674
- let aborted = false;
675
- const webHandler = async (req) => {
676
- signal = req.signal;
677
- signal.addEventListener("abort", () => {
678
- aborted = true;
679
- });
680
- // Return a never-ending stream so the listener stays open until close.
681
- return new Response(new ReadableStream({
682
- start() {
683
- // never enqueues
684
- },
685
- }));
686
- };
687
- const listener = chat.toNodeListener(webHandler);
688
- const req = fakeNodeRequest({});
689
- const res = fakeNodeResponse();
690
- // Run listener in background (it'll hang on the never-ending stream).
691
- const pending = listener(req, res);
692
- // Wait a tick for the handler to attach the abort listener.
693
- await new Promise((r) => setTimeout(r, 5));
694
- res._close();
695
- expect(aborted).toBe(true);
696
- // Cleanup: the listener will throw (abort) and we don't care about the result.
697
- await pending.catch(() => { });
698
- });
699
- it("returns 500 with error text if the handler throws before headers are sent", async () => {
700
- const webHandler = async () => {
701
- throw new Error("boom");
702
- };
703
- const listener = chat.toNodeListener(webHandler);
704
- const req = fakeNodeRequest({});
705
- const res = fakeNodeResponse();
706
- await listener(req, res);
707
- expect(res.statusCode).toBe(500);
708
- expect(res._written()).toBe("boom");
709
- });
710
- });
711
- //# sourceMappingURL=chat-server.test.js.map