@frontera-sdk/chat 1.50.40

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.
@@ -0,0 +1,352 @@
1
+ /**
2
+ * The browser half of connecting an end user's own account.
3
+ *
4
+ * The server side of this flow is three calls (`startAppConnection`,
5
+ * `completeAppConnection`, `resumeToolCall`) with a consent screen in the
6
+ * middle. What is fiddly is not the calls — it is the middle: opening a popup,
7
+ * knowing when it came back, telling "the user finished" apart from "the user
8
+ * closed the window", and not leaving a listener attached forever when neither
9
+ * happens.
10
+ *
11
+ * That is what lives here. It is deliberately not part of `ChatClient`: the
12
+ * client is transport, and this touches `window`.
13
+ */
14
+
15
+ import type { ChatClient } from './chat-client'
16
+ import type { ResumeToolCallResult } from './types'
17
+
18
+ /** What a callback page sends back to the window that opened it. */
19
+ export const CONNECT_MESSAGE_TYPE = 'frontera:oauth-callback'
20
+
21
+ export interface ConnectCallbackMessage {
22
+ type: typeof CONNECT_MESSAGE_TYPE
23
+ code?: string
24
+ state?: string
25
+ /** The provider's own refusal — the user pressed Cancel, typically. */
26
+ error?: string
27
+ }
28
+
29
+ /** Where a redirect-mode connect parked what it needs to finish. */
30
+ const PENDING_KEY = 'frontera:connect-pending'
31
+ /** Query parameters the callback adds when returning a whole tab. */
32
+ const RETURN_CODE = 'frontera_code'
33
+ const RETURN_STATE = 'frontera_state'
34
+ const RETURN_ERROR = 'frontera_error'
35
+
36
+ /**
37
+ * Call this FROM YOUR CALLBACK PAGE — the one you registered as the OAuth
38
+ * redirect URI. It is the page's entire implementation:
39
+ *
40
+ * ```ts
41
+ * // app/frontera/oauth/callback/page.tsx
42
+ * import { postConnectResultToOpener } from '@frontera-sdk/chat/connect-popup'
43
+ * postConnectResultToOpener(window.location.search)
44
+ * ```
45
+ *
46
+ * It handles both ways a connect can arrive back. In a popup it posts to the
47
+ * opener and closes. In a redirected tab — which is what mobile gets, because
48
+ * popups are blocked or mangled in iOS Safari and in-app browsers — there is no
49
+ * opener, so it navigates back to where the chat was with the result attached.
50
+ *
51
+ * `targetOrigin` defaults to this page's own origin, correct when the chat and
52
+ * the callback are the same app. Never `'*'`: the message carries an
53
+ * authorization code.
54
+ */
55
+ export function postConnectResultToOpener(search: string, targetOrigin?: string): void {
56
+ const params = new URLSearchParams(search)
57
+ const code = params.get('code') ?? undefined
58
+ const state = params.get('state') ?? undefined
59
+ const error = params.get('error') ?? undefined
60
+
61
+ if (window.opener) {
62
+ const message: ConnectCallbackMessage = { type: CONNECT_MESSAGE_TYPE, code, state, error }
63
+ window.opener.postMessage(message, targetOrigin ?? window.location.origin)
64
+ window.close()
65
+ return
66
+ }
67
+
68
+ // Redirect mode: the chat is not open behind us — it was this tab. The
69
+ // return address was stored before we left, because the redirect URI is
70
+ // fixed at the provider and cannot carry it.
71
+ const pending = readPending()
72
+ const target = new URL(pending?.returnTo ?? window.location.origin)
73
+ if (code) target.searchParams.set(RETURN_CODE, code)
74
+ if (state) target.searchParams.set(RETURN_STATE, state)
75
+ if (error) target.searchParams.set(RETURN_ERROR, error)
76
+ window.location.replace(target.toString())
77
+ }
78
+
79
+ interface PendingConnect {
80
+ /** Null for a voluntary connect — see `RunAppConnectionOptions`. */
81
+ pendingToolCallId: string | null
82
+ returnTo: string
83
+ }
84
+
85
+ function readPending(): PendingConnect | null {
86
+ try {
87
+ const raw = window.sessionStorage.getItem(PENDING_KEY)
88
+ return raw ? (JSON.parse(raw) as PendingConnect) : null
89
+ } catch {
90
+ // Private mode, a blocked storage partition, a corrupt value — none of
91
+ // which should throw out of a callback page whose only job is to forward.
92
+ return null
93
+ }
94
+ }
95
+
96
+ function writePending(value: PendingConnect | null): void {
97
+ try {
98
+ if (value) window.sessionStorage.setItem(PENDING_KEY, JSON.stringify(value))
99
+ else window.sessionStorage.removeItem(PENDING_KEY)
100
+ } catch {
101
+ /* see readPending */
102
+ }
103
+ }
104
+
105
+ export interface RunAppConnectionOptions {
106
+ /**
107
+ * The parked call's id — what gets resumed once the account exists.
108
+ *
109
+ * Omit for a VOLUNTARY connect: someone connecting an account from the
110
+ * composer before asking anything has no parked call, and there is nothing
111
+ * to resume. The account still lands the same way; only the last step
112
+ * differs.
113
+ */
114
+ pendingToolCallId?: string
115
+ /** Which app. Take `installId` from the parked call. */
116
+ installId?: string
117
+ catalogKind?: string
118
+ /** Your callback page, registered with your OAuth client AND allowlisted. */
119
+ redirectUri: string
120
+ /**
121
+ * Origin the callback message must come from. Defaults to `redirectUri`'s
122
+ * origin, which is where your callback page runs.
123
+ */
124
+ expectedOrigin?: string
125
+ /** How long to wait for the user before giving up. Default 5 minutes. */
126
+ timeoutMs?: number
127
+ /** Popup geometry, if the defaults do not suit your layout. */
128
+ features?: string
129
+ /**
130
+ * How to send the person to the provider.
131
+ *
132
+ * `auto` (default) tries a popup and falls back to redirecting this tab when
133
+ * the browser refuses one — which is the normal case on mobile: iOS Safari
134
+ * and in-app browsers (Instagram, LinkedIn, Slack) block or mangle
135
+ * `window.open`. A popup-only integration has a dead Connect button for
136
+ * every one of those users.
137
+ *
138
+ * `redirect` always navigates. `popup` never does, and reports the block.
139
+ */
140
+ mode?: 'auto' | 'popup' | 'redirect'
141
+ /**
142
+ * Where to come back to after a redirect. Defaults to the current URL, so
143
+ * the person lands where they left. Its ORIGIN must be on the workspace's
144
+ * embed allowlist.
145
+ */
146
+ returnTo?: string
147
+ }
148
+
149
+ export type RunAppConnectionResult =
150
+ /**
151
+ * The popup path completed here.
152
+ *
153
+ * `resume` is absent when nothing was parked — the plugins button connects
154
+ * an account with no tool call waiting on it, and the connect was the whole
155
+ * errand. Present only when a parked call was released by this connection.
156
+ */
157
+ | { status: 'connected'; resume?: ResumeToolCallResult }
158
+ | { status: 'cancelled'; reason: 'closed' | 'denied' | 'timeout'; error?: string }
159
+ /**
160
+ * The tab is navigating to the provider. Nothing after this call runs — the
161
+ * flow continues in `completeRedirectedConnection` when the person comes
162
+ * back.
163
+ */
164
+ | { status: 'redirecting' }
165
+
166
+ const DEFAULT_TIMEOUT_MS = 5 * 60_000
167
+ const DEFAULT_FEATURES = 'width=520,height=680,menubar=no,toolbar=no'
168
+
169
+ /**
170
+ * Run the whole thing: open the consent popup, wait for your callback to post
171
+ * the code back, exchange it, and re-run the parked tool call.
172
+ *
173
+ * Resolves rather than throwing when the person simply declines — a closed
174
+ * popup is an ordinary outcome and reads badly as an exception. Genuine
175
+ * failures (a refused start, a rejected exchange) still throw `FronteraError`
176
+ * with the server's code.
177
+ *
178
+ * The resume is included on purpose. Connecting without it leaves the agent
179
+ * exactly as blocked as before: it is waiting on a tool RESULT, not on an
180
+ * account.
181
+ */
182
+ export async function runAppConnection(
183
+ chat: ChatClient,
184
+ options: RunAppConnectionOptions,
185
+ ): Promise<RunAppConnectionResult> {
186
+ const mode = options.mode ?? 'auto'
187
+ const returnTo = options.returnTo ?? window.location.href
188
+
189
+ const { authUrl } = await chat.startAppConnection({
190
+ installId: options.installId,
191
+ catalogKind: options.catalogKind,
192
+ redirectUri: options.redirectUri,
193
+ returnTo,
194
+ })
195
+
196
+ const expectedOrigin = options.expectedOrigin ?? new URL(options.redirectUri).origin
197
+
198
+ const goByRedirect = (): RunAppConnectionResult => {
199
+ // Parked before navigating, because this tab is about to be replaced: the
200
+ // callback page reads it to find the way back, and the chat reads it on
201
+ // return to know which call to resume. The provider's redirect URI is
202
+ // fixed and cannot carry either.
203
+ writePending({ pendingToolCallId: options.pendingToolCallId ?? null, returnTo })
204
+ window.location.assign(authUrl)
205
+ return { status: 'redirecting' }
206
+ }
207
+
208
+ if (mode === 'redirect') return goByRedirect()
209
+
210
+ // Popup blockers judge by user gesture, and the awaited start call above may
211
+ // already have cost us that association. That is not a failure to report on
212
+ // mobile — it is the norm — so `auto` simply takes the other road.
213
+ const popup = window.open(authUrl, 'frontera-connect', options.features ?? DEFAULT_FEATURES)
214
+ if (!popup) {
215
+ if (mode === 'auto') return goByRedirect()
216
+ throw new Error(
217
+ 'The connection window was blocked. Open it from a direct click, or allow popups for this site.',
218
+ )
219
+ }
220
+
221
+ const outcome = await waitForCallback(popup, expectedOrigin, options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
222
+ if (outcome.status !== 'received') return { status: 'cancelled', reason: outcome.reason, error: outcome.error }
223
+
224
+ await chat.completeAppConnection({ code: outcome.code, state: outcome.state })
225
+ // Nothing parked, nothing to resume — the connect WAS the whole errand.
226
+ if (!options.pendingToolCallId) return { status: 'connected' }
227
+ const resume = await chat.resumeToolCall(options.pendingToolCallId)
228
+ return { status: 'connected', resume }
229
+ }
230
+
231
+ /**
232
+ * Finish a connect that came back as a page load rather than a popup message.
233
+ *
234
+ * Call it once when your chat mounts. It does nothing — cheaply, synchronously
235
+ * — unless this page load is a return from a provider, so it is safe to call
236
+ * unconditionally:
237
+ *
238
+ * ```ts
239
+ * useEffect(() => { void completeRedirectedConnection(chat) }, [chat])
240
+ * ```
241
+ *
242
+ * Without it the redirect path stops one step short: the account gets
243
+ * connected and the parked tool call is never re-run, which looks exactly like
244
+ * the freeze the whole flow exists to prevent. It also tidies the query string,
245
+ * so a refresh does not try to redeem a code that is already spent.
246
+ */
247
+ export async function completeRedirectedConnection(
248
+ chat: ChatClient,
249
+ options: { search?: string } = {},
250
+ ): Promise<RunAppConnectionResult | null> {
251
+ if (typeof window === 'undefined') return null
252
+
253
+ const params = new URLSearchParams(options.search ?? window.location.search)
254
+ const code = params.get(RETURN_CODE)
255
+ const state = params.get(RETURN_STATE)
256
+ const error = params.get(RETURN_ERROR)
257
+ if (!code && !state && !error) return null
258
+
259
+ const pending = readPending()
260
+ writePending(null)
261
+ clearReturnParams(params)
262
+
263
+ if (error || !code || !state) {
264
+ return { status: 'cancelled', reason: 'denied', error: error ?? 'callback carried no code' }
265
+ }
266
+ if (!pending) {
267
+ // The code is real but we no longer know which call it was for — a tab
268
+ // restored from history, or storage cleared mid-flow. The connection can
269
+ // still be completed; only the resume is lost, and the next attempt at the
270
+ // tool will find an account waiting.
271
+ await chat.completeAppConnection({ code, state })
272
+ return { status: 'connected', resume: { ok: true } }
273
+ }
274
+
275
+ await chat.completeAppConnection({ code, state })
276
+ // A voluntary connect parks nothing, so returning from one has nothing to
277
+ // resume — the account is the outcome.
278
+ if (!pending.pendingToolCallId) return { status: 'connected' }
279
+ const resume = await chat.resumeToolCall(pending.pendingToolCallId)
280
+ return { status: 'connected', resume }
281
+ }
282
+
283
+ /** Strip our parameters from the address bar without reloading or losing theirs. */
284
+ function clearReturnParams(params: URLSearchParams): void {
285
+ params.delete(RETURN_CODE)
286
+ params.delete(RETURN_STATE)
287
+ params.delete(RETURN_ERROR)
288
+ const query = params.toString()
289
+ window.history.replaceState(
290
+ window.history.state,
291
+ '',
292
+ `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`,
293
+ )
294
+ }
295
+
296
+ type CallbackOutcome =
297
+ | { status: 'received'; code: string; state: string }
298
+ | { status: 'cancelled'; reason: 'closed' | 'denied' | 'timeout'; error?: string }
299
+
300
+ /**
301
+ * Wait for whichever comes first: the callback message, the popup closing, or
302
+ * the timeout.
303
+ *
304
+ * The poll for `popup.closed` is what makes "the user gave up" a real outcome
305
+ * instead of a promise that never settles — there is no close event across
306
+ * windows. Every path runs the same teardown, because a listener left on
307
+ * `window` after a cancelled connect will happily act on the NEXT one.
308
+ */
309
+ function waitForCallback(
310
+ popup: Window,
311
+ expectedOrigin: string,
312
+ timeoutMs: number,
313
+ ): Promise<CallbackOutcome> {
314
+ return new Promise<CallbackOutcome>((resolve) => {
315
+ let settled = false
316
+
317
+ const finish = (outcome: CallbackOutcome) => {
318
+ if (settled) return
319
+ settled = true
320
+ window.removeEventListener('message', onMessage)
321
+ clearInterval(closeTimer)
322
+ clearTimeout(timeoutTimer)
323
+ resolve(outcome)
324
+ }
325
+
326
+ const onMessage = (event: MessageEvent) => {
327
+ // The origin check is the security boundary: this message carries an
328
+ // authorization code, and any page may post to an opener.
329
+ if (event.origin !== expectedOrigin) return
330
+ const data = event.data as ConnectCallbackMessage | undefined
331
+ if (!data || data.type !== CONNECT_MESSAGE_TYPE) return
332
+
333
+ if (data.error) return finish({ status: 'cancelled', reason: 'denied', error: data.error })
334
+ if (!data.code || !data.state) {
335
+ return finish({ status: 'cancelled', reason: 'denied', error: 'callback carried no code' })
336
+ }
337
+ finish({ status: 'received', code: data.code, state: data.state })
338
+ }
339
+
340
+ window.addEventListener('message', onMessage)
341
+
342
+ const closeTimer = setInterval(() => {
343
+ // A small grace period is unnecessary: the callback page posts BEFORE it
344
+ // closes, and `message` is delivered in order.
345
+ if (popup.closed) finish({ status: 'cancelled', reason: 'closed' })
346
+ }, 400)
347
+
348
+ const timeoutTimer = setTimeout(() => {
349
+ finish({ status: 'cancelled', reason: 'timeout' })
350
+ }, timeoutMs)
351
+ })
352
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * A development-time watchdog for the ways a chat goes quiet.
3
+ *
4
+ * The failure this exists for looks like nothing at all: the agent stops
5
+ * mid-turn because it is waiting for a person, the integration does not render
6
+ * the card that would let them answer, and the run sits there until it lapses.
7
+ * No error, no rejected promise, no failed request — which is precisely why it
8
+ * reaches production. The first anyone hears of it is a user saying "it froze".
9
+ *
10
+ * So the SDK watches its own stream in development. When something parks and
11
+ * nothing settles it within a grace period, this says so, in the integrator's
12
+ * console, naming the call they are missing. Silence becomes a message at the
13
+ * moment it happens rather than a support ticket a week later.
14
+ *
15
+ * Off in production, always: it tees the response body to read it, and that is
16
+ * a cost worth paying for a warning nobody in production would see.
17
+ */
18
+
19
+ export type DoctorConcern = 'approval' | 'connect' | 'unauthorized'
20
+
21
+ export interface ChatDoctorOptions {
22
+ /** Force on or off. Defaults to on outside production. */
23
+ enabled?: boolean
24
+ /**
25
+ * How long a parked run may go unanswered before it is worth mentioning.
26
+ *
27
+ * Long enough that a UI fetching its pending list on the next tick is never
28
+ * scolded; short enough to land while the developer is still looking at the
29
+ * screen that caused it.
30
+ */
31
+ graceMs?: number
32
+ /** Where warnings go. Defaults to `console.warn`. */
33
+ onWarn?: (concern: DoctorConcern, message: string) => void
34
+ }
35
+
36
+ const DEFAULT_GRACE_MS = 10_000
37
+
38
+ function defaultEnabled(): boolean {
39
+ // `process` is absent in a browser bundle that did not shim it; absent means
40
+ // we cannot prove production, and a dev warning is the safer default.
41
+ const mode =
42
+ typeof process !== 'undefined' ? (process.env?.NODE_ENV ?? undefined) : undefined
43
+ return mode !== 'production'
44
+ }
45
+
46
+ const ADVICE: Record<DoctorConcern, string> = {
47
+ approval: [
48
+ 'A tool call is parked waiting for a person to approve it, and nothing has answered.',
49
+ 'The run is alive and BLOCKED — it will not continue until it is settled or expires.',
50
+ '',
51
+ ' const pending = await chat.listPendingApprovals(conversationId)',
52
+ " await chat.respondToApproval(pending[0].id, 'approve')",
53
+ '',
54
+ 'Using @frontera-sdk/chat-ui, render <PendingCards conversationId={…} />.',
55
+ 'Read the pending list on mount too: a reload loses the stream chunk, not the block.',
56
+ ].join('\n'),
57
+ connect: [
58
+ "A tool needs this user's own account and returned `needs_connect`; nothing has started a connection.",
59
+ 'The agent has been told NOT to ask in prose, so if your UI stays silent, nothing is said.',
60
+ '',
61
+ ' await runAppConnection(chat, { pendingToolCallId, installId, redirectUri })',
62
+ '',
63
+ 'Using @frontera-sdk/chat-ui, pass `connectRedirectUri` to <FronteraChat>.',
64
+ ].join('\n'),
65
+ unauthorized: [
66
+ 'A request was refused with UNAUTHORIZED and no refresh is wired, so the next send will fail too.',
67
+ '',
68
+ ' const session = createTokenSession({ apiBaseUrl, getToken })',
69
+ ' createChatTransport(session.getClient, { onUnauthorized: session.refresh })',
70
+ ].join('\n'),
71
+ }
72
+
73
+ class ChatDoctor {
74
+ private options: ChatDoctorOptions = {}
75
+ private timers = new Map<DoctorConcern, ReturnType<typeof setTimeout>>()
76
+
77
+ configure(options: ChatDoctorOptions): void {
78
+ this.options = { ...this.options, ...options }
79
+ }
80
+
81
+ get enabled(): boolean {
82
+ return this.options.enabled ?? defaultEnabled()
83
+ }
84
+
85
+ private warn(concern: DoctorConcern): void {
86
+ const message = `[frontera-chat] ${ADVICE[concern]}`
87
+ if (this.options.onWarn) this.options.onWarn(concern, message)
88
+ else console.warn(message)
89
+ }
90
+
91
+ /**
92
+ * Something parked. Start counting; `resolved` before the grace period ends
93
+ * cancels it.
94
+ */
95
+ parked(concern: DoctorConcern): void {
96
+ if (!this.enabled || this.timers.has(concern)) return
97
+ const timer = setTimeout(() => {
98
+ this.timers.delete(concern)
99
+ this.warn(concern)
100
+ }, this.options.graceMs ?? DEFAULT_GRACE_MS)
101
+ ;(timer as unknown as { unref?: () => void }).unref?.()
102
+ this.timers.set(concern, timer)
103
+ }
104
+
105
+ /** The integration did the thing. Nothing to warn about. */
106
+ resolved(concern: DoctorConcern): void {
107
+ const timer = this.timers.get(concern)
108
+ if (!timer) return
109
+ clearTimeout(timer)
110
+ this.timers.delete(concern)
111
+ }
112
+
113
+ /** Immediate, not timed: nothing about a missing refresh improves by waiting. */
114
+ noticed(concern: DoctorConcern): void {
115
+ if (this.enabled) this.warn(concern)
116
+ }
117
+
118
+ /** For tests, and for a host that unmounts a chat mid-run. */
119
+ reset(): void {
120
+ for (const timer of this.timers.values()) clearTimeout(timer)
121
+ this.timers.clear()
122
+ }
123
+
124
+ /**
125
+ * Watch a chat stream for the two things that park a run.
126
+ *
127
+ * The body is teed rather than parsed: this reads a copy as text and matches
128
+ * two markers, leaving the real stream untouched for the AI SDK. A full parse
129
+ * here would be a second implementation of the protocol, kept in step with
130
+ * the first by hope.
131
+ */
132
+ observe(response: Response): Response {
133
+ if (!this.enabled || !response.body) return response
134
+
135
+ const [forCaller, forDoctor] = response.body.tee()
136
+ void this.scan(forDoctor)
137
+
138
+ // Headers and status are carried over: callers read the conversation and
139
+ // run ids off them, and a Response rebuilt without them breaks everything
140
+ // downstream of a warning that was supposed to be passive.
141
+ return new Response(forCaller, {
142
+ status: response.status,
143
+ statusText: response.statusText,
144
+ headers: response.headers,
145
+ })
146
+ }
147
+
148
+ private async scan(stream: ReadableStream<Uint8Array>): Promise<void> {
149
+ const reader = stream.getReader()
150
+ const decoder = new TextDecoder()
151
+ try {
152
+ for (;;) {
153
+ const { done, value } = await reader.read()
154
+ if (done) break
155
+ const text = decoder.decode(value, { stream: true })
156
+ if (text.includes('tool-approval-request')) this.parked('approval')
157
+ if (text.includes('needs_connect')) this.parked('connect')
158
+ }
159
+ } catch {
160
+ // A cancelled or broken stream is not a diagnostic finding. The caller's
161
+ // copy reports the failure through its own path.
162
+ } finally {
163
+ reader.releaseLock()
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * The instance the SDK reports into. Exported so a host can quieten it, widen
170
+ * the grace period, or route warnings into their own logger:
171
+ *
172
+ * ```ts
173
+ * chatDoctor.configure({ enabled: false })
174
+ * ```
175
+ */
176
+ export const chatDoctor = new ChatDoctor()