@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.
package/src/hooks.ts ADDED
@@ -0,0 +1,202 @@
1
+ import { createContext, useContext } from 'react'
2
+ import {
3
+ useMutation,
4
+ useQuery,
5
+ useQueryClient,
6
+ type UseMutationOptions,
7
+ type UseMutationResult,
8
+ type UseQueryOptions,
9
+ type UseQueryResult,
10
+ } from '@tanstack/react-query'
11
+
12
+ import type { ChatClient } from './chat-client'
13
+ import type {
14
+ ApprovalDecision,
15
+ CreateSessionRequest,
16
+ ListSessionsQuery,
17
+ PendingApproval,
18
+ Paginated,
19
+ Session,
20
+ SessionMessage,
21
+ UpdateSessionRequest,
22
+ } from './types'
23
+
24
+ /**
25
+ * Query-key factory, same convention as `blueprintKeys`: requests serialise
26
+ * into the key so structurally equal requests share a cache entry.
27
+ */
28
+ export const chatKeys = {
29
+ all: ['chat'] as const,
30
+ sessions: (query?: ListSessionsQuery) =>
31
+ ['chat', 'sessions', JSON.stringify(query ?? {})] as const,
32
+ session: (id: string) => ['chat', 'session', id] as const,
33
+ messages: (id: string) => ['chat', 'messages', id] as const,
34
+ followUps: (conversationId: string, messageId: string) =>
35
+ ['chat', 'follow-ups', conversationId, messageId] as const,
36
+ approvals: (conversationId: string) => ['chat', 'approvals', conversationId] as const,
37
+ }
38
+
39
+ export const FronteraChatContext = createContext<ChatClient | null>(null)
40
+
41
+ export function useChatClient(): ChatClient {
42
+ const client = useContext(FronteraChatContext)
43
+ if (!client) {
44
+ throw new Error(
45
+ 'No ChatClient in context. Wrap the tree in FronteraChatContext.Provider — in a Frontera app, add chatProvider to createFronteraApp providers.',
46
+ )
47
+ }
48
+ return client
49
+ }
50
+
51
+ type ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn' | 'select'>
52
+ type WriteOptions<TData, TVariables> = Omit<
53
+ UseMutationOptions<TData, Error, TVariables>,
54
+ 'mutationFn'
55
+ >
56
+
57
+ /**
58
+ * These hooks cover PERSISTED state (sessions, history). Live streaming state
59
+ * belongs to `useChat` from `@ai-sdk/react` with `createChatTransport` — there
60
+ * is deliberately no second streaming state machine here.
61
+ */
62
+ export function useSessions(
63
+ query?: ListSessionsQuery,
64
+ options: ReadOptions<Paginated<Session>> = {},
65
+ ): UseQueryResult<Paginated<Session>, Error> {
66
+ const client = useChatClient()
67
+ return useQuery({
68
+ queryKey: chatKeys.sessions(query),
69
+ queryFn: () => client.listSessions(query),
70
+ ...options,
71
+ })
72
+ }
73
+
74
+ export function useSession(
75
+ id: string,
76
+ options: ReadOptions<Session> = {},
77
+ ): UseQueryResult<Session, Error> {
78
+ const client = useChatClient()
79
+ return useQuery({
80
+ queryKey: chatKeys.session(id),
81
+ queryFn: () => client.getSession(id),
82
+ ...options,
83
+ })
84
+ }
85
+
86
+ export function useSessionMessages(
87
+ id: string,
88
+ options: ReadOptions<SessionMessage[]> = {},
89
+ ): UseQueryResult<SessionMessage[], Error> {
90
+ const client = useChatClient()
91
+ return useQuery({
92
+ queryKey: chatKeys.messages(id),
93
+ queryFn: () => client.getMessages(id),
94
+ ...options,
95
+ })
96
+ }
97
+
98
+ export function useFollowUps(
99
+ conversationId: string,
100
+ messageId: string,
101
+ options: ReadOptions<{ suggestions: unknown[] }> = {},
102
+ ): UseQueryResult<{ suggestions: unknown[] }, Error> {
103
+ const client = useChatClient()
104
+ return useQuery({
105
+ queryKey: chatKeys.followUps(conversationId, messageId),
106
+ queryFn: () => client.getFollowUps(conversationId, messageId),
107
+ ...options,
108
+ })
109
+ }
110
+
111
+ export function useCreateSession(
112
+ options: WriteOptions<Session, CreateSessionRequest> = {},
113
+ ): UseMutationResult<Session, Error, CreateSessionRequest> {
114
+ const client = useChatClient()
115
+ const queryClient = useQueryClient()
116
+ return useMutation({
117
+ mutationFn: (request: CreateSessionRequest) => client.createSession(request),
118
+ ...options,
119
+ onSuccess: (data, variables, context, mutation) => {
120
+ void queryClient.invalidateQueries({ queryKey: chatKeys.all })
121
+ return options.onSuccess?.(data, variables, context, mutation)
122
+ },
123
+ })
124
+ }
125
+
126
+ export function useUpdateSession(
127
+ options: WriteOptions<{ success: boolean }, { id: string; patch: UpdateSessionRequest }> = {},
128
+ ): UseMutationResult<{ success: boolean }, Error, { id: string; patch: UpdateSessionRequest }> {
129
+ const client = useChatClient()
130
+ const queryClient = useQueryClient()
131
+ return useMutation({
132
+ mutationFn: ({ id, patch }) => client.updateSession(id, patch),
133
+ ...options,
134
+ onSuccess: (data, variables, context, mutation) => {
135
+ void queryClient.invalidateQueries({ queryKey: chatKeys.session(variables.id) })
136
+ void queryClient.invalidateQueries({ queryKey: chatKeys.all })
137
+ return options.onSuccess?.(data, variables, context, mutation)
138
+ },
139
+ })
140
+ }
141
+
142
+ /**
143
+ * The parked tool calls of one conversation.
144
+ *
145
+ * Polling is on by default while the tab is visible, and that is the point: an
146
+ * approval appears mid-run, from the agent's side, so a query that only ran on
147
+ * mount would leave the user staring at a stalled conversation. Pass
148
+ * `refetchInterval: false` if you drive it from the stream instead.
149
+ */
150
+ export function usePendingApprovals(
151
+ conversationId: string,
152
+ options: ReadOptions<PendingApproval[]> = {},
153
+ ): UseQueryResult<PendingApproval[], Error> {
154
+ const client = useChatClient()
155
+ return useQuery({
156
+ queryKey: chatKeys.approvals(conversationId),
157
+ queryFn: () => client.listPendingApprovals(conversationId),
158
+ enabled: Boolean(conversationId),
159
+ refetchInterval: 5_000,
160
+ ...options,
161
+ })
162
+ }
163
+
164
+ export function useRespondToApproval(
165
+ conversationId: string,
166
+ options: WriteOptions<
167
+ { id: string; status: string },
168
+ { approvalId: string; decision: ApprovalDecision }
169
+ > = {},
170
+ ): UseMutationResult<
171
+ { id: string; status: string },
172
+ Error,
173
+ { approvalId: string; decision: ApprovalDecision }
174
+ > {
175
+ const client = useChatClient()
176
+ const queryClient = useQueryClient()
177
+ return useMutation({
178
+ mutationFn: ({ approvalId, decision }) => client.respondToApproval(approvalId, decision),
179
+ ...options,
180
+ onSuccess: (data, variables, context, mutation) => {
181
+ void queryClient.invalidateQueries({ queryKey: chatKeys.approvals(conversationId) })
182
+ return options.onSuccess?.(data, variables, context, mutation)
183
+ },
184
+ })
185
+ }
186
+
187
+ export function useCancelRun(
188
+ options: WriteOptions<{ success: true }, string> = {},
189
+ ): UseMutationResult<{ success: true }, Error, string> {
190
+ const client = useChatClient()
191
+ return useMutation({ mutationFn: (runId: string) => client.cancelRun(runId), ...options })
192
+ }
193
+
194
+ export function useSteerRun(
195
+ options: WriteOptions<{ success: true }, { runId: string; message: string }> = {},
196
+ ): UseMutationResult<{ success: true }, Error, { runId: string; message: string }> {
197
+ const client = useChatClient()
198
+ return useMutation({
199
+ mutationFn: ({ runId, message }) => client.steerRun(runId, message),
200
+ ...options,
201
+ })
202
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { FronteraClient } from '@frontera-sdk/core/client'
3
+
4
+ import { ChatClient } from './chat-client'
5
+ import { FronteraChatContext } from './hooks'
6
+
7
+ /**
8
+ * Plug chat into `createFronteraApp` — Frontera Apps only:
9
+ *
10
+ * ```tsx
11
+ * createFronteraApp(<App />, { providers: [blueprintProvider, chatProvider] })
12
+ * ```
13
+ *
14
+ * External applications don't use this: they construct their own
15
+ * `FronteraClient` and either use `ChatClient` directly or wrap
16
+ * `FronteraChatContext.Provider` themselves. The client is rebuilt whenever
17
+ * the host rotates the credential, same as `blueprintProvider`.
18
+ */
19
+ export function chatProvider(value: { client: FronteraClient }, children: ReactNode): ReactNode {
20
+ return (
21
+ <FronteraChatContext.Provider value={new ChatClient(value.client)}>
22
+ {children}
23
+ </FronteraChatContext.Provider>
24
+ )
25
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { FronteraClient } from '@frontera-sdk/core/client'
2
+ import { FronteraError } from '@frontera-sdk/core/errors'
3
+
4
+ import type { StartChatRequest, StartChatResult } from './types'
5
+
6
+ export interface ChatStreamOptions {
7
+ signal?: AbortSignal
8
+ }
9
+
10
+ /**
11
+ * Start a run. The returned `response.body` is an AI SDK v6 UI-message SSE
12
+ * stream — hand it to `readUIMessageStream` from `ai`, or use
13
+ * `createChatTransport` with `useChat` instead of calling this directly.
14
+ *
15
+ * The server interleaves `: ping` SSE comment frames every 15s as keepalive;
16
+ * standard SSE parsers (including the AI SDK's) ignore them.
17
+ */
18
+ export async function startChat(
19
+ client: FronteraClient,
20
+ request: StartChatRequest,
21
+ options: ChatStreamOptions = {},
22
+ ): Promise<StartChatResult> {
23
+ const response = await client.requestRaw('/v1/chat', {
24
+ method: 'POST',
25
+ body: request,
26
+ signal: options.signal,
27
+ })
28
+
29
+ const conversationId = response.headers.get('x-conversation-id')
30
+ const runId = response.headers.get('x-workflow-run-id')
31
+ if (!conversationId || !runId) {
32
+ throw new FronteraError('chat response is missing run identification headers', {
33
+ code: 'PROTOCOL_ERROR',
34
+ status: response.status,
35
+ })
36
+ }
37
+
38
+ return {
39
+ conversationId,
40
+ runId,
41
+ runtime: response.headers.get('x-agent-runtime') ?? 'pi',
42
+ quotaWarning: response.headers.get('x-quota-warning') ?? undefined,
43
+ response,
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Re-attach to a running (or just-finished) run's stream. `startIndex` is the
49
+ * journal index to replay from — pass the count of chunks already consumed to
50
+ * resume without duplicates.
51
+ */
52
+ export async function resumeStream(
53
+ client: FronteraClient,
54
+ runId: string,
55
+ options: { startIndex?: number } & ChatStreamOptions = {},
56
+ ): Promise<Response> {
57
+ return client.requestRaw(`/v1/chat/${encodeURIComponent(runId)}/stream`, {
58
+ query: { startIndex: options.startIndex },
59
+ signal: options.signal,
60
+ })
61
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * A chat credential that keeps itself alive.
3
+ *
4
+ * The alternative — what an integration writes by hand — is a timer that calls
5
+ * their own backend, plus a 401 handler, plus the knowledge that both are
6
+ * needed. Every one of those is a place to be subtly wrong: refresh too late
7
+ * and a send fails; forget the 401 path and a laptop reopened after lunch
8
+ * shows a chat that quietly cannot send.
9
+ *
10
+ * So the session owns it. It renews against the platform (one signature check,
11
+ * no round trip to the customer's backend), falls back to `getToken` when
12
+ * renewal is no longer allowed, and hands the current credential to whoever
13
+ * asks. Nothing else in the SDK needs to know a token can expire.
14
+ */
15
+
16
+ import { FronteraClient } from '@frontera-sdk/core/client'
17
+ import { FronteraError } from '@frontera-sdk/core/errors'
18
+
19
+ import type { ChatToken } from './types'
20
+
21
+ export interface TokenSessionOptions {
22
+ apiBaseUrl: string
23
+ /**
24
+ * Fetches a token from YOUR backend. Called once at the start, and again
25
+ * whenever renewal is no longer possible — a session that has run past its
26
+ * ceiling, or a token that expired while the tab was asleep.
27
+ */
28
+ getToken: () => Promise<ChatToken | string>
29
+ /**
30
+ * A token you already have — typically the one whose expiry told you which
31
+ * `apiBaseUrl` to use. Supplying it skips the opening fetch, so a mount does
32
+ * not mint twice for the same person.
33
+ */
34
+ initialToken?: ChatToken
35
+ /**
36
+ * Renew this long before expiry, as a fraction of the token's lifetime.
37
+ * 0.8 leaves a fifth of the lifetime as slack — enough for a slow network or
38
+ * a suspended tab to recover without a single failed request.
39
+ */
40
+ renewAt?: number
41
+ /** Called after every successful renewal. Handy for persisting the token. */
42
+ onToken?: (token: ChatToken) => void
43
+ /**
44
+ * Called when the session cannot be kept alive at all — both renewal and
45
+ * `getToken` failed. The chat is dead until something changes, and the host
46
+ * is the only one that can say so on screen.
47
+ */
48
+ onExpired?: (error: unknown) => void
49
+ /** Test seam. Defaults to the global. */
50
+ fetchImpl?: typeof fetch
51
+ }
52
+
53
+ export interface TokenSession {
54
+ /** The client to hand to `ChatClient` and `createChatTransport`. */
55
+ getClient: () => FronteraClient
56
+ /** The credential right now, mostly for diagnostics. */
57
+ current: () => ChatToken | null
58
+ /**
59
+ * Force a refresh — call it when a request comes back `UNAUTHORIZED`. Safe to
60
+ * call concurrently: overlapping callers share one in-flight refresh rather
61
+ * than racing to mint several tokens for the same person.
62
+ */
63
+ refresh: () => Promise<ChatToken>
64
+ /** Stop the timer. Call it when the chat unmounts. */
65
+ dispose: () => void
66
+ }
67
+
68
+ const DEFAULT_RENEW_AT = 0.8
69
+ /** Never schedule closer than this; a pathologically short token would
70
+ * otherwise spin the timer. */
71
+ const MIN_DELAY_MS = 5_000
72
+
73
+ function asToken(value: ChatToken | string): ChatToken {
74
+ return typeof value === 'string'
75
+ ? // A bare string has no expiry to plan around. Treated as one hour — the
76
+ // platform default — so the session still refreshes rather than waiting
77
+ // for a failure. Return the full object from your backend to be exact.
78
+ { token: value, expiresAt: new Date(Date.now() + 3600_000).toISOString() }
79
+ : value
80
+ }
81
+
82
+ export function createTokenSession(options: TokenSessionOptions): TokenSession {
83
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch
84
+ const renewAt = options.renewAt ?? DEFAULT_RENEW_AT
85
+
86
+ let token: ChatToken | null = null
87
+ let timer: ReturnType<typeof setTimeout> | null = null
88
+ let inFlight: Promise<ChatToken> | null = null
89
+ let disposed = false
90
+
91
+ const client = () =>
92
+ new FronteraClient(
93
+ {
94
+ apiBaseUrl: options.apiBaseUrl,
95
+ credential: { kind: 'token', token: token?.token ?? '' },
96
+ },
97
+ fetchImpl,
98
+ )
99
+
100
+ const schedule = (current: ChatToken) => {
101
+ if (timer) clearTimeout(timer)
102
+ if (disposed) return
103
+ const lifetime = new Date(current.expiresAt).getTime() - Date.now()
104
+ if (!Number.isFinite(lifetime)) return
105
+ const delay = Math.max(MIN_DELAY_MS, lifetime * renewAt)
106
+ timer = setTimeout(() => {
107
+ void refresh().catch(() => {
108
+ // `refresh` already reported through onExpired; swallowing here keeps
109
+ // an unhandled rejection out of the host's console for a condition it
110
+ // has already been told about.
111
+ })
112
+ }, delay)
113
+ // A timer must never hold a Node process open on its own account.
114
+ ;(timer as unknown as { unref?: () => void }).unref?.()
115
+ }
116
+
117
+ /** Renew against the platform; the customer's backend is not involved. */
118
+ const renew = async (): Promise<ChatToken> => {
119
+ if (!token) throw new Error('no token to renew')
120
+ const response = await fetchImpl(`${options.apiBaseUrl}/v1/chat-tokens/renew`, {
121
+ method: 'POST',
122
+ headers: { authorization: `Bearer ${token.token}` },
123
+ })
124
+ if (!response.ok) {
125
+ throw new FronteraError(`renewal refused with ${response.status}`, {
126
+ code: response.status === 401 ? 'UNAUTHORIZED' : 'RENEWAL_FAILED',
127
+ status: response.status,
128
+ })
129
+ }
130
+ const body = (await response.json()) as { data?: ChatToken } & ChatToken
131
+ return body.data ?? body
132
+ }
133
+
134
+ const refresh = (): Promise<ChatToken> => {
135
+ // One refresh at a time. Three components noticing an expiry in the same
136
+ // tick must not mint three tokens — and the two that lose the race would
137
+ // otherwise keep using a credential the third has already replaced.
138
+ inFlight ??= (async () => {
139
+ try {
140
+ let next: ChatToken
141
+ try {
142
+ next = token ? await renew() : asToken(await options.getToken())
143
+ } catch {
144
+ // Renewal is the fast path, not the only one: a session past its
145
+ // ceiling, or a token that expired while the tab slept, is exactly
146
+ // when the customer's backend should be asked again.
147
+ next = asToken(await options.getToken())
148
+ }
149
+ token = next
150
+ options.onToken?.(next)
151
+ schedule(next)
152
+ return next
153
+ } catch (error) {
154
+ options.onExpired?.(error)
155
+ throw error
156
+ } finally {
157
+ inFlight = null
158
+ }
159
+ })()
160
+ return inFlight
161
+ }
162
+
163
+ if (options.initialToken) {
164
+ token = options.initialToken
165
+ schedule(token)
166
+ } else {
167
+ // Start immediately so the first render has a credential in flight rather
168
+ // than a client pointing at an empty token.
169
+ void refresh().catch(() => {})
170
+ }
171
+
172
+ return {
173
+ getClient: client,
174
+ current: () => token,
175
+ refresh,
176
+ dispose: () => {
177
+ disposed = true
178
+ if (timer) clearTimeout(timer)
179
+ timer = null
180
+ },
181
+ }
182
+ }
@@ -0,0 +1,200 @@
1
+ import { DefaultChatTransport, type ChatTransport, type UIMessage } from 'ai'
2
+ import type { FronteraClient } from '@frontera-sdk/core/client'
3
+ import { authHeaders } from '@frontera-sdk/core/transport'
4
+ import { errorFromResponse } from '@frontera-sdk/core/errors'
5
+ import { chatDoctor } from './doctor'
6
+
7
+ import type { StartChatRequest } from './types'
8
+
9
+ export interface ChatTransportOptions {
10
+ /** Continue an existing conversation. Omit to let the server create one. */
11
+ conversationId?: string
12
+ /**
13
+ * Send only the last user message (default true). The server rehydrates the
14
+ * rest of the conversation from its own store, which keeps request bodies
15
+ * small — the platform web app does the same.
16
+ */
17
+ lastMessageOnly?: boolean
18
+ /**
19
+ * Extra body fields merged into every send (agentName, modelMode, …).
20
+ *
21
+ * Pass a FUNCTION for anything that can change while the chat is mounted.
22
+ * An object is read once, at construction: `useChat` keeps the transport it
23
+ * was first handed, so rebuilding this one with new options never reaches
24
+ * the request, and the send goes out with whatever was true at first render.
25
+ * That is how a conversation ends up with a different agent than the one on
26
+ * screen.
27
+ */
28
+ body?:
29
+ | Partial<Omit<StartChatRequest, 'messages' | 'conversationId'>>
30
+ | (() => Partial<Omit<StartChatRequest, 'messages' | 'conversationId'>>)
31
+ /**
32
+ * Fired once per run with the identifiers from the response headers. The
33
+ * `runId` is what `resumeStream`/`cancelRun`/`steerRun` take, and
34
+ * `conversationId` is how a fresh conversation learns its id.
35
+ */
36
+ onStart?: (info: {
37
+ conversationId: string
38
+ runId: string
39
+ runtime: string
40
+ quotaWarning?: string
41
+ }) => void
42
+ /**
43
+ * Called once when a request comes back `401`, before it is retried.
44
+ *
45
+ * Pass `session.refresh` from `createTokenSession` and an expired credential
46
+ * stops being a visible failure: the send is replayed with the new token and
47
+ * the person never learns there was one. Without it, a chat left open past
48
+ * the token's lifetime fails on the next message with nothing on screen
49
+ * explaining why.
50
+ */
51
+ onUnauthorized?: () => Promise<unknown>
52
+ }
53
+
54
+ /**
55
+ * The body of a failed response, as whatever it turns out to be.
56
+ *
57
+ * A gateway between the caller and the service answers HTML, and a dropped
58
+ * connection answers nothing at all; neither must turn a 502 into a parse
59
+ * error that hides the status the caller needed to see.
60
+ */
61
+ async function readErrorBody(response: Response): Promise<unknown> {
62
+ try {
63
+ const text = await response.text()
64
+ if (!text) return undefined
65
+ try {
66
+ return JSON.parse(text)
67
+ } catch {
68
+ return text
69
+ }
70
+ } catch {
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ /**
76
+ * An AI SDK `ChatTransport` for `useChat` that speaks the platform's chat
77
+ * wire protocol: auth from the `FronteraClient`'s credential, last-message-only
78
+ * sends, and reconnects via the run's replay stream.
79
+ *
80
+ * Browser use requires a browser-safe credential (an end-user `cht_` token, or
81
+ * an `apiBaseUrl` pointing at your own proxy) — never ship a `sak_` key to a
82
+ * browser.
83
+ */
84
+ export function createChatTransport(
85
+ client: FronteraClient | (() => FronteraClient),
86
+ options: ChatTransportOptions = {},
87
+ ): ChatTransport<UIMessage> {
88
+ // A getter is accepted because end-user tokens are short-lived BY DESIGN: an
89
+ // integration refreshes them mid-session, and a transport that captured the
90
+ // client at construction would keep signing with the expired credential
91
+ // until it was rebuilt — which drops any stream in flight. Reading the
92
+ // client per request makes rotation invisible.
93
+ const current = typeof client === 'function' ? client : () => client
94
+ let conversationId = options.conversationId
95
+ let runId: string | undefined
96
+
97
+ const wrappedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
98
+ const send = async () => {
99
+ const merged: Record<string, string> = {}
100
+ new Headers(init?.headers).forEach((value, name) => {
101
+ merged[name] = value
102
+ })
103
+ // Read per attempt, not once: a refresh between the two swaps the
104
+ // credential, and replaying with the old one would fail identically.
105
+ Object.assign(merged, authHeaders(current().config))
106
+ return fetch(input, { ...init, headers: merged })
107
+ }
108
+
109
+ let response = await send()
110
+
111
+ // Exactly one retry, and only for 401. A second attempt after a refusal
112
+ // that refreshing cannot fix is just a slower failure — and a loop here
113
+ // would hammer the mint path with a credential that will never work.
114
+ if (response.status === 401) {
115
+ if (options.onUnauthorized) {
116
+ await options.onUnauthorized()
117
+ response = await send()
118
+ } else {
119
+ // Said once, immediately: without a refresh wired, the next send fails
120
+ // the same way, and the person is looking at a chat that has silently
121
+ // stopped accepting messages.
122
+ chatDoctor.noticed('unauthorized')
123
+ }
124
+ }
125
+
126
+ /*
127
+ Refusals become `FronteraError` here, before the AI SDK sees them.
128
+
129
+ Left alone, a non-2xx reaches `useChat` as a bare `Error` whose message
130
+ is the response body — so "quota exhausted", "your token expired" and
131
+ "this agent was deleted" all arrive as one indistinguishable string, and
132
+ an integration cannot tell the user which of them happened or decide to
133
+ refresh the token. The service already answers with a coded envelope;
134
+ this keeps the code instead of discarding it.
135
+ */
136
+ if (!response.ok) {
137
+ throw errorFromResponse(response.status, await readErrorBody(response))
138
+ }
139
+ const headerConversationId = response.headers.get('x-conversation-id')
140
+ const headerRunId = response.headers.get('x-workflow-run-id')
141
+ if (headerConversationId && headerRunId) {
142
+ conversationId = headerConversationId
143
+ runId = headerRunId
144
+ options.onStart?.({
145
+ conversationId: headerConversationId,
146
+ runId: headerRunId,
147
+ runtime: response.headers.get('x-agent-runtime') ?? 'pi',
148
+ quotaWarning: response.headers.get('x-quota-warning') ?? undefined,
149
+ })
150
+ }
151
+ // Development only, and a no-op otherwise: watches the stream for the two
152
+ // things that park a run, so an unhandled one becomes a console warning
153
+ // rather than a conversation that appears to have stopped for no reason.
154
+ return chatDoctor.observe(response)
155
+ }) as typeof fetch
156
+
157
+ return new DefaultChatTransport<UIMessage>({
158
+ // Resolved per request, not here. The client may not exist yet: a caller
159
+ // that fetches its token asynchronously has nothing to give until the
160
+ // first exchange returns, and reading `.config` at construction would
161
+ // throw before anything could render.
162
+ api: '',
163
+ fetch: wrappedFetch,
164
+ /*
165
+ `body` here is the PER-CALL one — `sendMessage(message, { body })`.
166
+
167
+ It was dropped, and the loss was silent: attachments upload fine, the
168
+ composer shows them, the send succeeds, and the agent simply never
169
+ receives the file. Anything a caller attaches to one message travels this
170
+ way, so the same omission also swallowed per-send `modelMode` and any
171
+ custom field a host passes for a single turn.
172
+
173
+ Merged AFTER the transport-level `options.body` on purpose: that one is
174
+ the standing configuration for every send, and a value supplied for one
175
+ message is the more specific statement.
176
+ */
177
+ prepareSendMessagesRequest: ({ messages, body }) => {
178
+ const lastUserMessage = [...messages].reverse().find((m) => m.role === 'user')
179
+ const trimmed =
180
+ options.lastMessageOnly === false
181
+ ? messages
182
+ : lastUserMessage
183
+ ? [lastUserMessage]
184
+ : messages.slice(-1)
185
+ return {
186
+ api: `${current().config.apiBaseUrl}/v1/chat`,
187
+ body: {
188
+ messages: trimmed,
189
+ ...(conversationId ? { conversationId } : {}),
190
+ ...(typeof options.body === 'function' ? options.body() : options.body),
191
+ ...body,
192
+ },
193
+ }
194
+ },
195
+ prepareReconnectToStreamRequest: () => {
196
+ if (!runId) throw new Error('no run to reconnect to — no send has completed yet')
197
+ return { api: `${current().config.apiBaseUrl}/v1/chat/${encodeURIComponent(runId)}/stream` }
198
+ },
199
+ })
200
+ }