@trigger.dev/sdk 4.4.6 → 4.5.0-rc.0
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.
- package/dist/commonjs/v3/agentSkillsRuntime.d.ts +28 -0
- package/dist/commonjs/v3/agentSkillsRuntime.js +163 -0
- package/dist/commonjs/v3/agentSkillsRuntime.js.map +1 -0
- package/dist/commonjs/v3/ai-shared.d.ts +173 -0
- package/dist/commonjs/v3/ai-shared.js +25 -0
- package/dist/commonjs/v3/ai-shared.js.map +1 -0
- package/dist/commonjs/v3/ai.d.ts +2823 -5
- package/dist/commonjs/v3/ai.js +6197 -13
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/auth.d.ts +9 -0
- package/dist/commonjs/v3/auth.js.map +1 -1
- package/dist/commonjs/v3/chat-client.d.ts +301 -0
- package/dist/commonjs/v3/chat-client.js +624 -0
- package/dist/commonjs/v3/chat-client.js.map +1 -0
- package/dist/commonjs/v3/chat-react.d.ts +155 -0
- package/dist/commonjs/v3/chat-react.js +330 -0
- package/dist/commonjs/v3/chat-react.js.map +1 -0
- package/dist/commonjs/v3/chat-server.d.ts +206 -0
- package/dist/commonjs/v3/chat-server.js +737 -0
- package/dist/commonjs/v3/chat-server.js.map +1 -0
- package/dist/commonjs/v3/chat-server.test.d.ts +1 -0
- package/dist/commonjs/v3/chat-server.test.js +518 -0
- package/dist/commonjs/v3/chat-server.test.js.map +1 -0
- package/dist/commonjs/v3/chat-tab-coordinator.d.ts +65 -0
- package/dist/commonjs/v3/chat-tab-coordinator.js +235 -0
- package/dist/commonjs/v3/chat-tab-coordinator.js.map +1 -0
- package/dist/commonjs/v3/chat-tab-coordinator.test.d.ts +1 -0
- package/dist/commonjs/v3/chat-tab-coordinator.test.js +140 -0
- package/dist/commonjs/v3/chat-tab-coordinator.test.js.map +1 -0
- package/dist/commonjs/v3/chat.d.ts +437 -0
- package/dist/commonjs/v3/chat.js +968 -0
- package/dist/commonjs/v3/chat.js.map +1 -0
- package/dist/commonjs/v3/chat.test.d.ts +1 -0
- package/dist/commonjs/v3/chat.test.js +1180 -0
- package/dist/commonjs/v3/chat.test.js.map +1 -0
- package/dist/commonjs/v3/createStartSessionAction.test.d.ts +1 -0
- package/dist/commonjs/v3/createStartSessionAction.test.js +113 -0
- package/dist/commonjs/v3/createStartSessionAction.test.js.map +1 -0
- package/dist/commonjs/v3/deployments.d.ts +26 -0
- package/dist/commonjs/v3/deployments.js +37 -0
- package/dist/commonjs/v3/deployments.js.map +1 -0
- package/dist/commonjs/v3/index.d.ts +6 -3
- package/dist/commonjs/v3/index.js +7 -1
- package/dist/commonjs/v3/index.js.map +1 -1
- package/dist/commonjs/v3/runs.d.ts +22 -7
- package/dist/commonjs/v3/runs.js +1 -0
- package/dist/commonjs/v3/runs.js.map +1 -1
- package/dist/commonjs/v3/sessions.d.ts +228 -0
- package/dist/commonjs/v3/sessions.js +664 -0
- package/dist/commonjs/v3/sessions.js.map +1 -0
- package/dist/commonjs/v3/sessions.test.d.ts +1 -0
- package/dist/commonjs/v3/sessions.test.js +154 -0
- package/dist/commonjs/v3/sessions.test.js.map +1 -0
- package/dist/commonjs/v3/shared.d.ts +24 -2
- package/dist/commonjs/v3/shared.js +189 -1
- package/dist/commonjs/v3/shared.js.map +1 -1
- package/dist/commonjs/v3/skill.d.ts +99 -0
- package/dist/commonjs/v3/skill.js +155 -0
- package/dist/commonjs/v3/skill.js.map +1 -0
- package/dist/commonjs/v3/skills.d.ts +2 -0
- package/dist/commonjs/v3/skills.js +6 -0
- package/dist/commonjs/v3/skills.js.map +1 -0
- package/dist/commonjs/v3/streams.js +127 -19
- package/dist/commonjs/v3/streams.js.map +1 -1
- package/dist/commonjs/v3/tasks.d.ts +2 -1
- package/dist/commonjs/v3/tasks.js +1 -0
- package/dist/commonjs/v3/tasks.js.map +1 -1
- package/dist/commonjs/v3/test/index.d.ts +3 -0
- package/dist/commonjs/v3/test/index.js +18 -0
- package/dist/commonjs/v3/test/index.js.map +1 -0
- package/dist/commonjs/v3/test/mock-chat-agent.d.ts +259 -0
- package/dist/commonjs/v3/test/mock-chat-agent.js +468 -0
- package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -0
- package/dist/commonjs/v3/test/setup-catalog.d.ts +1 -0
- package/dist/commonjs/v3/test/setup-catalog.js +18 -0
- package/dist/commonjs/v3/test/setup-catalog.js.map +1 -0
- package/dist/commonjs/v3/test/test-session-handle.d.ts +53 -0
- package/dist/commonjs/v3/test/test-session-handle.js +256 -0
- package/dist/commonjs/v3/test/test-session-handle.js.map +1 -0
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/agentSkillsRuntime.d.ts +28 -0
- package/dist/esm/v3/agentSkillsRuntime.js +136 -0
- package/dist/esm/v3/agentSkillsRuntime.js.map +1 -0
- package/dist/esm/v3/ai-shared.d.ts +173 -0
- package/dist/esm/v3/ai-shared.js +22 -0
- package/dist/esm/v3/ai-shared.js.map +1 -0
- package/dist/esm/v3/ai.d.ts +2823 -5
- package/dist/esm/v3/ai.js +6187 -14
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/auth.d.ts +9 -0
- package/dist/esm/v3/auth.js.map +1 -1
- package/dist/esm/v3/chat-client.d.ts +301 -0
- package/dist/esm/v3/chat-client.js +619 -0
- package/dist/esm/v3/chat-client.js.map +1 -0
- package/dist/esm/v3/chat-react.d.ts +155 -0
- package/dist/esm/v3/chat-react.js +325 -0
- package/dist/esm/v3/chat-react.js.map +1 -0
- package/dist/esm/v3/chat-server.d.ts +206 -0
- package/dist/esm/v3/chat-server.js +734 -0
- package/dist/esm/v3/chat-server.js.map +1 -0
- package/dist/esm/v3/chat-server.test.d.ts +1 -0
- package/dist/esm/v3/chat-server.test.js +516 -0
- package/dist/esm/v3/chat-server.test.js.map +1 -0
- package/dist/esm/v3/chat-tab-coordinator.d.ts +65 -0
- package/dist/esm/v3/chat-tab-coordinator.js +231 -0
- package/dist/esm/v3/chat-tab-coordinator.js.map +1 -0
- package/dist/esm/v3/chat-tab-coordinator.test.d.ts +1 -0
- package/dist/esm/v3/chat-tab-coordinator.test.js +138 -0
- package/dist/esm/v3/chat-tab-coordinator.test.js.map +1 -0
- package/dist/esm/v3/chat.d.ts +437 -0
- package/dist/esm/v3/chat.js +961 -0
- package/dist/esm/v3/chat.js.map +1 -0
- package/dist/esm/v3/chat.test.d.ts +1 -0
- package/dist/esm/v3/chat.test.js +1178 -0
- package/dist/esm/v3/chat.test.js.map +1 -0
- package/dist/esm/v3/createStartSessionAction.test.d.ts +1 -0
- package/dist/esm/v3/createStartSessionAction.test.js +111 -0
- package/dist/esm/v3/createStartSessionAction.test.js.map +1 -0
- package/dist/esm/v3/deployments.d.ts +26 -0
- package/dist/esm/v3/deployments.js +34 -0
- package/dist/esm/v3/deployments.js.map +1 -0
- package/dist/esm/v3/index.d.ts +6 -3
- package/dist/esm/v3/index.js +4 -1
- package/dist/esm/v3/index.js.map +1 -1
- package/dist/esm/v3/runs.d.ts +15 -0
- package/dist/esm/v3/runs.js +1 -0
- package/dist/esm/v3/runs.js.map +1 -1
- package/dist/esm/v3/sessions.d.ts +228 -0
- package/dist/esm/v3/sessions.js +656 -0
- package/dist/esm/v3/sessions.js.map +1 -0
- package/dist/esm/v3/sessions.test.d.ts +1 -0
- package/dist/esm/v3/sessions.test.js +152 -0
- package/dist/esm/v3/sessions.test.js.map +1 -0
- package/dist/esm/v3/shared.d.ts +24 -2
- package/dist/esm/v3/shared.js +188 -1
- package/dist/esm/v3/shared.js.map +1 -1
- package/dist/esm/v3/skill.d.ts +99 -0
- package/dist/esm/v3/skill.js +128 -0
- package/dist/esm/v3/skill.js.map +1 -0
- package/dist/esm/v3/skills.d.ts +2 -0
- package/dist/esm/v3/skills.js +2 -0
- package/dist/esm/v3/skills.js.map +1 -0
- package/dist/esm/v3/streams.js +127 -20
- package/dist/esm/v3/streams.js.map +1 -1
- package/dist/esm/v3/tasks.d.ts +2 -1
- package/dist/esm/v3/tasks.js +2 -1
- package/dist/esm/v3/tasks.js.map +1 -1
- package/dist/esm/v3/test/index.d.ts +3 -0
- package/dist/esm/v3/test/index.js +13 -0
- package/dist/esm/v3/test/index.js.map +1 -0
- package/dist/esm/v3/test/mock-chat-agent.d.ts +259 -0
- package/dist/esm/v3/test/mock-chat-agent.js +465 -0
- package/dist/esm/v3/test/mock-chat-agent.js.map +1 -0
- package/dist/esm/v3/test/setup-catalog.d.ts +1 -0
- package/dist/esm/v3/test/setup-catalog.js +16 -0
- package/dist/esm/v3/test/setup-catalog.js.map +1 -0
- package/dist/esm/v3/test/test-session-handle.d.ts +53 -0
- package/dist/esm/v3/test/test-session-handle.js +251 -0
- package/dist/esm/v3/test/test-session-handle.js.map +1 -0
- package/dist/esm/version.js +1 -1
- package/package.json +87 -6
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module @trigger.dev/sdk/chat
|
|
4
|
+
*
|
|
5
|
+
* Browser-safe module for AI SDK chat transport integration.
|
|
6
|
+
* Use this on the frontend with the AI SDK's `useChat` hook.
|
|
7
|
+
*
|
|
8
|
+
* For backend helpers (`chatAgent`, `pipeChat`), use `@trigger.dev/sdk/ai` instead.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```tsx
|
|
12
|
+
* import { useChat } from "@ai-sdk/react";
|
|
13
|
+
* import { TriggerChatTransport } from "@trigger.dev/sdk/chat";
|
|
14
|
+
*
|
|
15
|
+
* function Chat() {
|
|
16
|
+
* const { messages, sendMessage, status } = useChat({
|
|
17
|
+
* transport: new TriggerChatTransport({
|
|
18
|
+
* task: "my-chat-task",
|
|
19
|
+
* accessToken: async ({ chatId }) => fetchSessionToken(chatId),
|
|
20
|
+
* startSession: async ({ chatId, taskId }) => createChatSession({ chatId, taskId }),
|
|
21
|
+
* }),
|
|
22
|
+
* });
|
|
23
|
+
* }
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.ChatStream = exports.AgentChat = exports.TriggerChatTransport = void 0;
|
|
28
|
+
exports.createChatTransport = createChatTransport;
|
|
29
|
+
const v3_1 = require("@trigger.dev/core/v3");
|
|
30
|
+
const chat_tab_coordinator_js_1 = require("./chat-tab-coordinator.js");
|
|
31
|
+
const DEFAULT_BASE_URL = "https://api.trigger.dev";
|
|
32
|
+
const DEFAULT_STREAM_TIMEOUT_SECONDS = 120;
|
|
33
|
+
/**
|
|
34
|
+
* Detect 401/403 from realtime/input-stream calls without relying on `instanceof`
|
|
35
|
+
* (Vitest can load duplicate `@trigger.dev/core` copies, which breaks subclass checks).
|
|
36
|
+
*/
|
|
37
|
+
function isAuthError(error) {
|
|
38
|
+
if (error === null || typeof error !== "object")
|
|
39
|
+
return false;
|
|
40
|
+
const e = error;
|
|
41
|
+
return e.name === "TriggerApiError" && (e.status === 401 || e.status === 403);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Parses an SSE byte/text stream of `data: <UIMessageChunk JSON>\n\n`
|
|
45
|
+
* frames back into `UIMessageChunk` objects. Used by the handover
|
|
46
|
+
* first-turn path to convert the customer's route handler response
|
|
47
|
+
* (which is AI-SDK-shaped SSE text) into the chunk form the AI SDK's
|
|
48
|
+
* `useChat` consumes from a transport.
|
|
49
|
+
*
|
|
50
|
+
* Spec-light parser — assumes well-formed `data:` events from our own
|
|
51
|
+
* `chat.handover` SSE writer. Lines starting with `:` (comments) and
|
|
52
|
+
* other event types are ignored.
|
|
53
|
+
*/
|
|
54
|
+
function parseUIMessageSseTransform() {
|
|
55
|
+
let buffer = "";
|
|
56
|
+
return new TransformStream({
|
|
57
|
+
transform(chunk, controller) {
|
|
58
|
+
buffer += chunk;
|
|
59
|
+
// Frames are separated by blank lines.
|
|
60
|
+
let idx = buffer.indexOf("\n\n");
|
|
61
|
+
while (idx !== -1) {
|
|
62
|
+
const frame = buffer.slice(0, idx);
|
|
63
|
+
buffer = buffer.slice(idx + 2);
|
|
64
|
+
for (const line of frame.split("\n")) {
|
|
65
|
+
if (line.startsWith("data: ")) {
|
|
66
|
+
const data = line.slice(6).trim();
|
|
67
|
+
if (!data)
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
controller.enqueue(JSON.parse(data));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* drop malformed chunk; the response source is our own writer */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
idx = buffer.indexOf("\n\n");
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
flush(controller) {
|
|
81
|
+
// Trailing data without a closing blank line — treat as a final frame.
|
|
82
|
+
if (buffer.trim().length === 0)
|
|
83
|
+
return;
|
|
84
|
+
for (const line of buffer.split("\n")) {
|
|
85
|
+
if (line.startsWith("data: ")) {
|
|
86
|
+
const data = line.slice(6).trim();
|
|
87
|
+
if (!data)
|
|
88
|
+
continue;
|
|
89
|
+
try {
|
|
90
|
+
controller.enqueue(JSON.parse(data));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* drop */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
buffer = "";
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* A custom AI SDK `ChatTransport` that runs chat completions as durable
|
|
103
|
+
* Trigger.dev tasks via the Sessions primitive.
|
|
104
|
+
*
|
|
105
|
+
* Lifecycle:
|
|
106
|
+
* 1. Customer pre-creates the session server-side OR calls
|
|
107
|
+
* `transport.start(chatId)` to mint a one-shot start token and
|
|
108
|
+
* `POST /api/v1/sessions` from the browser.
|
|
109
|
+
* 2. The server triggers the first run as part of session create and
|
|
110
|
+
* returns a session-scoped PAT.
|
|
111
|
+
* 3. `sendMessages` appends to `.in` and subscribes to `.out`. When a
|
|
112
|
+
* run dies (idle, cancel, end-and-continue), the server's
|
|
113
|
+
* append-time probe triggers a fresh run for the same session —
|
|
114
|
+
* transport keeps streaming.
|
|
115
|
+
* 4. `stop()` posts a `{kind:"stop"}` chunk; the agent's turn aborts
|
|
116
|
+
* but the run keeps reading `.in` for the next message.
|
|
117
|
+
* 5. PAT expiry: transport invokes `accessToken` to refresh and
|
|
118
|
+
* retries the failing request once.
|
|
119
|
+
*/
|
|
120
|
+
class TriggerChatTransport {
|
|
121
|
+
taskId;
|
|
122
|
+
resolveAccessToken;
|
|
123
|
+
resolveStartSession;
|
|
124
|
+
resolveBaseURLFn;
|
|
125
|
+
fetchOverride;
|
|
126
|
+
extraHeaders;
|
|
127
|
+
streamTimeoutSeconds;
|
|
128
|
+
defaultMetadata;
|
|
129
|
+
watchMode;
|
|
130
|
+
headStart;
|
|
131
|
+
coordinator = null;
|
|
132
|
+
_onSessionChange;
|
|
133
|
+
sessions = new Map();
|
|
134
|
+
activeStreams = new Map();
|
|
135
|
+
pendingStarts = new Map();
|
|
136
|
+
constructor(options) {
|
|
137
|
+
this.taskId = options.task;
|
|
138
|
+
this.resolveAccessToken = options.accessToken;
|
|
139
|
+
this.resolveStartSession = options.startSession;
|
|
140
|
+
const baseURLOption = options.baseURL ?? DEFAULT_BASE_URL;
|
|
141
|
+
const streamOverride = options.streamBaseURL;
|
|
142
|
+
this.resolveBaseURLFn = typeof baseURLOption === "function"
|
|
143
|
+
? (ctx) => (ctx.endpoint === "out" && streamOverride ? streamOverride : baseURLOption(ctx))
|
|
144
|
+
: (ctx) => (ctx.endpoint === "out" && streamOverride ? streamOverride : baseURLOption);
|
|
145
|
+
this.fetchOverride = options.fetch;
|
|
146
|
+
this.extraHeaders = options.headers ?? {};
|
|
147
|
+
this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? DEFAULT_STREAM_TIMEOUT_SECONDS;
|
|
148
|
+
this.defaultMetadata = options.clientData;
|
|
149
|
+
this._onSessionChange = options.onSessionChange;
|
|
150
|
+
this.watchMode = options.watch ?? false;
|
|
151
|
+
this.headStart = options.headStart;
|
|
152
|
+
if (options.multiTab && !this.watchMode) {
|
|
153
|
+
this.coordinator = new chat_tab_coordinator_js_1.ChatTabCoordinator();
|
|
154
|
+
this.coordinator.addSessionListener((chatId, sessionUpdate) => {
|
|
155
|
+
const session = this.sessions.get(chatId);
|
|
156
|
+
if (session && sessionUpdate.lastEventId) {
|
|
157
|
+
session.lastEventId = sessionUpdate.lastEventId;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (options.sessions) {
|
|
162
|
+
for (const [chatId, session] of Object.entries(options.sessions)) {
|
|
163
|
+
this.sessions.set(chatId, {
|
|
164
|
+
publicAccessToken: session.publicAccessToken,
|
|
165
|
+
lastEventId: session.lastEventId,
|
|
166
|
+
isStreaming: session.isStreaming,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
// Public lifecycle
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Eagerly create a Session and trigger its first run. Useful as a
|
|
176
|
+
* "the user might be about to send a message — boot the agent now"
|
|
177
|
+
* preload, or to take ownership of the session before any sendMessage.
|
|
178
|
+
*
|
|
179
|
+
* Idempotent: calling `start(chatId)` twice converges to the same
|
|
180
|
+
* session via the `(env, externalId)` upsert. Concurrent calls
|
|
181
|
+
* deduplicate via an in-flight promise.
|
|
182
|
+
*
|
|
183
|
+
* Requires `getStartToken` to be configured. Customers who pre-create
|
|
184
|
+
* sessions server-side don't need to call this.
|
|
185
|
+
*/
|
|
186
|
+
async start(chatId) {
|
|
187
|
+
const existing = this.sessions.get(chatId);
|
|
188
|
+
if (existing?.publicAccessToken) {
|
|
189
|
+
return this.toPersisted(existing);
|
|
190
|
+
}
|
|
191
|
+
const inflight = this.pendingStarts.get(chatId);
|
|
192
|
+
if (inflight)
|
|
193
|
+
return inflight.then(this.toPersisted);
|
|
194
|
+
const promise = this.doStart(chatId).finally(() => {
|
|
195
|
+
this.pendingStarts.delete(chatId);
|
|
196
|
+
});
|
|
197
|
+
this.pendingStarts.set(chatId, promise);
|
|
198
|
+
return promise.then(this.toPersisted);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Eagerly create the session before the user types. Same semantics as
|
|
202
|
+
* {@link start} — kept as a separate name for the AI SDK Chat hook,
|
|
203
|
+
* which calls `preload` rather than `start`.
|
|
204
|
+
*/
|
|
205
|
+
async preload(chatId) {
|
|
206
|
+
await this.start(chatId);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Send a user message via the session's `.in` channel. The server
|
|
210
|
+
* probes `currentRunId`; if terminal/null it triggers a fresh run on
|
|
211
|
+
* the same session before the append lands. The returned
|
|
212
|
+
* `ReadableStream` carries the agent's response chunks via `.out` SSE.
|
|
213
|
+
*/
|
|
214
|
+
sendMessages = async (options) => {
|
|
215
|
+
const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options;
|
|
216
|
+
if (this.coordinator) {
|
|
217
|
+
if (this.coordinator.isReadOnly(chatId)) {
|
|
218
|
+
throw new Error("This chat is active in another tab");
|
|
219
|
+
}
|
|
220
|
+
this.coordinator.claim(chatId);
|
|
221
|
+
}
|
|
222
|
+
const mergedMetadata = this.defaultMetadata || metadata
|
|
223
|
+
? { ...(this.defaultMetadata ?? {}), ...(metadata ?? {}) }
|
|
224
|
+
: undefined;
|
|
225
|
+
// First-turn handover routing — when `headStart` is set AND no
|
|
226
|
+
// session state exists yet for this chatId, POST the wire payload
|
|
227
|
+
// to the customer's `chat.handover` route handler. The handler
|
|
228
|
+
// creates the session, triggers the agent run with
|
|
229
|
+
// `handover-prepare`, runs `streamText` step 1 in its warm
|
|
230
|
+
// process, and tees the output back as the SSE response. We
|
|
231
|
+
// hydrate session state from the response headers so subsequent
|
|
232
|
+
// turns bypass the handler and use direct `session.in` writes.
|
|
233
|
+
if (this.headStart && !this.sessions.has(chatId)) {
|
|
234
|
+
return this.sendMessagesViaHandover({
|
|
235
|
+
trigger,
|
|
236
|
+
chatId,
|
|
237
|
+
messageId,
|
|
238
|
+
messages,
|
|
239
|
+
abortSignal,
|
|
240
|
+
body,
|
|
241
|
+
metadata: mergedMetadata,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
// Slim wire — at most ONE message per record. The agent rebuilds prior
|
|
245
|
+
// history from its durable S3 snapshot + session.out replay at run boot
|
|
246
|
+
// (or `hydrateMessages`, if registered). See plan vivid-humming-bonbon.
|
|
247
|
+
//
|
|
248
|
+
// - "submit-message": ship the latest message (new user message OR a
|
|
249
|
+
// tool-approval-responded assistant message). Throw if absent.
|
|
250
|
+
// - "regenerate-message": omit `message`; the agent slices its own
|
|
251
|
+
// history (drops the trailing assistant) and re-runs.
|
|
252
|
+
if (trigger === "submit-message" && messages.length === 0) {
|
|
253
|
+
throw new Error("TriggerChatTransport.sendMessages: 'submit-message' trigger requires at least one message");
|
|
254
|
+
}
|
|
255
|
+
const wirePayload = {
|
|
256
|
+
...(body ?? {}),
|
|
257
|
+
...(trigger === "submit-message" ? { message: messages.at(-1) } : {}),
|
|
258
|
+
chatId,
|
|
259
|
+
trigger,
|
|
260
|
+
messageId,
|
|
261
|
+
metadata: mergedMetadata,
|
|
262
|
+
};
|
|
263
|
+
const state = await this.ensureSessionState(chatId);
|
|
264
|
+
const sendChatMessage = async (token) => {
|
|
265
|
+
await this.appendInputChunk(chatId, token, this.serializeInputChunk({ kind: "message", payload: wirePayload }));
|
|
266
|
+
};
|
|
267
|
+
await this.callWithAuthRetry(chatId, state, sendChatMessage);
|
|
268
|
+
// Cancel any in-flight stream for this chat — the new turn supersedes it.
|
|
269
|
+
const activeStream = this.activeStreams.get(chatId);
|
|
270
|
+
if (activeStream) {
|
|
271
|
+
activeStream.abort();
|
|
272
|
+
this.activeStreams.delete(chatId);
|
|
273
|
+
}
|
|
274
|
+
state.isStreaming = true;
|
|
275
|
+
this.notifySessionChange(chatId, state);
|
|
276
|
+
return this.subscribeToSessionStream(state, abortSignal, chatId);
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* First-turn-only path used when `headStart` is configured. POSTs the
|
|
280
|
+
* wire payload to the customer's `chat.handover` route handler and
|
|
281
|
+
* pipes its SSE response back as a UIMessageChunk stream. Hydrates
|
|
282
|
+
* session state from response headers so subsequent turns bypass
|
|
283
|
+
* the endpoint and use the direct `session.in` path.
|
|
284
|
+
*/
|
|
285
|
+
async sendMessagesViaHandover(args) {
|
|
286
|
+
if (!this.headStart) {
|
|
287
|
+
throw new Error("sendMessagesViaHandover called without headStart configured");
|
|
288
|
+
}
|
|
289
|
+
// Head-start ships full UIMessage history via `headStartMessages`. The
|
|
290
|
+
// route handler runs on the customer's own HTTP endpoint (NOT
|
|
291
|
+
// `/realtime/v1/sessions/{id}/in/append`), so the 512 KiB body cap
|
|
292
|
+
// doesn't apply. The agent's run boot consumes `headStartMessages` ONLY
|
|
293
|
+
// when no snapshot exists yet (very first turn) — see plan section B.3.
|
|
294
|
+
const wirePayload = {
|
|
295
|
+
...(args.body ?? {}),
|
|
296
|
+
headStartMessages: args.messages,
|
|
297
|
+
chatId: args.chatId,
|
|
298
|
+
trigger: args.trigger,
|
|
299
|
+
messageId: args.messageId,
|
|
300
|
+
metadata: args.metadata,
|
|
301
|
+
};
|
|
302
|
+
const response = await fetch(this.headStart, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: {
|
|
305
|
+
"Content-Type": "application/json",
|
|
306
|
+
...this.extraHeaders,
|
|
307
|
+
},
|
|
308
|
+
body: JSON.stringify(wirePayload),
|
|
309
|
+
signal: args.abortSignal,
|
|
310
|
+
});
|
|
311
|
+
if (!response.ok) {
|
|
312
|
+
throw new Error(`chat.handover endpoint returned ${response.status} ${response.statusText}`);
|
|
313
|
+
}
|
|
314
|
+
if (!response.body) {
|
|
315
|
+
throw new Error("chat.handover endpoint returned no response body");
|
|
316
|
+
}
|
|
317
|
+
// Hydrate session state from response headers so subsequent turns
|
|
318
|
+
// skip the endpoint and write directly to session.in. Failing fast
|
|
319
|
+
// when the header is missing avoids a quiet degraded state where
|
|
320
|
+
// every later turn re-runs the handover route instead of taking
|
|
321
|
+
// the slim-wire path.
|
|
322
|
+
const accessToken = response.headers.get("X-Trigger-Chat-Access-Token");
|
|
323
|
+
const chatId = args.chatId;
|
|
324
|
+
if (!accessToken) {
|
|
325
|
+
throw new Error("chat.handover response is missing the X-Trigger-Chat-Access-Token header. chat.agent's handover endpoint must echo the session PAT so the transport can hydrate.");
|
|
326
|
+
}
|
|
327
|
+
const state = {
|
|
328
|
+
publicAccessToken: accessToken,
|
|
329
|
+
isStreaming: true,
|
|
330
|
+
};
|
|
331
|
+
this.sessions.set(chatId, state);
|
|
332
|
+
this.notifySessionChange(chatId, state);
|
|
333
|
+
// Filter the parsed UIMessage stream:
|
|
334
|
+
// - Drop control chunks (`trigger:turn-complete`,
|
|
335
|
+
// `trigger:session-state`) before they reach AI SDK — they
|
|
336
|
+
// aren't valid UIMessageChunks and the AI SDK chunk parser
|
|
337
|
+
// would reject them.
|
|
338
|
+
// - On `trigger:turn-complete`, clear `isStreaming` so the
|
|
339
|
+
// useChat resume / reconnectToStream path doesn't open a
|
|
340
|
+
// second `session.out` subscription on top of our stitched
|
|
341
|
+
// response.
|
|
342
|
+
// - On `trigger:session-state`, hydrate `state.lastEventId`
|
|
343
|
+
// with the agent's final S2 event id. Without this, turn 2's
|
|
344
|
+
// `session.out` subscribe reads from the start and replays
|
|
345
|
+
// turn 1's chunks back into the UI.
|
|
346
|
+
// - On stream end (handover-skip case — no
|
|
347
|
+
// `trigger:turn-complete` arrives, customer's stream just
|
|
348
|
+
// ends), also clear `isStreaming` for the same reason.
|
|
349
|
+
const sessions = this.sessions;
|
|
350
|
+
const notifyChange = (id, state) => this.notifySessionChange(id, state);
|
|
351
|
+
const TRIGGER_TURN_COMPLETE = "trigger:turn-complete";
|
|
352
|
+
const TRIGGER_SESSION_STATE = "trigger:session-state";
|
|
353
|
+
const clearStreaming = () => {
|
|
354
|
+
const state = sessions.get(chatId);
|
|
355
|
+
if (state && state.isStreaming) {
|
|
356
|
+
state.isStreaming = false;
|
|
357
|
+
notifyChange(chatId, state);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
const setLastEventId = (lastEventId) => {
|
|
361
|
+
const state = sessions.get(chatId);
|
|
362
|
+
if (state) {
|
|
363
|
+
state.lastEventId = lastEventId;
|
|
364
|
+
notifyChange(chatId, state);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
return response.body
|
|
368
|
+
.pipeThrough(new TextDecoderStream())
|
|
369
|
+
.pipeThrough(parseUIMessageSseTransform())
|
|
370
|
+
.pipeThrough(new TransformStream({
|
|
371
|
+
transform(chunk, controller) {
|
|
372
|
+
if (chunk && typeof chunk === "object") {
|
|
373
|
+
const type = chunk.type;
|
|
374
|
+
if (type === TRIGGER_TURN_COMPLETE) {
|
|
375
|
+
clearStreaming();
|
|
376
|
+
return; // drop — not a real UIMessageChunk
|
|
377
|
+
}
|
|
378
|
+
if (type === TRIGGER_SESSION_STATE) {
|
|
379
|
+
const lastEventId = chunk.lastEventId;
|
|
380
|
+
if (typeof lastEventId === "string") {
|
|
381
|
+
setLastEventId(lastEventId);
|
|
382
|
+
}
|
|
383
|
+
return; // drop
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
controller.enqueue(chunk);
|
|
387
|
+
},
|
|
388
|
+
flush() {
|
|
389
|
+
clearStreaming();
|
|
390
|
+
},
|
|
391
|
+
}));
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Send a steering message during an active stream without disrupting
|
|
395
|
+
* it. The agent's `pendingMessages` config decides whether to inject
|
|
396
|
+
* between tool-call steps or buffer for the next turn.
|
|
397
|
+
*/
|
|
398
|
+
sendPendingMessage = async (chatId, message, metadata) => {
|
|
399
|
+
const state = this.sessions.get(chatId);
|
|
400
|
+
if (!state)
|
|
401
|
+
return false;
|
|
402
|
+
const mergedMetadata = this.defaultMetadata || metadata
|
|
403
|
+
? { ...(this.defaultMetadata ?? {}), ...(metadata ?? {}) }
|
|
404
|
+
: undefined;
|
|
405
|
+
const wirePayload = {
|
|
406
|
+
message,
|
|
407
|
+
chatId,
|
|
408
|
+
trigger: "submit-message",
|
|
409
|
+
metadata: mergedMetadata,
|
|
410
|
+
};
|
|
411
|
+
const send = async (token) => {
|
|
412
|
+
await this.appendInputChunk(chatId, token, this.serializeInputChunk({ kind: "message", payload: wirePayload }));
|
|
413
|
+
};
|
|
414
|
+
try {
|
|
415
|
+
await this.callWithAuthRetry(chatId, state, send);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return false;
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
/**
|
|
423
|
+
* Re-establish an SSE subscription to a known session. Used after a
|
|
424
|
+
* page refresh: the customer hydrates `sessions` in the constructor,
|
|
425
|
+
* the AI SDK calls `reconnectToStream` to resume the stream.
|
|
426
|
+
*/
|
|
427
|
+
reconnectToStream = async (options) => {
|
|
428
|
+
const state = this.sessions.get(options.chatId);
|
|
429
|
+
if (!state)
|
|
430
|
+
return null;
|
|
431
|
+
if (state.isStreaming === false)
|
|
432
|
+
return null;
|
|
433
|
+
if (this.activeStreams.has(options.chatId))
|
|
434
|
+
return null;
|
|
435
|
+
const abortController = new AbortController();
|
|
436
|
+
this.activeStreams.set(options.chatId, abortController);
|
|
437
|
+
const abortSignal = options.abortSignal
|
|
438
|
+
? AbortSignal.any([options.abortSignal, abortController.signal])
|
|
439
|
+
: abortController.signal;
|
|
440
|
+
return this.subscribeToSessionStream(state, abortSignal, options.chatId, {
|
|
441
|
+
sendStopOnAbort: !!options.abortSignal,
|
|
442
|
+
// Reconnect-on-reload opts into the server's settled-peek shortcut
|
|
443
|
+
// so the SSE doesn't hang for 60s when no turn is in flight. Active
|
|
444
|
+
// send-a-message paths must keep wait=60 to avoid racing the
|
|
445
|
+
// freshly-triggered turn's first chunk.
|
|
446
|
+
peekSettled: true,
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
/**
|
|
450
|
+
* Stop the current generation. Sends `{kind:"stop"}` on `.in`; the
|
|
451
|
+
* agent aborts its `streamText` call but stays alive for the next
|
|
452
|
+
* message.
|
|
453
|
+
*/
|
|
454
|
+
stopGeneration = async (chatId) => {
|
|
455
|
+
const state = this.sessions.get(chatId);
|
|
456
|
+
if (!state)
|
|
457
|
+
return false;
|
|
458
|
+
const send = async (token) => {
|
|
459
|
+
await this.appendInputChunk(chatId, token, this.serializeInputChunk({ kind: "stop" }));
|
|
460
|
+
};
|
|
461
|
+
try {
|
|
462
|
+
await this.callWithAuthRetry(chatId, state, send);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
state.skipToTurnComplete = true;
|
|
468
|
+
const activeStream = this.activeStreams.get(chatId);
|
|
469
|
+
if (activeStream) {
|
|
470
|
+
activeStream.abort();
|
|
471
|
+
this.activeStreams.delete(chatId);
|
|
472
|
+
}
|
|
473
|
+
return true;
|
|
474
|
+
};
|
|
475
|
+
/**
|
|
476
|
+
* Send a custom action chunk (for `chat.agent`'s `actionSchema` /
|
|
477
|
+
* `onAction` hook). Actions are not turns — only `hydrateMessages`
|
|
478
|
+
* and `onAction` fire on the agent side. The returned stream
|
|
479
|
+
* carries any model response `onAction` produced (when it returns a
|
|
480
|
+
* `StreamTextResult`); for `void`-returning side-effect-only actions
|
|
481
|
+
* the stream completes immediately with `trigger:turn-complete`.
|
|
482
|
+
*/
|
|
483
|
+
sendAction = async (chatId, action) => {
|
|
484
|
+
if (this.coordinator) {
|
|
485
|
+
if (this.coordinator.isReadOnly(chatId)) {
|
|
486
|
+
throw new Error("This chat is active in another tab");
|
|
487
|
+
}
|
|
488
|
+
this.coordinator.claim(chatId);
|
|
489
|
+
}
|
|
490
|
+
const state = await this.ensureSessionState(chatId);
|
|
491
|
+
const wirePayload = {
|
|
492
|
+
chatId,
|
|
493
|
+
trigger: "action",
|
|
494
|
+
action,
|
|
495
|
+
metadata: this.defaultMetadata ?? undefined,
|
|
496
|
+
};
|
|
497
|
+
const body = this.serializeInputChunk({ kind: "message", payload: wirePayload });
|
|
498
|
+
const send = async (token) => {
|
|
499
|
+
await this.appendInputChunk(chatId, token, body);
|
|
500
|
+
};
|
|
501
|
+
await this.callWithAuthRetry(chatId, state, send);
|
|
502
|
+
return this.subscribeToSessionStream(state, undefined, chatId);
|
|
503
|
+
};
|
|
504
|
+
// -------------------------------------------------------------------------
|
|
505
|
+
// External-state surface
|
|
506
|
+
// -------------------------------------------------------------------------
|
|
507
|
+
getSession = (chatId) => {
|
|
508
|
+
const state = this.sessions.get(chatId);
|
|
509
|
+
if (!state)
|
|
510
|
+
return undefined;
|
|
511
|
+
return this.toPersisted(state);
|
|
512
|
+
};
|
|
513
|
+
setSession(chatId, session) {
|
|
514
|
+
this.sessions.set(chatId, {
|
|
515
|
+
publicAccessToken: session.publicAccessToken,
|
|
516
|
+
lastEventId: session.lastEventId,
|
|
517
|
+
isStreaming: session.isStreaming,
|
|
518
|
+
});
|
|
519
|
+
this.notifySessionChange(chatId, this.toPersisted(this.sessions.get(chatId)));
|
|
520
|
+
}
|
|
521
|
+
setOnSessionChange(callback) {
|
|
522
|
+
this._onSessionChange = callback;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Update the transport's `clientData`. Used by `useTriggerChatTransport`
|
|
526
|
+
* to keep the latest value reachable from inside `startSession` and
|
|
527
|
+
* the per-turn `metadata` merge without recreating the transport.
|
|
528
|
+
*
|
|
529
|
+
* Reads always go through the live field — closures around the
|
|
530
|
+
* transport see the latest value the next time they fire.
|
|
531
|
+
*/
|
|
532
|
+
setClientData(clientData) {
|
|
533
|
+
this.defaultMetadata = clientData;
|
|
534
|
+
}
|
|
535
|
+
// -------------------------------------------------------------------------
|
|
536
|
+
// Multi-tab coordination passthrough
|
|
537
|
+
// -------------------------------------------------------------------------
|
|
538
|
+
isReadOnly(chatId) {
|
|
539
|
+
return this.coordinator?.isReadOnly(chatId) ?? false;
|
|
540
|
+
}
|
|
541
|
+
hasClaim(chatId) {
|
|
542
|
+
return this.coordinator?.hasClaim(chatId) ?? false;
|
|
543
|
+
}
|
|
544
|
+
addReadOnlyListener(fn) {
|
|
545
|
+
this.coordinator?.addListener(fn);
|
|
546
|
+
}
|
|
547
|
+
removeReadOnlyListener(fn) {
|
|
548
|
+
this.coordinator?.removeListener(fn);
|
|
549
|
+
}
|
|
550
|
+
broadcastMessages(chatId, messages) {
|
|
551
|
+
this.coordinator?.broadcastMessages(chatId, messages);
|
|
552
|
+
}
|
|
553
|
+
addMessagesListener(fn) {
|
|
554
|
+
this.coordinator?.addMessagesListener(fn);
|
|
555
|
+
}
|
|
556
|
+
removeMessagesListener(fn) {
|
|
557
|
+
this.coordinator?.removeMessagesListener(fn);
|
|
558
|
+
}
|
|
559
|
+
dispose() {
|
|
560
|
+
// Tear down any open session.out subscriptions before the coordinator
|
|
561
|
+
// goes away. Otherwise controllers in `activeStreams` keep reading
|
|
562
|
+
// until they time out, leaking network and memory on every
|
|
563
|
+
// unmount/navigation.
|
|
564
|
+
for (const controller of this.activeStreams.values()) {
|
|
565
|
+
controller.abort();
|
|
566
|
+
}
|
|
567
|
+
this.activeStreams.clear();
|
|
568
|
+
this.coordinator?.dispose();
|
|
569
|
+
this.coordinator = null;
|
|
570
|
+
}
|
|
571
|
+
// -------------------------------------------------------------------------
|
|
572
|
+
// Internal helpers
|
|
573
|
+
// -------------------------------------------------------------------------
|
|
574
|
+
serializeInputChunk(chunk) {
|
|
575
|
+
return JSON.stringify(chunk);
|
|
576
|
+
}
|
|
577
|
+
toPersisted = (state) => ({
|
|
578
|
+
publicAccessToken: state.publicAccessToken,
|
|
579
|
+
lastEventId: state.lastEventId,
|
|
580
|
+
isStreaming: state.isStreaming,
|
|
581
|
+
});
|
|
582
|
+
notifySessionChange(chatId, session) {
|
|
583
|
+
if (!this._onSessionChange)
|
|
584
|
+
return;
|
|
585
|
+
this._onSessionChange(chatId, session ? this.toPersisted(session) : null);
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Resolves the session state for a chatId, starting the session if
|
|
589
|
+
* needed (and `getStartToken` is configured). Customers who provide
|
|
590
|
+
* `accessToken` but no `getStartToken` are expected to have created
|
|
591
|
+
* the session server-side; in that case the first `accessToken` call
|
|
592
|
+
* returns a fresh session PAT.
|
|
593
|
+
*/
|
|
594
|
+
async ensureSessionState(chatId) {
|
|
595
|
+
const existing = this.sessions.get(chatId);
|
|
596
|
+
if (existing?.publicAccessToken)
|
|
597
|
+
return existing;
|
|
598
|
+
if (this.resolveStartSession) {
|
|
599
|
+
// Lazily start: customer's server action creates the session and
|
|
600
|
+
// returns a PAT. Idempotent on `(env, externalId)` so concurrent
|
|
601
|
+
// tabs / repeat calls converge to the same session.
|
|
602
|
+
const inflight = this.pendingStarts.get(chatId);
|
|
603
|
+
if (inflight)
|
|
604
|
+
return inflight;
|
|
605
|
+
const promise = this.doStart(chatId).finally(() => {
|
|
606
|
+
this.pendingStarts.delete(chatId);
|
|
607
|
+
});
|
|
608
|
+
this.pendingStarts.set(chatId, promise);
|
|
609
|
+
return promise;
|
|
610
|
+
}
|
|
611
|
+
// No `startSession` configured. Customer fully manages session
|
|
612
|
+
// lifecycle externally — they're expected to have hydrated
|
|
613
|
+
// `sessions: { ... }` already, or the very first `accessToken` call
|
|
614
|
+
// returns a PAT for an out-of-band-created session.
|
|
615
|
+
const token = await this.resolveAccessToken({ chatId });
|
|
616
|
+
const state = { publicAccessToken: token };
|
|
617
|
+
this.sessions.set(chatId, state);
|
|
618
|
+
this.notifySessionChange(chatId, state);
|
|
619
|
+
return state;
|
|
620
|
+
}
|
|
621
|
+
async doStart(chatId) {
|
|
622
|
+
if (!this.resolveStartSession) {
|
|
623
|
+
throw new Error("TriggerChatTransport: `startSession` is required to call `start()` / `preload()`. Either provide it or pre-hydrate the session via `sessions: { ... }`.");
|
|
624
|
+
}
|
|
625
|
+
const { publicAccessToken } = await this.resolveStartSession({
|
|
626
|
+
taskId: this.taskId,
|
|
627
|
+
chatId,
|
|
628
|
+
clientData: (this.defaultMetadata ?? {}),
|
|
629
|
+
});
|
|
630
|
+
const state = {
|
|
631
|
+
publicAccessToken,
|
|
632
|
+
isStreaming: false,
|
|
633
|
+
};
|
|
634
|
+
this.sessions.set(chatId, state);
|
|
635
|
+
this.notifySessionChange(chatId, state);
|
|
636
|
+
return state;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Run `op` with the session's stored PAT. On 401/403, refresh the PAT
|
|
640
|
+
* via `accessToken` and retry once. Surfaces non-auth errors as-is.
|
|
641
|
+
*/
|
|
642
|
+
resolveBaseURL(ctx) {
|
|
643
|
+
const raw = this.resolveBaseURLFn(ctx);
|
|
644
|
+
return raw.replace(/\/$/, "");
|
|
645
|
+
}
|
|
646
|
+
async doFetch(ctx, url, init) {
|
|
647
|
+
return this.fetchOverride ? this.fetchOverride(url, init, ctx) : fetch(url, init);
|
|
648
|
+
}
|
|
649
|
+
async appendInputChunk(chatId, token, body) {
|
|
650
|
+
const ctx = { endpoint: "in", chatId };
|
|
651
|
+
const url = `${this.resolveBaseURL(ctx)}/realtime/v1/sessions/${encodeURIComponent(chatId)}/in/append`;
|
|
652
|
+
const headers = {
|
|
653
|
+
"Content-Type": "application/json",
|
|
654
|
+
Authorization: `Bearer ${token}`,
|
|
655
|
+
"x-trigger-source": "sdk",
|
|
656
|
+
...this.extraHeaders,
|
|
657
|
+
};
|
|
658
|
+
const response = await this.doFetch(ctx, url, { method: "POST", headers, body });
|
|
659
|
+
if (!response.ok) {
|
|
660
|
+
const text = await response.text().catch(() => "");
|
|
661
|
+
const err = new Error(`appendToSessionStream failed: ${response.status} ${text}`);
|
|
662
|
+
err.name = "TriggerApiError";
|
|
663
|
+
err.status = response.status;
|
|
664
|
+
throw err;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async callWithAuthRetry(chatId, state, op) {
|
|
668
|
+
try {
|
|
669
|
+
await op(state.publicAccessToken);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
if (!isAuthError(err))
|
|
674
|
+
throw err;
|
|
675
|
+
}
|
|
676
|
+
const fresh = await this.resolveAccessToken({ chatId });
|
|
677
|
+
state.publicAccessToken = fresh;
|
|
678
|
+
this.notifySessionChange(chatId, state);
|
|
679
|
+
await op(fresh);
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Open an SSE subscription to the session's `.out` stream and pipe
|
|
683
|
+
* UIMessageChunks through to the AI SDK. Trigger control records
|
|
684
|
+
* (`turn-complete`, `upgrade-required` — see `trigger-control` header
|
|
685
|
+
* on `client-protocol.mdx#records-on-session-out`) are routed by
|
|
686
|
+
* header and never reach the consumer. `upgrade-required` is purely
|
|
687
|
+
* telemetry now since the server handles the run swap inline (see
|
|
688
|
+
* `end-and-continue`).
|
|
689
|
+
*/
|
|
690
|
+
subscribeToSessionStream(state, abortSignal, chatId, options) {
|
|
691
|
+
const internalAbort = new AbortController();
|
|
692
|
+
this.activeStreams.set(chatId, internalAbort);
|
|
693
|
+
const combinedSignal = abortSignal
|
|
694
|
+
? AbortSignal.any([abortSignal, internalAbort.signal])
|
|
695
|
+
: internalAbort.signal;
|
|
696
|
+
if (abortSignal) {
|
|
697
|
+
abortSignal.addEventListener("abort", () => {
|
|
698
|
+
if (options?.sendStopOnAbort !== false) {
|
|
699
|
+
state.skipToTurnComplete = true;
|
|
700
|
+
this.appendInputChunk(chatId, state.publicAccessToken, this.serializeInputChunk({ kind: "stop" })).catch(() => { });
|
|
701
|
+
}
|
|
702
|
+
internalAbort.abort();
|
|
703
|
+
}, { once: true });
|
|
704
|
+
}
|
|
705
|
+
const streamUrl = `${this.resolveBaseURL({ endpoint: "out", chatId })}/realtime/v1/sessions/${encodeURIComponent(chatId)}/out`;
|
|
706
|
+
return new ReadableStream({
|
|
707
|
+
start: async (controller) => {
|
|
708
|
+
// Track the live subscription so browser wake events can act
|
|
709
|
+
// on it. Three classes of wake:
|
|
710
|
+
// - `online`: network came back. Existing connection might
|
|
711
|
+
// be silently dead; force a fresh one.
|
|
712
|
+
// - `visibilitychange` → visible after long hidden: tab
|
|
713
|
+
// was backgrounded long enough that the OS likely killed
|
|
714
|
+
// the TCP socket. Force reconnect.
|
|
715
|
+
// - `visibilitychange` → visible after short hidden: cheap
|
|
716
|
+
// wake of any in-flight backoff.
|
|
717
|
+
// - `pageshow` with `event.persisted`: bfcache restore
|
|
718
|
+
// (mobile Safari back/forward, app-switcher resume). The
|
|
719
|
+
// socket is definitely dead. Force reconnect.
|
|
720
|
+
let currentSubscription = null;
|
|
721
|
+
let hiddenSince = null;
|
|
722
|
+
const FORCE_RECONNECT_AFTER_HIDDEN_MS = 30_000;
|
|
723
|
+
const onVisibilityChange = () => {
|
|
724
|
+
if (typeof document === "undefined")
|
|
725
|
+
return;
|
|
726
|
+
if (document.visibilityState === "hidden") {
|
|
727
|
+
hiddenSince = Date.now();
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const wasHiddenForMs = hiddenSince ? Date.now() - hiddenSince : 0;
|
|
731
|
+
hiddenSince = null;
|
|
732
|
+
if (wasHiddenForMs >= FORCE_RECONNECT_AFTER_HIDDEN_MS) {
|
|
733
|
+
currentSubscription?.forceReconnect();
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
currentSubscription?.retryNow();
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
const onPageShow = (event) => {
|
|
740
|
+
// PageTransitionEvent in browsers; type guard via `persisted`.
|
|
741
|
+
if (event.persisted) {
|
|
742
|
+
currentSubscription?.forceReconnect();
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
const onOnline = () => currentSubscription?.forceReconnect();
|
|
746
|
+
const teardownWakeListeners = typeof document !== "undefined" && typeof window !== "undefined"
|
|
747
|
+
? (() => {
|
|
748
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
749
|
+
window.addEventListener("online", onOnline);
|
|
750
|
+
window.addEventListener("pageshow", onPageShow);
|
|
751
|
+
return () => {
|
|
752
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
753
|
+
window.removeEventListener("online", onOnline);
|
|
754
|
+
window.removeEventListener("pageshow", onPageShow);
|
|
755
|
+
};
|
|
756
|
+
})()
|
|
757
|
+
: () => { };
|
|
758
|
+
const sseCtx = { endpoint: "out", chatId };
|
|
759
|
+
const fetchOverride = this.fetchOverride;
|
|
760
|
+
const sseFetchClient = fetchOverride
|
|
761
|
+
? ((input, init) => {
|
|
762
|
+
if (typeof input === "string") {
|
|
763
|
+
return fetchOverride(input, init ?? {}, sseCtx);
|
|
764
|
+
}
|
|
765
|
+
if (input instanceof URL) {
|
|
766
|
+
return fetchOverride(input.toString(), init ?? {}, sseCtx);
|
|
767
|
+
}
|
|
768
|
+
// Request — preserve its url + intrinsic init, let any
|
|
769
|
+
// provided init override on top (matches fetch(Request, init)
|
|
770
|
+
// semantics).
|
|
771
|
+
return fetchOverride(input.url, {
|
|
772
|
+
method: input.method,
|
|
773
|
+
headers: input.headers,
|
|
774
|
+
signal: input.signal,
|
|
775
|
+
...(init ?? {}),
|
|
776
|
+
}, sseCtx);
|
|
777
|
+
})
|
|
778
|
+
: undefined;
|
|
779
|
+
const connectSseOnce = async (token) => {
|
|
780
|
+
const subscription = new v3_1.SSEStreamSubscription(streamUrl, {
|
|
781
|
+
headers: {
|
|
782
|
+
Authorization: `Bearer ${token}`,
|
|
783
|
+
...this.extraHeaders,
|
|
784
|
+
...(options?.peekSettled ? { "X-Peek-Settled": "1" } : {}),
|
|
785
|
+
},
|
|
786
|
+
signal: combinedSignal,
|
|
787
|
+
timeoutInSeconds: this.streamTimeoutSeconds,
|
|
788
|
+
lastEventId: state.lastEventId,
|
|
789
|
+
// Catch silent-dead-socket: if no chunk (or server
|
|
790
|
+
// keepalive) arrives in 60s, force reconnect. Sized
|
|
791
|
+
// generously over typical agent thinking pauses.
|
|
792
|
+
stallTimeoutMs: 60_000,
|
|
793
|
+
fetchClient: sseFetchClient,
|
|
794
|
+
});
|
|
795
|
+
currentSubscription = subscription;
|
|
796
|
+
const sseStream = await subscription.subscribe();
|
|
797
|
+
const reader = sseStream.getReader();
|
|
798
|
+
try {
|
|
799
|
+
const first = await reader.read();
|
|
800
|
+
if (first.done) {
|
|
801
|
+
reader.releaseLock();
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
return { reader, primed: first.value };
|
|
805
|
+
}
|
|
806
|
+
catch (readErr) {
|
|
807
|
+
reader.releaseLock();
|
|
808
|
+
throw readErr;
|
|
809
|
+
}
|
|
810
|
+
};
|
|
811
|
+
try {
|
|
812
|
+
let reader;
|
|
813
|
+
let primed;
|
|
814
|
+
try {
|
|
815
|
+
const opened = await connectSseOnce(state.publicAccessToken);
|
|
816
|
+
if (opened === null) {
|
|
817
|
+
controller.close();
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
reader = opened.reader;
|
|
821
|
+
primed = opened.primed;
|
|
822
|
+
}
|
|
823
|
+
catch (e) {
|
|
824
|
+
if (isAuthError(e)) {
|
|
825
|
+
const fresh = await this.resolveAccessToken({ chatId });
|
|
826
|
+
state.publicAccessToken = fresh;
|
|
827
|
+
this.notifySessionChange(chatId, state);
|
|
828
|
+
const opened = await connectSseOnce(fresh);
|
|
829
|
+
if (opened === null) {
|
|
830
|
+
controller.close();
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
reader = opened.reader;
|
|
834
|
+
primed = opened.primed;
|
|
835
|
+
}
|
|
836
|
+
else {
|
|
837
|
+
throw e;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
while (true) {
|
|
841
|
+
let value;
|
|
842
|
+
if (primed !== undefined) {
|
|
843
|
+
value = primed;
|
|
844
|
+
primed = undefined;
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
const next = await reader.read();
|
|
848
|
+
if (next.done) {
|
|
849
|
+
controller.close();
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
value = next.value;
|
|
853
|
+
}
|
|
854
|
+
if (combinedSignal.aborted) {
|
|
855
|
+
internalAbort.abort();
|
|
856
|
+
await reader.cancel();
|
|
857
|
+
controller.close();
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (value.id)
|
|
861
|
+
state.lastEventId = value.id;
|
|
862
|
+
// Trigger control record (turn-complete, upgrade-required) —
|
|
863
|
+
// routed by header, body is empty. Detect via the
|
|
864
|
+
// `trigger-control` header on the SSE record. Data records
|
|
865
|
+
// (UIMessageChunks) fall through to the chunk path below.
|
|
866
|
+
//
|
|
867
|
+
// Cross-version bridge: a customer who redeploys their
|
|
868
|
+
// Next.js app (new browser SDK) before their next
|
|
869
|
+
// `trigger deploy` (old agent SDK still writing turn-complete
|
|
870
|
+
// / upgrade-required as `chunk.type` data records) would
|
|
871
|
+
// otherwise hang. Fall back to the legacy chunk-type form
|
|
872
|
+
// when no header is present so the deploy-skew window
|
|
873
|
+
// closes turns correctly.
|
|
874
|
+
let controlValue = (0, v3_1.controlSubtype)(value.headers);
|
|
875
|
+
let legacyChunk;
|
|
876
|
+
if (!controlValue && value.chunk && typeof value.chunk === "object") {
|
|
877
|
+
const chunk = value.chunk;
|
|
878
|
+
if (chunk.type === "trigger:turn-complete") {
|
|
879
|
+
controlValue = v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE;
|
|
880
|
+
legacyChunk = chunk;
|
|
881
|
+
}
|
|
882
|
+
else if (chunk.type === "trigger:upgrade-required") {
|
|
883
|
+
controlValue = v3_1.TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED;
|
|
884
|
+
}
|
|
885
|
+
else if (typeof chunk.type === "string" && chunk.type.startsWith("trigger:")) {
|
|
886
|
+
// Future / unknown `trigger:*` legacy control type from
|
|
887
|
+
// a pre-upgrade agent — drop so it doesn't reach the AI
|
|
888
|
+
// SDK as an unrecognised UIMessageChunk.
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (state.skipToTurnComplete) {
|
|
893
|
+
if (controlValue === v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
|
|
894
|
+
state.skipToTurnComplete = false;
|
|
895
|
+
}
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (controlValue === v3_1.TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED) {
|
|
899
|
+
// Server has already triggered the new run via
|
|
900
|
+
// `end-and-continue`; the next chunks on this same `.out`
|
|
901
|
+
// stream come from v2. Filter the marker for cleanliness
|
|
902
|
+
// and keep reading.
|
|
903
|
+
continue;
|
|
904
|
+
}
|
|
905
|
+
if (controlValue === v3_1.TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE) {
|
|
906
|
+
const refreshedToken = (0, v3_1.headerValue)(value.headers, v3_1.PUBLIC_ACCESS_TOKEN_HEADER) ??
|
|
907
|
+
legacyChunk?.publicAccessToken;
|
|
908
|
+
if (refreshedToken) {
|
|
909
|
+
state.publicAccessToken = refreshedToken;
|
|
910
|
+
}
|
|
911
|
+
state.isStreaming = false;
|
|
912
|
+
this.notifySessionChange(chatId, state);
|
|
913
|
+
this.coordinator?.release(chatId);
|
|
914
|
+
this.coordinator?.broadcastSession(chatId, {
|
|
915
|
+
lastEventId: state.lastEventId,
|
|
916
|
+
});
|
|
917
|
+
if (this.watchMode)
|
|
918
|
+
continue;
|
|
919
|
+
internalAbort.abort();
|
|
920
|
+
try {
|
|
921
|
+
controller.close();
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
/* already closed */
|
|
925
|
+
}
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
// Data record — `value.chunk` is the parsed UIMessageChunk
|
|
929
|
+
// unwrapped from the S2 record envelope (the parser does the
|
|
930
|
+
// JSON unwrap). Drop empty/malformed payloads defensively.
|
|
931
|
+
if (value.chunk == null)
|
|
932
|
+
continue;
|
|
933
|
+
controller.enqueue(value.chunk);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
938
|
+
try {
|
|
939
|
+
controller.close();
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
/* already closed */
|
|
943
|
+
}
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
controller.error(error);
|
|
947
|
+
}
|
|
948
|
+
finally {
|
|
949
|
+
teardownWakeListeners();
|
|
950
|
+
this.activeStreams.delete(chatId);
|
|
951
|
+
this.coordinator?.release(chatId);
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
exports.TriggerChatTransport = TriggerChatTransport;
|
|
958
|
+
/**
|
|
959
|
+
* Convenience constructor matching {@link TriggerChatTransport}.
|
|
960
|
+
*/
|
|
961
|
+
function createChatTransport(options) {
|
|
962
|
+
return new TriggerChatTransport(options);
|
|
963
|
+
}
|
|
964
|
+
// Server-side agent chat re-exports.
|
|
965
|
+
var chat_client_js_1 = require("./chat-client.js");
|
|
966
|
+
Object.defineProperty(exports, "AgentChat", { enumerable: true, get: function () { return chat_client_js_1.AgentChat; } });
|
|
967
|
+
Object.defineProperty(exports, "ChatStream", { enumerable: true, get: function () { return chat_client_js_1.ChatStream; } });
|
|
968
|
+
//# sourceMappingURL=chat.js.map
|