@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,734 @@
1
+ /**
2
+ * Server-side helpers for the `chat.agent` head-start flow — a
3
+ * customer's warm process (Next.js route handler, Express, etc.)
4
+ * gets the conversation moving while the heavy chat.agent run boots
5
+ * in parallel. Mid-turn, ownership of the durable stream hands over
6
+ * to the agent.
7
+ *
8
+ * The `chat.headStart({ agentId, run })` entry point returns a
9
+ * Next.js-style POST handler. Inside the customer's `run` callback
10
+ * they call `streamText` themselves, spreading
11
+ * `chat.toStreamTextOptions({ tools })` to inherit handover wiring.
12
+ * The handler runs `streamText` step 1 in the customer's process
13
+ * while the chat.agent run boots in parallel; on `tool-calls` the
14
+ * agent run picks up tool execution and continues, on pure-text the
15
+ * agent run exits clean without an LLM call.
16
+ *
17
+ * Two-layer naming: customer-facing surface is "head start"
18
+ * (describes the *benefit* — fast first-turn TTFC). The internal
19
+ * protocol still uses "handover" (describes the *mechanism* — the
20
+ * conversation hands off mid-turn from the warm process to the
21
+ * agent). Customers see `chat.headStart`, `HeadStartSession`, etc.
22
+ * The wire format and run-loop locals stay on `handover` /
23
+ * `handover-prepare` / `handover-skip`.
24
+ *
25
+ * Cooperative ordering only — handler stops writing to `session.out`
26
+ * before sending the `handover` chunk on `session.in`. No S2 fencing.
27
+ *
28
+ * ⚠️ HARD CONSTRAINT — bundle isolation
29
+ *
30
+ * This module is the customer-facing boundary for the route handler.
31
+ * The whole TTFC win comes from the customer's process being
32
+ * lightweight while the heavy agent run boots in parallel. **The
33
+ * route-handler bundle must not include heavy tool execute deps**:
34
+ * E2B, puppeteer/playwright, native bindings, the trigger SDK
35
+ * runtime, turndown, image processing libs, anything that pulls
36
+ * weight or pulls `node:` builtins.
37
+ *
38
+ * "Schema-only" tools must live in a module that imports only `ai`
39
+ * (for `tool()`) and `zod`. The agent task module imports those
40
+ * schemas and adds execute fns elsewhere — that's where the heavy
41
+ * deps live, and it's never reached by the route handler bundle.
42
+ *
43
+ * Runtime "strip executes" helpers (anything that takes a tool
44
+ * catalog with executes and removes them) DO NOT solve this. The
45
+ * import chain is resolved at bundle/build time, so importing the
46
+ * full catalog drags every dep in regardless of what the SDK does
47
+ * with the value at runtime.
48
+ *
49
+ * IMPORTANT (internal): this module must NOT import from `./ai.ts`.
50
+ * `ai.ts` statically imports `agentSkillsRuntime` (which uses `node:`
51
+ * builtins unfit for some serverless runtimes) and the heavy task
52
+ * runtime. Allowed imports: `./ai-shared.js`, `./chat-client.js`,
53
+ * `@trigger.dev/core/v3` (api client), `ai` (types + lightweight
54
+ * helpers like `stepCountIs` / `convertToModelMessages`).
55
+ */
56
+ import { SessionStreamInstance, TRIGGER_CONTROL_SUBTYPE, apiClientManager, } from "@trigger.dev/core/v3";
57
+ import { convertToModelMessages, generateId as generateAssistantMessageId, stepCountIs, } from "ai";
58
+ // ---------------------------------------------------------------------------
59
+ // Public API
60
+ // ---------------------------------------------------------------------------
61
+ export const chat = {
62
+ /**
63
+ * Returns a Next.js-style POST handler for the chat.agent
64
+ * head-start flow. Customer mounts it as
65
+ * `export const { POST } = chat.headStart({...})` (or
66
+ * `export const POST = chat.headStart({...})`).
67
+ *
68
+ * Pair with the browser transport's `headStart: "/api/chat"`
69
+ * option so the first message of a brand-new chat lands here
70
+ * before the agent run boots.
71
+ */
72
+ headStart(opts) {
73
+ return async (req) => {
74
+ const session = await openHandoverSession({
75
+ req,
76
+ agentId: opts.agentId,
77
+ idleTimeoutInSeconds: opts.idleTimeoutInSeconds,
78
+ });
79
+ const helper = {
80
+ toStreamTextOptions(spreadOpts) {
81
+ return session.buildStreamTextOptions(spreadOpts);
82
+ },
83
+ session: session.handle,
84
+ };
85
+ const result = await opts.run({
86
+ messages: session.uiMessages,
87
+ signal: session.combinedSignal,
88
+ chat: helper,
89
+ });
90
+ return session.handle.handoverResponse(result);
91
+ };
92
+ },
93
+ /**
94
+ * Lower-level primitive for power users who want to call
95
+ * `streamText` themselves outside the `run` callback shape — custom
96
+ * transforms, non-AI-SDK code paths, or manual control over the
97
+ * response. Same wiring `chat.headStart` builds on internally.
98
+ */
99
+ openSession(opts) {
100
+ return openHandoverSession(opts).then((s) => s.handle);
101
+ },
102
+ /**
103
+ * Wrap a Web Fetch handler — `(req: Request) => Promise<Response>` —
104
+ * as a Node `http` listener — `(req: IncomingMessage, res: ServerResponse) => Promise<void>`.
105
+ *
106
+ * Use this to mount `chat.headStart` (or any other Web Fetch
107
+ * handler) inside Node-only frameworks like Express, Fastify, Koa,
108
+ * or raw `node:http`. Web-native frameworks (Next.js App Router,
109
+ * Hono, SvelteKit, Remix, Workers, Bun, Deno, etc.) don't need
110
+ * this — they pass `Request` objects directly.
111
+ *
112
+ * Streams the response body chunk-by-chunk to the Node response,
113
+ * so the `chat.headStart` SSE chunks reach the browser as they
114
+ * arrive (no buffering). Aborts the underlying handler if the
115
+ * client closes the connection.
116
+ *
117
+ * Type-only import of `node:http` types — no runtime dep on `node:http`,
118
+ * so this stays safe to bundle into edge / Workers builds (the
119
+ * function just won't be called there).
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * import express from "express";
124
+ * import { chat } from "@trigger.dev/sdk/chat-server";
125
+ *
126
+ * const handler = chat.headStart({
127
+ * agentId: "my-chat",
128
+ * run: async ({ chat: helper }) => streamText({ ... }),
129
+ * });
130
+ *
131
+ * const app = express();
132
+ * app.post("/api/chat", chat.toNodeListener(handler));
133
+ * ```
134
+ */
135
+ toNodeListener,
136
+ };
137
+ async function openHandoverSession(opts) {
138
+ const wirePayload = (await opts.req.json());
139
+ const chatId = wirePayload.chatId;
140
+ if (!chatId) {
141
+ throw new Error("[chat.handover] request body missing `chatId`");
142
+ }
143
+ // Slim wire — head-start ships full history via `headStartMessages` (not
144
+ // `message`/`messages`) because the route handler runs on the customer's
145
+ // own HTTP endpoint and isn't subject to the 512 KiB `/in/append` cap.
146
+ // The full UIMessage[] flows through `wirePayload` into the auto-trigger
147
+ // `basePayload` below, where the agent run boot consumes it on first turn.
148
+ const uiMessages = (wirePayload.headStartMessages ?? []);
149
+ // `convertToModelMessages` is async — resolve once up front so the
150
+ // synchronous `toStreamTextOptions` builder can hand back a fully
151
+ // formed object. AI SDK's `streamText` validates `messages` as a
152
+ // `ModelMessage[]` synchronously and rejects a Promise.
153
+ const modelMessages = await convertToModelMessages(uiMessages);
154
+ const apiClient = resolveApiClient();
155
+ const idleTimeoutInSeconds = opts.idleTimeoutInSeconds ?? 60;
156
+ // Create the session and trigger the chat.agent's `handover-prepare`
157
+ // run atomically. `createSession` is idempotent on `(env, externalId
158
+ // = chatId)` and the auto-triggered run uses `triggerConfig.
159
+ // basePayload` as the wire payload — so a single round-trip both
160
+ // ensures the session exists and starts the agent booting with the
161
+ // right trigger.
162
+ //
163
+ // Awaited intentionally: subsequent writes to `session.out` (the
164
+ // tee from the customer's `streamText` to S2) need the session to
165
+ // exist, and the handover signal at end-of-step-1 needs the agent
166
+ // run to be there to consume it. The added latency (~one round trip
167
+ // to the control plane) is bounded; the agent's compute boot still
168
+ // overlaps with LLM TTFB.
169
+ const created = await apiClient.createSession({
170
+ type: "chat.agent",
171
+ externalId: chatId,
172
+ taskIdentifier: opts.agentId,
173
+ triggerConfig: {
174
+ basePayload: {
175
+ ...wirePayload,
176
+ chatId,
177
+ trigger: "handover-prepare",
178
+ idleTimeoutInSeconds,
179
+ },
180
+ idleTimeoutInSeconds,
181
+ },
182
+ });
183
+ const sessionPublicAccessToken = created.publicAccessToken;
184
+ // Combined abort signal: request lifecycle OR an internal timeout
185
+ // mirroring the agent's idle wait so a hung handler doesn't sit
186
+ // forever.
187
+ const abortController = new AbortController();
188
+ const requestAbort = opts.req.signal;
189
+ if (requestAbort) {
190
+ if (requestAbort.aborted)
191
+ abortController.abort();
192
+ else
193
+ requestAbort.addEventListener("abort", () => abortController.abort(), { once: true });
194
+ }
195
+ const idleTimer = setTimeout(() => abortController.abort(new Error("chat.handover: idle timeout")), idleTimeoutInSeconds * 1000);
196
+ const buildStreamTextOptions = (spreadOpts) => {
197
+ // The customer spreads this object into their `streamText` call
198
+ // and then adds `model`, `system`, etc. on top. We set the four
199
+ // keys handover correctness depends on:
200
+ //
201
+ // - `messages`: the wire payload's UIMessages, converted
202
+ // (Promise resolved upfront so the spread is synchronous)
203
+ // - `tools`: customer's schema-only tool set
204
+ // - `stopWhen`: `stepCountIs(1)` — step 1 only. Agent run picks
205
+ // up tool execution and step 2+ after the handover signal.
206
+ // - `abortSignal`: combined request-lifecycle + idle timeout
207
+ //
208
+ // The customer's `StreamTextResult` exposes `finishReason` and
209
+ // `response.messages` directly, so we don't need to install an
210
+ // `onStepFinish` capture hook — we read those off the result in
211
+ // `handoverWhenDone`.
212
+ return {
213
+ messages: modelMessages,
214
+ tools: spreadOpts?.tools,
215
+ stopWhen: stepCountIs(1),
216
+ abortSignal: abortController.signal,
217
+ };
218
+ };
219
+ // Tee a UIMessage stream into session.out via S2 direct-write,
220
+ // batched. `SessionStreamInstance` calls `initializeSessionStream`
221
+ // once to fetch S2 credentials, then pipes via `StreamsWriterV2`'s
222
+ // `BatchTransform` — one S2 append per ~200ms of chunks instead of
223
+ // one HTTP round-trip per UIMessageChunk.
224
+ let sessionWriter = null;
225
+ const tee = (stream) => {
226
+ const [a, b] = stream.tee();
227
+ sessionWriter = new SessionStreamInstance({
228
+ apiClient,
229
+ baseUrl: apiClient.baseUrl,
230
+ sessionId: chatId, // Sessions are addressable by externalId (chatId).
231
+ io: "out",
232
+ source: b,
233
+ signal: abortController.signal,
234
+ });
235
+ return a;
236
+ };
237
+ /** Wait for the teed S2 writer to drain. Called before signaling handover. */
238
+ const flushSessionWriter = async () => {
239
+ if (!sessionWriter)
240
+ return;
241
+ try {
242
+ await sessionWriter.wait();
243
+ }
244
+ catch {
245
+ // Drop write errors — the customer's response stream is the
246
+ // source of truth for what the user sees. Durability/resume
247
+ // best-effort.
248
+ }
249
+ };
250
+ const handover = async (args) => {
251
+ const chunk = {
252
+ kind: "handover",
253
+ partialAssistantMessage: args.partialAssistantMessage,
254
+ messageId: args.messageId,
255
+ isFinal: args.isFinal,
256
+ };
257
+ await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
258
+ };
259
+ /**
260
+ * Sent only on dispatch error (handler aborted before producing a
261
+ * `finishReason`). Normal pure-text and tool-call finishes go
262
+ * through `handover()` with the appropriate `isFinal` flag.
263
+ */
264
+ const handoverSkip = async () => {
265
+ const chunk = { kind: "handover-skip" };
266
+ await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk));
267
+ };
268
+ // A stable assistant messageId for this turn. The customer's
269
+ // `toUIMessageStream` is configured to emit its `start` chunk with
270
+ // this id, the handover signal carries it to the agent, and the
271
+ // agent's post-handover `toUIMessageStream` reuses it — so all
272
+ // chunks (customer's step 1 + agent's step 2) merge into one
273
+ // assistant message on the browser side.
274
+ const turnMessageId = generateAssistantMessageId();
275
+ let resolveDecision;
276
+ const decisionPromise = new Promise((resolve) => {
277
+ resolveDecision = resolve;
278
+ });
279
+ const handoverWhenDone = async (result) => {
280
+ // Owns idle-timer cleanup via the finally below, so both the
281
+ // sugar (`handoverResponse`) and the escape-hatch
282
+ // (`chat.openSession()` → `handle.handoverWhenDone(...)`) clean up
283
+ // the timer the same way.
284
+ try {
285
+ // `result.finishReason` is a Promise<FinishReason> on the AI SDK
286
+ // result. Wait for the stream to settle, then dispatch.
287
+ const finishReason = await result.finishReason;
288
+ // Drain the S2 tee so any in-flight handler writes (last
289
+ // `tool-input-available` parts, the synthetic `finish-step` for
290
+ // pure-text) are visible before the agent reads from session.out
291
+ // / session.in. Cooperative ordering — agent doesn't read past
292
+ // these unless we've finished writing them.
293
+ await flushSessionWriter();
294
+ const responseMessages = (await result.response).messages;
295
+ if (finishReason === "tool-calls") {
296
+ // Reshape pending tool-calls into AI SDK's tool-approval round
297
+ // so the agent's `streamText` resumes by executing them
298
+ // before the step-2 LLM call.
299
+ const reshaped = reshapeForHandoverResume(responseMessages);
300
+ await handover({
301
+ partialAssistantMessage: reshaped,
302
+ messageId: turnMessageId,
303
+ isFinal: false,
304
+ });
305
+ }
306
+ else {
307
+ // Pure-text (or any non-tool-calls) finish — customer's step 1
308
+ // IS the final response. The agent runs the turn-loop hooks
309
+ // (`onChatStart`, `onTurnStart`, `onTurnComplete`, etc.) using
310
+ // this partial as the response, but skips the LLM call. That
311
+ // way persistence (`onTurnComplete` writing to DB), self-
312
+ // review, and any post-turn work all fire normally.
313
+ await handover({
314
+ partialAssistantMessage: responseMessages,
315
+ messageId: turnMessageId,
316
+ isFinal: true,
317
+ });
318
+ }
319
+ resolveDecision({ kind: "handover" });
320
+ }
321
+ catch (err) {
322
+ // Dispatch failed before we could send the handover signal.
323
+ // Tell the agent to exit clean (no hooks fire) and close the
324
+ // response stream so it doesn't hang waiting for agent chunks.
325
+ resolveDecision({ kind: "handover-skip" });
326
+ try {
327
+ await handoverSkip();
328
+ }
329
+ catch {
330
+ // best-effort
331
+ }
332
+ throw err;
333
+ }
334
+ finally {
335
+ clearTimeout(idleTimer);
336
+ }
337
+ };
338
+ /**
339
+ * Build a single ReadableStream that:
340
+ * 1. Forwards the customer's `streamText` chunks (step 1) directly
341
+ * to the response — same low-latency path as before.
342
+ * 2. After step 1 ends and the dispatch decision lands:
343
+ * - `handover-skip`: closes the response immediately. The agent
344
+ * run exits without writing more chunks.
345
+ * - `handover`: subscribes to `session.out` from the sequence
346
+ * ID where the customer's tee left off, forwarding the agent
347
+ * run's chunks (tool-output-available, step 2 LLM text,
348
+ * `finish-step`, etc.) until `trigger:turn-complete`.
349
+ *
350
+ * The browser sees one continuous SSE response per first turn, just
351
+ * like a normal `streamText` would produce.
352
+ */
353
+ const stitchHandoverStream = (customerBranch) => {
354
+ return new ReadableStream({
355
+ async start(controller) {
356
+ try {
357
+ // Phase 1: forward customer's chunks.
358
+ const reader = customerBranch.getReader();
359
+ try {
360
+ while (true) {
361
+ const { done, value } = await reader.read();
362
+ if (done)
363
+ break;
364
+ controller.enqueue(value);
365
+ }
366
+ }
367
+ finally {
368
+ reader.releaseLock();
369
+ }
370
+ // Phase 2a: wait for handoverWhenDone to decide.
371
+ const decision = await decisionPromise;
372
+ if (decision.kind === "handover-skip") {
373
+ controller.close();
374
+ return;
375
+ }
376
+ // Phase 2b: agent is taking over. Resume from session.out
377
+ // starting AFTER the customer tee's last write, so we don't
378
+ // re-emit chunks the browser already saw.
379
+ const writeResult = sessionWriter
380
+ ? await sessionWriter.wait().catch(() => undefined)
381
+ : undefined;
382
+ const customerLastEventId = writeResult?.lastEventId;
383
+ // Capture the latest S2 event id seen on session.out via
384
+ // `onPart`. After the stream closes we emit it to the
385
+ // browser as a `trigger:session-state` control chunk so the
386
+ // transport can hydrate `state.lastEventId` for turn 2's
387
+ // subscribe — without it, turn 2 reads session.out from the
388
+ // start and replays turn 1 to the user.
389
+ //
390
+ // The agent's `turn-complete` control record is now header-
391
+ // form on S2 (see `client-protocol.mdx`), so the
392
+ // `for await (const chunk of agentStream)` loop below NEVER
393
+ // sees it as a data chunk — `subscribeToSessionStream` routes
394
+ // it to `onControl`. Use that to know when to stop and
395
+ // synthesise the data-chunk shape the browser bridge still
396
+ // expects (this HTTP response stream is NOT S2 and keeps the
397
+ // legacy chunk shape for the customer-server-to-browser hop).
398
+ let latestEventId;
399
+ let turnComplete = false;
400
+ // Dedicated abort signal for this agent subscription. Aborted
401
+ // from `onControl` the moment turn-complete fires so the
402
+ // `for await` loop below exits immediately instead of blocking
403
+ // until S2's long-poll closes (~60s). Combined with the outer
404
+ // `abortController.signal` via `AbortSignal.any` so a request-
405
+ // wide abort still tears the subscription down.
406
+ const subscriptionAbort = new AbortController();
407
+ const agentStream = await apiClient.subscribeToSessionStream(chatId, "out", {
408
+ ...(customerLastEventId != null
409
+ ? { lastEventId: customerLastEventId }
410
+ : {}),
411
+ signal: AbortSignal.any([abortController.signal, subscriptionAbort.signal]),
412
+ onPart: (part) => {
413
+ if (part.id)
414
+ latestEventId = part.id;
415
+ },
416
+ onControl: (event) => {
417
+ if (event.subtype === TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
418
+ turnComplete = true;
419
+ // Synthesise the data-chunk shape for the browser
420
+ // bridge. The customer-server-to-browser response is
421
+ // not S2; it keeps the legacy chunk shape so the
422
+ // browser's transport can recognise turn-complete the
423
+ // same way it always has.
424
+ controller.enqueue({
425
+ type: "trigger:turn-complete",
426
+ });
427
+ // Stop the SSE read now. Without this the `for await`
428
+ // can't see the control event (control records are
429
+ // never enqueued into the data stream) and would idle
430
+ // until S2's long-poll timeout closes the connection.
431
+ subscriptionAbort.abort();
432
+ }
433
+ },
434
+ });
435
+ try {
436
+ for await (const chunk of agentStream) {
437
+ // Data records only — control records are routed via
438
+ // `onControl` above and trigger the subscription abort.
439
+ controller.enqueue(chunk);
440
+ if (turnComplete)
441
+ break;
442
+ }
443
+ }
444
+ catch (err) {
445
+ // AbortError from `subscriptionAbort` is the expected exit
446
+ // path once turn-complete fires; surface anything else.
447
+ const isAbort = err instanceof Error && err.name === "AbortError";
448
+ if (!isAbort || !turnComplete)
449
+ throw err;
450
+ }
451
+ // Final control chunk: hand the browser transport the
452
+ // `lastEventId` it should use for the next turn's
453
+ // session.out subscribe. Filtered out before reaching the
454
+ // AI SDK on the browser side.
455
+ if (latestEventId != null) {
456
+ controller.enqueue({
457
+ type: "trigger:session-state",
458
+ lastEventId: latestEventId,
459
+ });
460
+ }
461
+ controller.close();
462
+ }
463
+ catch (err) {
464
+ controller.error(err);
465
+ }
466
+ },
467
+ cancel() {
468
+ // Browser closed the connection. Trigger the abort so any
469
+ // pending session.out subscription stops too.
470
+ abortController.abort();
471
+ },
472
+ });
473
+ };
474
+ const handoverResponse = (result) => {
475
+ // `generateMessageId` makes the customer's `start` chunk carry
476
+ // `turnMessageId`, so the browser-side AI SDK keys the assistant
477
+ // message by it. The agent's post-handover stream emits chunks
478
+ // with the same id (passed via the handover signal) — both sides
479
+ // merge into one message on the browser.
480
+ const teed = tee(result.toUIMessageStream({
481
+ generateMessageId: () => turnMessageId,
482
+ }));
483
+ // `handoverWhenDone` re-throws on dispatch failure for visibility,
484
+ // but the recovery (resolveDecision + handoverSkip) has already run
485
+ // by then and `stitchHandoverStream` closes the response cleanly via
486
+ // `decisionPromise`. The user-facing path is fine; we only suppress
487
+ // the unhandled-rejection so processes started with
488
+ // `--unhandled-rejections=throw` don't crash on what is effectively
489
+ // a logged failure with no further action to take.
490
+ // (Idle-timer cleanup lives inside `handoverWhenDone` itself.)
491
+ void handoverWhenDone(result).catch(() => { });
492
+ const stitched = stitchHandoverStream(teed);
493
+ // Encode UIMessageChunks as SSE for the AI SDK transport on the
494
+ // browser. AI SDK's `toUIMessageStreamResponse()` does this same
495
+ // thing internally; replicate the format here so we don't have
496
+ // to bridge through the SDK's response helper.
497
+ const encoder = new TextEncoder();
498
+ const sseStream = stitched.pipeThrough(new TransformStream({
499
+ transform(chunk, controller) {
500
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
501
+ },
502
+ }));
503
+ return new Response(sseStream, {
504
+ headers: {
505
+ "Content-Type": "text/event-stream",
506
+ "X-Vercel-AI-UI-Message-Stream": "v1",
507
+ "Cache-Control": "no-cache, no-transform",
508
+ Connection: "keep-alive",
509
+ // Browser transport reads these to hydrate session state
510
+ // for subsequent (non-handover) turns. Once the browser has
511
+ // the PAT it talks directly to `session.in` / `session.out`
512
+ // without going back through the handler.
513
+ "X-Trigger-Chat-Id": chatId,
514
+ "X-Trigger-Chat-Access-Token": sessionPublicAccessToken,
515
+ },
516
+ });
517
+ };
518
+ const handle = {
519
+ chatId,
520
+ tee,
521
+ handoverWhenDone,
522
+ handoverResponse,
523
+ handover,
524
+ handoverSkip,
525
+ };
526
+ return {
527
+ uiMessages,
528
+ combinedSignal: abortController.signal,
529
+ handle,
530
+ buildStreamTextOptions,
531
+ };
532
+ }
533
+ function resolveApiClient() {
534
+ // Reuse the SDK's standard apiClientManager so customers configure
535
+ // base URL + secret key the same way as for `tasks.trigger(...)`.
536
+ const client = apiClientManager.clientOrThrow();
537
+ return client;
538
+ }
539
+ /** @internal — exposed via `chat.toNodeListener`. */
540
+ function toNodeListener(webHandler) {
541
+ return async function nodeListener(req, res) {
542
+ const abort = new AbortController();
543
+ res.on("close", () => abort.abort());
544
+ try {
545
+ const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`;
546
+ const method = req.method ?? "GET";
547
+ const hasBody = method !== "GET" && method !== "HEAD";
548
+ // Read full body upfront. Chat wire payloads are small (sub-KB
549
+ // typically) so accumulating avoids the duplex-stream ceremony
550
+ // some Node versions need for streaming request bodies into
551
+ // a Web Request.
552
+ let body;
553
+ if (hasBody) {
554
+ const chunks = [];
555
+ for await (const chunk of req) {
556
+ chunks.push(chunk);
557
+ }
558
+ if (chunks.length > 0) {
559
+ let total = 0;
560
+ for (const c of chunks)
561
+ total += c.length;
562
+ const merged = new Uint8Array(total);
563
+ let offset = 0;
564
+ for (const c of chunks) {
565
+ merged.set(c, offset);
566
+ offset += c.length;
567
+ }
568
+ body = merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength);
569
+ }
570
+ }
571
+ // Flatten Node header values: arrays → comma-joined (per RFC 7230 §3.2.2).
572
+ const webHeaders = new Headers();
573
+ for (const [name, value] of Object.entries(req.headers)) {
574
+ if (value == null)
575
+ continue;
576
+ if (Array.isArray(value)) {
577
+ for (const v of value)
578
+ webHeaders.append(name, v);
579
+ }
580
+ else {
581
+ webHeaders.set(name, value);
582
+ }
583
+ }
584
+ const webReq = new Request(url, {
585
+ method,
586
+ headers: webHeaders,
587
+ body,
588
+ signal: abort.signal,
589
+ });
590
+ const webRes = await webHandler(webReq);
591
+ res.statusCode = webRes.status;
592
+ // `Headers.forEach` exposes the value comma-joined for multi-valued
593
+ // headers, which `setHeader` accepts. Set-Cookie is handled separately
594
+ // via `getSetCookie()` to preserve multiple values.
595
+ webRes.headers.forEach((value, key) => {
596
+ if (key.toLowerCase() === "set-cookie")
597
+ return;
598
+ res.setHeader(key, value);
599
+ });
600
+ const setCookies = typeof webRes.headers.getSetCookie === "function"
601
+ ? webRes.headers.getSetCookie()
602
+ : [];
603
+ if (setCookies.length > 0) {
604
+ res.setHeader("set-cookie", setCookies);
605
+ }
606
+ if (!webRes.body) {
607
+ res.end();
608
+ return;
609
+ }
610
+ // Pipe the Web Response body to the Node response. On client
611
+ // disconnect (`abort.signal`), cancel the reader so a pending
612
+ // `read()` rejects and we exit the loop instead of blocking on
613
+ // a stream that will never produce more chunks.
614
+ const reader = webRes.body.getReader();
615
+ const onAbort = () => {
616
+ reader.cancel(abort.signal.reason).catch(() => { });
617
+ };
618
+ if (abort.signal.aborted)
619
+ onAbort();
620
+ else
621
+ abort.signal.addEventListener("abort", onAbort, { once: true });
622
+ try {
623
+ while (true) {
624
+ const { done, value } = await reader.read();
625
+ if (done)
626
+ break;
627
+ res.write(value);
628
+ }
629
+ }
630
+ catch {
631
+ // Reader was cancelled (client disconnect). Silently end.
632
+ }
633
+ finally {
634
+ abort.signal.removeEventListener("abort", onAbort);
635
+ }
636
+ res.end();
637
+ }
638
+ catch (err) {
639
+ if (!res.headersSent) {
640
+ res.statusCode = 500;
641
+ res.setHeader("content-type", "text/plain; charset=utf-8");
642
+ res.end(err instanceof Error ? err.message : "Internal error");
643
+ }
644
+ else {
645
+ res.end();
646
+ }
647
+ }
648
+ };
649
+ }
650
+ /**
651
+ * Reshape a step-1 partial so the agent's `streamText` resumes by
652
+ * executing pending tool-calls before the next LLM call.
653
+ *
654
+ * When the customer's handler runs `streamText` with schema-only tools
655
+ * (no `execute` fns) and `stopWhen: stepCountIs(1)`, the LLM emits
656
+ * tool-calls but AI SDK can't execute them — the partial we ship is
657
+ * `[{ assistant: text + tool-call }]`. Splicing that as-is onto the
658
+ * agent's accumulator and calling `streamText` throws
659
+ * `MissingToolResultsError` synchronously inside
660
+ * `convertToLanguageModelPrompt`.
661
+ *
662
+ * AI SDK's documented escape hatch for "external party decides what
663
+ * to do with a tool-call, then SDK executes" is the tool-approval
664
+ * round. By appending a `tool-approval-request` part to the assistant
665
+ * message and a trailing `tool` message with a matching
666
+ * `tool-approval-response { approved: true }`, AI SDK:
667
+ * 1. Suppresses `MissingToolResultsError` for approved tool-calls
668
+ * (`convert-to-language-model-prompt.ts:135-144`).
669
+ * 2. Hits its initial-tool-execution branch
670
+ * (`stream-text.ts:1342-1486`) on the next `streamText` call,
671
+ * runs the agent-side `execute` fns, and synthesizes
672
+ * `tool-result` parts before the step-2 LLM call.
673
+ *
674
+ * If the customer's tools already had `execute` fns (rare for the
675
+ * handover use case but valid), the partial already contains a
676
+ * `tool-result` per tool-call — we leave those alone and only inject
677
+ * approvals for genuinely-pending calls.
678
+ *
679
+ * `collectToolApprovals` only scans the LAST message
680
+ * (`collect-tool-approvals.ts:30-37`), so the synthesized tool message
681
+ * must end up at the tail of the partial. The agent's run-loop
682
+ * splices the partial onto the end of the accumulator, which keeps
683
+ * this invariant.
684
+ */
685
+ function reshapeForHandoverResume(responseMessages) {
686
+ // First pass: gather the set of tool-call IDs that already have a
687
+ // matching tool-result. Those are "complete" — leave them alone.
688
+ const completedToolCallIds = new Set();
689
+ for (const message of responseMessages) {
690
+ if (message.role !== "tool" || typeof message.content === "string")
691
+ continue;
692
+ for (const part of message.content) {
693
+ if (part.type === "tool-result" && part.toolCallId) {
694
+ completedToolCallIds.add(part.toolCallId);
695
+ }
696
+ }
697
+ }
698
+ // Second pass: clone the messages, appending a tool-approval-request
699
+ // alongside each pending tool-call. Collect the matching responses.
700
+ const approvalResponses = [];
701
+ let approvalCounter = 0;
702
+ const reshaped = responseMessages.map((message) => {
703
+ if (message.role !== "assistant" || typeof message.content === "string") {
704
+ return message;
705
+ }
706
+ const newContent = [...message.content];
707
+ for (const part of message.content) {
708
+ if (part.type === "tool-call" &&
709
+ part.toolCallId &&
710
+ !completedToolCallIds.has(part.toolCallId)) {
711
+ const approvalId = `handover-approval-${++approvalCounter}`;
712
+ newContent.push({
713
+ type: "tool-approval-request",
714
+ approvalId,
715
+ toolCallId: part.toolCallId,
716
+ });
717
+ approvalResponses.push({
718
+ type: "tool-approval-response",
719
+ approvalId,
720
+ approved: true,
721
+ });
722
+ }
723
+ }
724
+ return { ...message, content: newContent };
725
+ });
726
+ if (approvalResponses.length > 0) {
727
+ reshaped.push({
728
+ role: "tool",
729
+ content: approvalResponses,
730
+ });
731
+ }
732
+ return reshaped;
733
+ }
734
+ //# sourceMappingURL=chat-server.js.map