@tanstack/ai-client 0.22.1 → 0.23.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/README.md +15 -1
- package/dist/esm/audio-recorder.js +190 -213
- package/dist/esm/audio-recorder.js.map +1 -1
- package/dist/esm/chat-client.d.ts +172 -3
- package/dist/esm/chat-client.js +1656 -1386
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/cleared-stream-tracker.d.ts +23 -0
- package/dist/esm/cleared-stream-tracker.js +97 -0
- package/dist/esm/cleared-stream-tracker.js.map +1 -0
- package/dist/esm/client-persistor.d.ts +25 -12
- package/dist/esm/client-persistor.js +260 -235
- package/dist/esm/client-persistor.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +231 -10
- package/dist/esm/connection-adapters.js +989 -574
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/devtools-noop.d.ts +1 -0
- package/dist/esm/devtools-noop.js +79 -139
- package/dist/esm/devtools-noop.js.map +1 -1
- package/dist/esm/devtools.d.ts +31 -1
- package/dist/esm/devtools.js +977 -1127
- package/dist/esm/devtools.js.map +1 -1
- package/dist/esm/events.js +224 -226
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/generation-client.d.ts +145 -2
- package/dist/esm/generation-client.js +659 -321
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-reconstruct.d.ts +21 -0
- package/dist/esm/generation-reconstruct.js +85 -0
- package/dist/esm/generation-reconstruct.js.map +1 -0
- package/dist/esm/generation-types.d.ts +289 -3
- package/dist/esm/generation-types.js +356 -13
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/index.d.ts +9 -4
- package/dist/esm/index.js +7 -39
- package/dist/esm/interrupt-manager.d.ts +77 -0
- package/dist/esm/interrupt-manager.js +787 -0
- package/dist/esm/interrupt-manager.js.map +1 -0
- package/dist/esm/mcp-app-bridge.js +56 -64
- package/dist/esm/mcp-app-bridge.js.map +1 -1
- package/dist/esm/realtime-client.js +366 -440
- package/dist/esm/realtime-client.js.map +1 -1
- package/dist/esm/response-stream.js +19 -26
- package/dist/esm/response-stream.js.map +1 -1
- package/dist/esm/sse-parser.js +44 -47
- package/dist/esm/sse-parser.js.map +1 -1
- package/dist/esm/sse-utils.js +8 -9
- package/dist/esm/sse-utils.js.map +1 -1
- package/dist/esm/storage-adapters.d.ts +62 -0
- package/dist/esm/storage-adapters.js +174 -0
- package/dist/esm/storage-adapters.js.map +1 -0
- package/dist/esm/types.d.ts +212 -10
- package/dist/esm/types.js +38 -7
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +113 -2
- package/dist/esm/video-generation-client.js +665 -379
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +7 -7
- package/src/chat-client.ts +1079 -61
- package/src/cleared-stream-tracker.ts +151 -0
- package/src/client-persistor.ts +102 -33
- package/src/connection-adapters.ts +1185 -142
- package/src/devtools-noop.ts +4 -3
- package/src/devtools.ts +121 -3
- package/src/generation-client.ts +563 -13
- package/src/generation-reconstruct.ts +121 -0
- package/src/generation-types.ts +727 -3
- package/src/index.ts +56 -1
- package/src/interrupt-manager.ts +1440 -0
- package/src/storage-adapters.ts +242 -0
- package/src/types.ts +301 -9
- package/src/video-generation-client.ts +479 -13
- package/dist/esm/index.js.map +0 -1
package/src/chat-client.ts
CHANGED
|
@@ -13,13 +13,18 @@ import {
|
|
|
13
13
|
normalizeConnectionAdapter,
|
|
14
14
|
} from './connection-adapters'
|
|
15
15
|
import { ChatPersistor } from './client-persistor'
|
|
16
|
+
import { ClearedStreamTracker } from './cleared-stream-tracker'
|
|
17
|
+
import { InterruptManager } from './interrupt-manager'
|
|
16
18
|
import type {
|
|
17
19
|
AnyClientTool,
|
|
18
20
|
ContentPart,
|
|
21
|
+
InterruptSubmissionError,
|
|
19
22
|
ModelMessage,
|
|
23
|
+
RunAgentResumeItem,
|
|
20
24
|
StreamChunk,
|
|
21
25
|
} from '@tanstack/ai/client'
|
|
22
26
|
import type {
|
|
27
|
+
ChatHydrationResult,
|
|
23
28
|
ConnectionAdapter,
|
|
24
29
|
SubscribeConnectionAdapter,
|
|
25
30
|
} from './connection-adapters'
|
|
@@ -33,9 +38,15 @@ import type {
|
|
|
33
38
|
ChatDevtoolsBridgeOptions,
|
|
34
39
|
} from './devtools'
|
|
35
40
|
import type {
|
|
41
|
+
BoundInterrupts,
|
|
36
42
|
ChatClientOptions,
|
|
37
43
|
ChatClientState,
|
|
38
44
|
ChatFetcher,
|
|
45
|
+
ChatInterrupt,
|
|
46
|
+
ChatInterruptState,
|
|
47
|
+
ChatPendingInterrupt,
|
|
48
|
+
ChatResumeSnapshot,
|
|
49
|
+
ChatResumeState,
|
|
39
50
|
ConnectionStatus,
|
|
40
51
|
MessagePart,
|
|
41
52
|
MultimodalContent,
|
|
@@ -48,6 +59,7 @@ import type {
|
|
|
48
59
|
UIMessage,
|
|
49
60
|
WhenBusy,
|
|
50
61
|
} from './types'
|
|
62
|
+
import type { InterruptManagerSubmission } from './interrupt-manager'
|
|
51
63
|
|
|
52
64
|
/** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */
|
|
53
65
|
interface InternalQueuedMessage extends QueuedMessage {
|
|
@@ -72,6 +84,16 @@ type ChatClientUpdateOptionsWithoutContext<
|
|
|
72
84
|
onConnectionStatusChange?: (status: ConnectionStatus) => void
|
|
73
85
|
onSessionGeneratingChange?: (isGenerating: boolean) => void
|
|
74
86
|
onQueueChange?: (queue: Array<QueuedMessage>) => void
|
|
87
|
+
onResumeStateChange?: (
|
|
88
|
+
resumeState: ChatResumeState | null,
|
|
89
|
+
pendingInterrupts: BoundInterrupts<TTools>,
|
|
90
|
+
) => void
|
|
91
|
+
/**
|
|
92
|
+
* Fires whenever the id of the run in flight changes: the new id when a run
|
|
93
|
+
* starts (including a rejoin), `null` when it settles.
|
|
94
|
+
*/
|
|
95
|
+
onRunIdChange?: (runId: string | null) => void
|
|
96
|
+
onInterruptStateChange?: (state: ChatInterruptState<TTools>) => void
|
|
75
97
|
onCustomEvent?: (
|
|
76
98
|
eventType: string,
|
|
77
99
|
data: unknown,
|
|
@@ -178,6 +200,74 @@ function mergeQueuedMessages(items: Array<InternalQueuedMessage>): {
|
|
|
178
200
|
}
|
|
179
201
|
}
|
|
180
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Extract a boolean approval decision from an AG-UI resume payload, if present.
|
|
205
|
+
* Tool-approval resolutions carry `{ approved: boolean, ... }`; generic
|
|
206
|
+
* interrupt payloads do not.
|
|
207
|
+
*/
|
|
208
|
+
function readApprovalApproved(payload: unknown): boolean | undefined {
|
|
209
|
+
if (
|
|
210
|
+
payload === null ||
|
|
211
|
+
typeof payload !== 'object' ||
|
|
212
|
+
Array.isArray(payload)
|
|
213
|
+
) {
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
if (!('approved' in payload) || typeof payload.approved !== 'boolean') {
|
|
217
|
+
return undefined
|
|
218
|
+
}
|
|
219
|
+
return payload.approved
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readResumeState(
|
|
223
|
+
snapshot: ChatResumeSnapshot,
|
|
224
|
+
): ChatResumeState | undefined {
|
|
225
|
+
const value: unknown = snapshot
|
|
226
|
+
if (
|
|
227
|
+
value === null ||
|
|
228
|
+
typeof value !== 'object' ||
|
|
229
|
+
!('resumeState' in value)
|
|
230
|
+
) {
|
|
231
|
+
return undefined
|
|
232
|
+
}
|
|
233
|
+
const resumeState = value.resumeState
|
|
234
|
+
if (
|
|
235
|
+
resumeState === null ||
|
|
236
|
+
typeof resumeState !== 'object' ||
|
|
237
|
+
!('threadId' in resumeState) ||
|
|
238
|
+
typeof resumeState.threadId !== 'string' ||
|
|
239
|
+
resumeState.threadId.length === 0 ||
|
|
240
|
+
!('runId' in resumeState) ||
|
|
241
|
+
typeof resumeState.runId !== 'string' ||
|
|
242
|
+
resumeState.runId.length === 0
|
|
243
|
+
) {
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
return { threadId: resumeState.threadId, runId: resumeState.runId }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* How long a reload rejoin waits for its first chunk before giving up. A durable
|
|
251
|
+
* backend keeps a from-start join open waiting for a producer; without this
|
|
252
|
+
* bound a stale pointer to an unknown/evicted run would pin the UI loading for
|
|
253
|
+
* the backend's full first-chunk deadline (tens of seconds). Kept short so the
|
|
254
|
+
* client decides "reachable or not" quickly.
|
|
255
|
+
*/
|
|
256
|
+
const REJOIN_CONNECT_DEADLINE_MS = 2000
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Chunk types that (re)build the assistant message on a rejoin. The hydrated
|
|
260
|
+
* in-flight partial is dropped only when one of these arrives — never on
|
|
261
|
+
* `RUN_STARTED` alone — so a rejoin that connects but delivers no content cannot
|
|
262
|
+
* leave an empty assistant bubble.
|
|
263
|
+
*/
|
|
264
|
+
const REJOIN_REBUILD_TRIGGERS = new Set<string>([
|
|
265
|
+
'TEXT_MESSAGE_START',
|
|
266
|
+
'TEXT_MESSAGE_CONTENT',
|
|
267
|
+
'TOOL_CALL_START',
|
|
268
|
+
'MESSAGES_SNAPSHOT',
|
|
269
|
+
])
|
|
270
|
+
|
|
181
271
|
export class ChatClient<
|
|
182
272
|
TTools extends ReadonlyArray<AnyClientTool> = any,
|
|
183
273
|
TContext = unknown,
|
|
@@ -186,11 +276,35 @@ export class ChatClient<
|
|
|
186
276
|
private connection: SubscribeConnectionAdapter
|
|
187
277
|
private readonly uniqueId: string
|
|
188
278
|
private readonly threadId: string
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
279
|
+
// Durable chat persistence (optional): messages + resume snapshot as one
|
|
280
|
+
// combined record, so a full page reload restores the transcript, rehydrates
|
|
281
|
+
// pending interrupts, and rejoins an in-flight run. Clear-during-stream
|
|
282
|
+
// suppression is always on via ClearedStreamTracker so `clear()` works
|
|
283
|
+
// without a storage adapter.
|
|
192
284
|
private readonly persistor?: ChatPersistor
|
|
285
|
+
private readonly clearedStreamTracker = new ClearedStreamTracker()
|
|
193
286
|
private currentRunId: string | null = null
|
|
287
|
+
// Interrupt-resume tracking: the run/thread of the most recent interrupted
|
|
288
|
+
// run, so approvals/client-tool results can be sent back. Cleared when the
|
|
289
|
+
// run terminates. This is STATE (interrupt) resume, not delivery/cursor.
|
|
290
|
+
private lastResume: ChatResumeState | null = null
|
|
291
|
+
// The in-flight run id already handed to `resumeInFlightRun`, so a persisted
|
|
292
|
+
// run is rejoined at most once even when both the sync read and the async
|
|
293
|
+
// hydrate surface the same resume pointer.
|
|
294
|
+
private rejoinedRunId: string | null = null
|
|
295
|
+
private readonly interruptManager: InterruptManager<TTools>
|
|
296
|
+
private activeInterruptSubmission: InterruptManagerSubmission | undefined
|
|
297
|
+
private interruptSubmissionFailure:
|
|
298
|
+
| { errors: ReadonlyArray<InterruptSubmissionError> }
|
|
299
|
+
| undefined
|
|
300
|
+
private readonly joinedRunWaiters = new Map<string, () => void>()
|
|
301
|
+
// When set, the next streamResponse() continues this interrupted run instead
|
|
302
|
+
// of starting a fresh run (consumed once).
|
|
303
|
+
private pendingResumeParentRunId: string | null = null
|
|
304
|
+
private pendingResumeThreadId: string | null = null
|
|
305
|
+
private pendingResumeItems: Array<RunAgentResumeItem> | null = null
|
|
306
|
+
private activeResumeThreadId: string | null = null
|
|
307
|
+
private activeResumeRunId: string | null = null
|
|
194
308
|
// Track the legacy `body` option and the canonical `forwardedProps`
|
|
195
309
|
// option as separate slots so that `updateOptions({ forwardedProps })`
|
|
196
310
|
// doesn't wipe a previously-set `body` (and vice versa). They are
|
|
@@ -258,6 +372,13 @@ export class ChatClient<
|
|
|
258
372
|
private draining = false
|
|
259
373
|
private sessionGenerating = false
|
|
260
374
|
private readonly activeRunIds = new Set<string>()
|
|
375
|
+
/** Latched by `dispose()`; stops any late async callback starting new work. */
|
|
376
|
+
private disposed = false
|
|
377
|
+
/** Whether a view is currently watching. See `attach` / `detach`. */
|
|
378
|
+
private tailing = false
|
|
379
|
+
/** Constructor inputs `attach()` needs on every re-attach, not just the first. */
|
|
380
|
+
private readonly rejoinRunId: string | null | undefined
|
|
381
|
+
private readonly cachesMessages: boolean
|
|
261
382
|
private devtoolsMounted = false
|
|
262
383
|
|
|
263
384
|
private readonly callbacksRef: {
|
|
@@ -274,6 +395,12 @@ export class ChatClient<
|
|
|
274
395
|
onConnectionStatusChange: (status: ConnectionStatus) => void
|
|
275
396
|
onSessionGeneratingChange: (isGenerating: boolean) => void
|
|
276
397
|
onQueueChange: (queue: Array<QueuedMessage>) => void
|
|
398
|
+
onResumeStateChange: (
|
|
399
|
+
resumeState: ChatResumeState | null,
|
|
400
|
+
pendingInterrupts: BoundInterrupts<TTools>,
|
|
401
|
+
) => void
|
|
402
|
+
onRunIdChange: (runId: string | null) => void
|
|
403
|
+
onInterruptStateChange: (state: ChatInterruptState<TTools>) => void
|
|
277
404
|
onCustomEvent: (
|
|
278
405
|
eventType: string,
|
|
279
406
|
data: unknown,
|
|
@@ -283,13 +410,33 @@ export class ChatClient<
|
|
|
283
410
|
}
|
|
284
411
|
|
|
285
412
|
constructor(options: ChatClientOptions<TTools, TContext>) {
|
|
286
|
-
this.uniqueId = options.id || this.generateUniqueId('chat')
|
|
287
413
|
this.threadId = options.threadId || this.generateUniqueId('thread')
|
|
288
|
-
|
|
414
|
+
// The instance/devtools id defaults to the threadId (the chat's identity),
|
|
415
|
+
// falling back to a generated id only when neither is set. `id` overrides it
|
|
416
|
+
// only for direct ChatClient users who key storage separately from the wire
|
|
417
|
+
// thread; the framework hooks never pass it.
|
|
418
|
+
this.uniqueId = options.id || this.threadId
|
|
419
|
+
// `persistence` is `false`/omitted (ephemeral, in-memory), `true`
|
|
420
|
+
// (server-authoritative: cache nothing client-side, hydrate the thread from
|
|
421
|
+
// the server by `threadId` on mount), or a storage adapter
|
|
422
|
+
// (client-authoritative: cache the transcript plus resume pointer). Only the
|
|
423
|
+
// server-authoritative mode turns transcript caching off; that is what gates
|
|
424
|
+
// the mount hydration and keeps a client record from shadowing server history.
|
|
425
|
+
let cachesMessages = true
|
|
426
|
+
if (options.persistence === true) {
|
|
427
|
+
cachesMessages = false
|
|
428
|
+
} else if (options.persistence) {
|
|
429
|
+
// A storage adapter: keep the combined record (transcript + resume pointer)
|
|
430
|
+
// in the browser. Persistence keys on `threadId` (the conversation
|
|
431
|
+
// identity) so a reload with the same `threadId` finds the same record;
|
|
432
|
+
// `id` overrides it only when set, for apps that key storage separately
|
|
433
|
+
// from the wire thread.
|
|
434
|
+
const persistenceKey = options.id ?? this.threadId
|
|
289
435
|
this.persistor = new ChatPersistor(
|
|
290
436
|
options.persistence,
|
|
291
|
-
|
|
437
|
+
persistenceKey,
|
|
292
438
|
(messages) => this.processor.setMessages(messages),
|
|
439
|
+
(snapshot) => this.applyPersistedResume(snapshot),
|
|
293
440
|
)
|
|
294
441
|
}
|
|
295
442
|
// Both `body` (deprecated) and `forwardedProps` populate the AG-UI
|
|
@@ -332,17 +479,72 @@ export class ChatClient<
|
|
|
332
479
|
onSessionGeneratingChange:
|
|
333
480
|
options.onSessionGeneratingChange || (() => {}),
|
|
334
481
|
onQueueChange: options.onQueueChange || (() => {}),
|
|
482
|
+
onResumeStateChange: options.onResumeStateChange || (() => {}),
|
|
483
|
+
onRunIdChange: options.onRunIdChange || (() => {}),
|
|
484
|
+
onInterruptStateChange: options.onInterruptStateChange || (() => {}),
|
|
335
485
|
onCustomEvent: options.onCustomEvent || (() => {}),
|
|
336
486
|
},
|
|
337
487
|
}
|
|
338
488
|
|
|
489
|
+
this.interruptManager = new InterruptManager({
|
|
490
|
+
...(options.tools !== undefined ? { tools: options.tools } : {}),
|
|
491
|
+
submit: (submission) => this.submitInterruptBatch(submission),
|
|
492
|
+
onChange: () => this.notifyResumeStateChange(),
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
// In-memory rehydrate of interrupt descriptors (e.g. after a page reload
|
|
496
|
+
// when the host supplies a snapshot). Durable storage of that snapshot is
|
|
497
|
+
// a persistence-stack concern — not wired here.
|
|
498
|
+
if (options.initialResumeSnapshot) {
|
|
499
|
+
this.applyResumeSnapshot(options.initialResumeSnapshot)
|
|
500
|
+
}
|
|
501
|
+
|
|
339
502
|
// Create StreamProcessor with event handlers.
|
|
340
503
|
// Use conditional spreads so we don't pass `undefined` into
|
|
341
504
|
// `StreamProcessorOptions` fields under `exactOptionalPropertyTypes`.
|
|
342
|
-
const
|
|
343
|
-
const
|
|
344
|
-
?
|
|
505
|
+
const persistedState = this.persistor?.readInitial()
|
|
506
|
+
const syncPersistedState =
|
|
507
|
+
persistedState instanceof Promise ? undefined : persistedState
|
|
508
|
+
// A persistor exists only in client-authoritative mode, so a synchronously
|
|
509
|
+
// read record's transcript is the conversation; adopt it over host
|
|
510
|
+
// `initialMessages`. (Server-authoritative mode has no persistor and instead
|
|
511
|
+
// hydrates from the server on mount, keyed by threadId.)
|
|
512
|
+
const initialMessages = syncPersistedState
|
|
513
|
+
? syncPersistedState.messages
|
|
345
514
|
: options.initialMessages
|
|
515
|
+
// A durable snapshot read synchronously from storage wins over the
|
|
516
|
+
// in-memory `initialResumeSnapshot` fallback applied above. A snapshot with
|
|
517
|
+
// pending interrupts rehydrates the interrupt UI; a bare in-flight run is
|
|
518
|
+
// rejoined after the processor is ready (see `rejoinRunId` below).
|
|
519
|
+
let rejoinRunId: string | null = null
|
|
520
|
+
if (syncPersistedState?.resume) {
|
|
521
|
+
const snapshot = syncPersistedState.resume
|
|
522
|
+
const hasPendingInterrupts =
|
|
523
|
+
Array.isArray(snapshot.pendingInterrupts) &&
|
|
524
|
+
snapshot.pendingInterrupts.length > 0
|
|
525
|
+
if (hasPendingInterrupts) {
|
|
526
|
+
// Interrupts are run-scoped state, restored from the cached snapshot.
|
|
527
|
+
this.applyResumeSnapshot(snapshot)
|
|
528
|
+
} else if (snapshot.resumeState.runId) {
|
|
529
|
+
// A bare in-flight run pointer drives a client-authoritative rejoin.
|
|
530
|
+
rejoinRunId = snapshot.resumeState.runId
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// A host-supplied `initialResumeSnapshot` carrying a bare in-flight run is
|
|
534
|
+
// rejoined too, not just its interrupts (which `applyResumeSnapshot` above
|
|
535
|
+
// already restored). This is how a server-authoritative app hands a FRESH
|
|
536
|
+
// client an in-flight run to tail — e.g. opening the thread on a second
|
|
537
|
+
// device / browser, where hydration reports the active run id but no local
|
|
538
|
+
// resume pointer exists. A run named by the persisted store wins.
|
|
539
|
+
if (!rejoinRunId && options.initialResumeSnapshot) {
|
|
540
|
+
const snapshot = options.initialResumeSnapshot
|
|
541
|
+
const hasPendingInterrupts =
|
|
542
|
+
Array.isArray(snapshot.pendingInterrupts) &&
|
|
543
|
+
snapshot.pendingInterrupts.length > 0
|
|
544
|
+
if (!hasPendingInterrupts && snapshot.resumeState.runId) {
|
|
545
|
+
rejoinRunId = snapshot.resumeState.runId
|
|
546
|
+
}
|
|
547
|
+
}
|
|
346
548
|
|
|
347
549
|
this.processor = new StreamProcessor({
|
|
348
550
|
...(options.streamProcessor?.chunkStrategy
|
|
@@ -544,12 +746,230 @@ export class ChatClient<
|
|
|
544
746
|
data: unknown,
|
|
545
747
|
context: { toolCallId?: string },
|
|
546
748
|
) => {
|
|
749
|
+
// Server-side memory middleware transports its state as a `memory:state`
|
|
750
|
+
// CUSTOM event (its own event bus never reaches this browser runtime).
|
|
751
|
+
// Route it to the devtools bridge here — the designated custom-event
|
|
752
|
+
// path — then still forward to the app's callback.
|
|
753
|
+
if (eventType === 'memory:state') {
|
|
754
|
+
this.devtoolsBridge.recordMemoryState(data)
|
|
755
|
+
}
|
|
547
756
|
this.callbacksRef.current.onCustomEvent(eventType, data, context)
|
|
548
757
|
},
|
|
549
758
|
},
|
|
550
759
|
})
|
|
551
760
|
|
|
552
|
-
this.persistor?.hydrateAsync(
|
|
761
|
+
this.persistor?.hydrateAsync(persistedState)
|
|
762
|
+
|
|
763
|
+
this.rejoinRunId = rejoinRunId
|
|
764
|
+
this.cachesMessages = cachesMessages
|
|
765
|
+
// NO TAILING HERE, deliberately. Constructing a client must not open a
|
|
766
|
+
// connection.
|
|
767
|
+
//
|
|
768
|
+
// A UI framework may build a client and then throw it away — React does it on
|
|
769
|
+
// every double-invoked render, and the discarded instance is never mounted, so
|
|
770
|
+
// nothing ever calls `detach()` or `dispose()` on it. When the constructor
|
|
771
|
+
// opened a stream, that stream became unreachable and held one of the browser's
|
|
772
|
+
// ~6 connections per origin until the page reloaded. Traced with CDP: connection
|
|
773
|
+
// ids 1374/1396/1428/1437 were still held after eight thread switches, and a
|
|
774
|
+
// later request waited 210 SECONDS for a free slot (`stallMs: 210752`).
|
|
775
|
+
//
|
|
776
|
+
// Guarding inside the client cannot fix that, because the leaking instance is
|
|
777
|
+
// the one the framework discarded — every guard runs on the instance it kept.
|
|
778
|
+
// Only "idle until a view attaches" makes a thrown-away client harmless.
|
|
779
|
+
//
|
|
780
|
+
// Callers therefore drive the lifecycle: `attach()` when a view mounts,
|
|
781
|
+
// `detach()` when it unmounts. Every framework wrapper in this repo does.
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* START TAILING: re-attach to an in-flight run so its chunks arrive here.
|
|
786
|
+
*
|
|
787
|
+
* Called by the constructor, and again by a UI wrapper every time its view
|
|
788
|
+
* mounts. Idempotent — attaching while already attached does nothing — so the
|
|
789
|
+
* constructor call and a wrapper's first mount cost one attach between them.
|
|
790
|
+
*
|
|
791
|
+
* Pairs with {@link detach}. The pair exists because tailing used to begin ONLY
|
|
792
|
+
* in the constructor, which meant a view could never stop tailing and then
|
|
793
|
+
* resume: unmount had to either keep the connection open or lose it for good.
|
|
794
|
+
* Keeping it open is what starved the page — a browser allows ~6 connections per
|
|
795
|
+
* origin, and one long-lived stream per view reaches that after a handful of
|
|
796
|
+
* views, after which every other request queues (measured: an in-page fetch took
|
|
797
|
+
* over two minutes while the same request from outside the browser took 17ms).
|
|
798
|
+
*/
|
|
799
|
+
attach(): void {
|
|
800
|
+
if (this.disposed || this.tailing) return
|
|
801
|
+
this.tailing = true
|
|
802
|
+
|
|
803
|
+
// Full page reload with an in-flight run persisted (synchronous store):
|
|
804
|
+
// re-attach to it off the server's delivery-durability log so the stream
|
|
805
|
+
// finishes here. Async stores rejoin from `applyPersistedResume` once the
|
|
806
|
+
// hydrate resolves. Best-effort and non-blocking.
|
|
807
|
+
if (this.rejoinRunId) {
|
|
808
|
+
this.maybeRejoinInFlight(this.rejoinRunId)
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Server-authoritative (`persistence: true`): the client caches no transcript
|
|
812
|
+
// and no run pointer — it re-hydrates from the server on mount, keyed by the
|
|
813
|
+
// stable threadId. `hydrate` returns the stored transcript plus a cursor to
|
|
814
|
+
// any in-flight run, which is tailed via the same joinRun path. This is what
|
|
815
|
+
// makes reload AND a fresh device work with zero app glue (no loader/prop).
|
|
816
|
+
if (!this.cachesMessages && this.connection.hydrate) {
|
|
817
|
+
this.hydrateFromServer()
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* STOP TAILING: drop the connection, keep everything else.
|
|
823
|
+
*
|
|
824
|
+
* Called by a UI wrapper when its view unmounts. The transcript, the resume
|
|
825
|
+
* pointer and the run id all stay, so a later {@link attach} repaints instantly
|
|
826
|
+
* and re-tails from the durable log — nothing is lost, because the run keeps
|
|
827
|
+
* going server-side and its log holds every chunk.
|
|
828
|
+
*
|
|
829
|
+
* Deliberately NOT `dispose()`: this client is expected back. And deliberately
|
|
830
|
+
* not `stop()`, which means "the user ended this run" — detaching says only that
|
|
831
|
+
* nobody is watching right now.
|
|
832
|
+
*
|
|
833
|
+
* `rejoinedRunId` is cleared so the next `attach` can re-join the same run;
|
|
834
|
+
* without that reset the guard in {@link maybeRejoinInFlight} would treat the
|
|
835
|
+
* run as already joined and the view would come back silent.
|
|
836
|
+
*/
|
|
837
|
+
detach(): void {
|
|
838
|
+
if (!this.tailing) return
|
|
839
|
+
// BEFORE the abort, because `resumeInFlightRun`'s cleanup reads it: a join
|
|
840
|
+
// aborted before its first chunk normally means "this run is unreachable" and
|
|
841
|
+
// clears the resume pointer. A detach is not that — the run is fine and we
|
|
842
|
+
// intend to come back — so the pointer must survive.
|
|
843
|
+
this.tailing = false
|
|
844
|
+
this.cancelInFlightStream({ setReadyStatus: true })
|
|
845
|
+
this.rejoinedRunId = null
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void {
|
|
849
|
+
const resumeState = readResumeState(snapshot)
|
|
850
|
+
if (resumeState === undefined) {
|
|
851
|
+
this.interruptManager.reset()
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
this.lastResume = resumeState
|
|
855
|
+
const pendingInterrupts = Array.isArray(snapshot.pendingInterrupts)
|
|
856
|
+
? snapshot.pendingInterrupts
|
|
857
|
+
: []
|
|
858
|
+
if (pendingInterrupts.length === 0) {
|
|
859
|
+
this.interruptManager.reset()
|
|
860
|
+
return
|
|
861
|
+
}
|
|
862
|
+
const generation = this.interruptGeneration(pendingInterrupts)
|
|
863
|
+
this.interruptManager.hydrate({
|
|
864
|
+
threadId: resumeState.threadId,
|
|
865
|
+
interruptedRunId: resumeState.runId,
|
|
866
|
+
generation,
|
|
867
|
+
interrupts: pendingInterrupts,
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Apply a resume snapshot read from durable storage. Restores interrupt state,
|
|
873
|
+
* and for a bare in-flight run (no pending interrupts) also rejoins it. This is
|
|
874
|
+
* the async-store counterpart to the synchronous rejoin in the constructor:
|
|
875
|
+
* `applyResumeSnapshot` alone only handles interrupts, so an async store
|
|
876
|
+
* (`indexedDBPersistence`) would otherwise never rejoin a mid-stream run.
|
|
877
|
+
*/
|
|
878
|
+
private applyPersistedResume(snapshot: ChatResumeSnapshot): void {
|
|
879
|
+
this.applyResumeSnapshot(snapshot)
|
|
880
|
+
const hasInterrupts =
|
|
881
|
+
Array.isArray(snapshot.pendingInterrupts) &&
|
|
882
|
+
snapshot.pendingInterrupts.length > 0
|
|
883
|
+
const runId = snapshot.resumeState?.runId
|
|
884
|
+
// A cached run pointer only reaches here through the persistor, which exists
|
|
885
|
+
// only in client-authoritative mode, so a bare in-flight run rejoins here.
|
|
886
|
+
// (Server-authoritative reconnect is resolved from the server by threadId in
|
|
887
|
+
// `hydrateFromServer`.)
|
|
888
|
+
if (!hasInterrupts && runId) {
|
|
889
|
+
this.maybeRejoinInFlight(runId)
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Rejoin a persisted in-flight run, guarded so it fires at most once and never
|
|
895
|
+
* while another run is already active. Skipped when the connection is not
|
|
896
|
+
* resumable (`joinRun` absent), so a non-durable transport is a no-op.
|
|
897
|
+
*/
|
|
898
|
+
private maybeRejoinInFlight(runId: string): void {
|
|
899
|
+
if (!this.connection.joinRun) return
|
|
900
|
+
// A client with no view attached must never open a connection. `tailing` is
|
|
901
|
+
// the load-bearing half: a view switch calls `detach()`, NOT `dispose()`, and
|
|
902
|
+
// an in-flight hydration resolves a moment later and lands right here — so
|
|
903
|
+
// guarding only on `disposed` let every switch open a fresh tail that nothing
|
|
904
|
+
// would ever abort. Measured with CDP: connection ids 1366/1397/1429/1460 were
|
|
905
|
+
// still held after eight switches, and a later request waited 97 SECONDS for a
|
|
906
|
+
// slot (`stallMs: 97691`).
|
|
907
|
+
if (this.disposed || !this.tailing) return
|
|
908
|
+
if (this.rejoinedRunId === runId) return
|
|
909
|
+
// A fresh send (or an already-running rejoin) owns the client; don't stomp it.
|
|
910
|
+
if (this.isLoading || this.abortController) return
|
|
911
|
+
this.rejoinedRunId = runId
|
|
912
|
+
this.resumeInFlightRun(runId)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Server-authoritative mount hydration (`persistence: true`). The client holds
|
|
917
|
+
* no transcript and no run pointer; on mount it asks the server — keyed by the
|
|
918
|
+
* stable threadId — for the stored transcript and whether a run is still
|
|
919
|
+
* generating. The transcript repaints immediately; an in-flight run is tailed
|
|
920
|
+
* through the same durability rejoin as a reload. Best-effort and
|
|
921
|
+
* non-blocking: a failure leaves the client empty rather than throwing, and a
|
|
922
|
+
* send that starts first owns the client (hydration then backs off).
|
|
923
|
+
*/
|
|
924
|
+
private hydrateFromServer(): void {
|
|
925
|
+
const hydrate = this.connection.hydrate
|
|
926
|
+
if (!hydrate) return
|
|
927
|
+
if (this.isLoading || this.abortController) return
|
|
928
|
+
if (this.disposed) return
|
|
929
|
+
void (async () => {
|
|
930
|
+
let result: ChatHydrationResult
|
|
931
|
+
try {
|
|
932
|
+
result = await hydrate(this.threadId)
|
|
933
|
+
} catch {
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
// NO VIEW IS WATCHING ANY MORE (it unmounted while this fetch was in
|
|
937
|
+
// flight). Applying anything now is pointless, and one thing is actively
|
|
938
|
+
// harmful: the branch below calls `maybeRejoinInFlight`, which opens a TAIL.
|
|
939
|
+
// A tail started here belongs to a view that has gone, so nothing will ever
|
|
940
|
+
// abort it, and a browser allows only ~6 connections per origin — so a
|
|
941
|
+
// handful of switches starve the page and every later request queues.
|
|
942
|
+
//
|
|
943
|
+
// `!this.tailing` is the case that actually bites: a switch calls `detach()`,
|
|
944
|
+
// not `dispose()`, so a `disposed`-only check let the leak straight through.
|
|
945
|
+
if (this.disposed || !this.tailing) return
|
|
946
|
+
// A send may have started while the fetch was in flight — don't stomp it.
|
|
947
|
+
if (this.isLoading || this.abortController) return
|
|
948
|
+
if (result.messages.length > 0) {
|
|
949
|
+
this.processor.setMessages(result.messages)
|
|
950
|
+
}
|
|
951
|
+
if (result.interrupts && result.interrupts.pending.length > 0) {
|
|
952
|
+
// Pending interrupt = the thread is paused awaiting a human decision, so
|
|
953
|
+
// there is nothing to tail (no chunks stream until it resolves). Restore
|
|
954
|
+
// the approval/wait from the SERVER — identical to reconstructing it from
|
|
955
|
+
// a resume snapshot — so the reload re-prompts the decision and the resume
|
|
956
|
+
// targets the run it paused. This is checked BEFORE `activeRun` on
|
|
957
|
+
// purpose: a run that just paused can momentarily still read as `running`
|
|
958
|
+
// on the server, so a racing hydrate reports both an `activeRun` cursor
|
|
959
|
+
// AND the pending interrupt. Tailing that "active" run would drop the
|
|
960
|
+
// approval card (and hang on a stream that never comes), so the interrupt
|
|
961
|
+
// always wins.
|
|
962
|
+
this.applyResumeSnapshot({
|
|
963
|
+
resumeState: {
|
|
964
|
+
threadId: this.threadId,
|
|
965
|
+
runId: result.interrupts.runId,
|
|
966
|
+
},
|
|
967
|
+
pendingInterrupts: result.interrupts.pending,
|
|
968
|
+
})
|
|
969
|
+
} else if (result.activeRun?.runId) {
|
|
970
|
+
this.maybeRejoinInFlight(result.activeRun.runId)
|
|
971
|
+
}
|
|
972
|
+
})()
|
|
553
973
|
}
|
|
554
974
|
|
|
555
975
|
mountDevtools(): void {
|
|
@@ -568,22 +988,52 @@ export class ChatClient<
|
|
|
568
988
|
*/
|
|
569
989
|
private drainIgnoredRunlessChunk(chunk: StreamChunk): void {
|
|
570
990
|
if (chunk.type !== 'RUN_ERROR') return
|
|
571
|
-
const runId = this.
|
|
991
|
+
const runId = this.clearedStreamTracker.takeRunlessRunId()
|
|
572
992
|
if (!runId) return
|
|
573
993
|
this.activeRunIds.delete(runId)
|
|
574
994
|
this.setSessionGenerating(this.activeRunIds.size > 0)
|
|
575
995
|
this.resolveProcessing()
|
|
576
996
|
}
|
|
577
997
|
|
|
998
|
+
private retireIgnoredClearedTerminalChunk(chunk: StreamChunk): void {
|
|
999
|
+
if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') return
|
|
1000
|
+
const runId =
|
|
1001
|
+
getChunkRunId(chunk) ?? this.clearedStreamTracker.takeRunlessRunId()
|
|
1002
|
+
if (!runId) return
|
|
1003
|
+
this.activeRunIds.delete(runId)
|
|
1004
|
+
this.setSessionGenerating(this.activeRunIds.size > 0)
|
|
1005
|
+
if (!getChunkRunId(chunk)) {
|
|
1006
|
+
this.resolveProcessing()
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
578
1010
|
private updateRunLifecycle(
|
|
579
1011
|
chunk: StreamChunk,
|
|
580
1012
|
options?: { resolveProcessing?: boolean },
|
|
581
1013
|
): void {
|
|
582
1014
|
if (chunk.type === 'RUN_STARTED') {
|
|
583
1015
|
const chunkRunId = getChunkRunId(chunk) ?? chunk.runId
|
|
1016
|
+
this.activeResumeThreadId =
|
|
1017
|
+
'threadId' in chunk && typeof chunk.threadId === 'string'
|
|
1018
|
+
? chunk.threadId
|
|
1019
|
+
: this.activeResumeThreadId
|
|
1020
|
+
this.activeResumeRunId = chunkRunId
|
|
584
1021
|
this.activeRunIds.add(chunkRunId)
|
|
585
|
-
this.
|
|
1022
|
+
this.clearedStreamTracker.onRunStarted(chunkRunId)
|
|
586
1023
|
this.setSessionGenerating(true)
|
|
1024
|
+
// Persist a live-run resume snapshot so a full page reload can rejoin this
|
|
1025
|
+
// in-flight run via joinRun. Only a persistor writes it, and a persistor
|
|
1026
|
+
// exists only in client-authoritative mode; server-authoritative reconnect
|
|
1027
|
+
// is resolved from the server by threadId in `hydrateFromServer`, so no
|
|
1028
|
+
// client-cached run pointer (which goes stale the moment a turn spans a
|
|
1029
|
+
// second run) is ever written. Interrupt/terminal handling overwrites or
|
|
1030
|
+
// clears it in observeInterruptState.
|
|
1031
|
+
if (this.persistor && this.connection.joinRun && !this.lastResume) {
|
|
1032
|
+
this.persistResumeSnapshot({
|
|
1033
|
+
threadId: this.activeResumeThreadId ?? this.threadId,
|
|
1034
|
+
runId: chunkRunId,
|
|
1035
|
+
})
|
|
1036
|
+
}
|
|
587
1037
|
return
|
|
588
1038
|
}
|
|
589
1039
|
|
|
@@ -594,11 +1044,11 @@ export class ChatClient<
|
|
|
594
1044
|
const runId = getChunkRunId(chunk)
|
|
595
1045
|
if (runId) {
|
|
596
1046
|
this.activeRunIds.delete(runId)
|
|
597
|
-
this.
|
|
1047
|
+
this.clearedStreamTracker.onRunSettled(runId)
|
|
598
1048
|
} else if (chunk.type === 'RUN_ERROR') {
|
|
599
1049
|
// RUN_ERROR without runId is a session-level error; clear all runs.
|
|
600
1050
|
this.activeRunIds.clear()
|
|
601
|
-
this.
|
|
1051
|
+
this.clearedStreamTracker.onSessionRunError()
|
|
602
1052
|
}
|
|
603
1053
|
this.setSessionGenerating(this.activeRunIds.size > 0)
|
|
604
1054
|
if (options?.resolveProcessing !== false) {
|
|
@@ -606,6 +1056,260 @@ export class ChatClient<
|
|
|
606
1056
|
}
|
|
607
1057
|
}
|
|
608
1058
|
|
|
1059
|
+
/**
|
|
1060
|
+
* Track interrupt state off the stream's terminal events. A RUN_FINISHED with
|
|
1061
|
+
* an interrupt outcome records the pending interrupts + the run/thread to
|
|
1062
|
+
* resume; any other terminal event for the tracked/current run clears that
|
|
1063
|
+
* state. This is interrupt (state) resume — there is no delivery cursor.
|
|
1064
|
+
*/
|
|
1065
|
+
private observeInterruptState(chunk: StreamChunk): void {
|
|
1066
|
+
if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') {
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (this.activeInterruptSubmission && chunk.type === 'RUN_ERROR') {
|
|
1071
|
+
return
|
|
1072
|
+
}
|
|
1073
|
+
const runId = getChunkRunId(chunk)
|
|
1074
|
+
const threadId =
|
|
1075
|
+
'threadId' in chunk && typeof chunk.threadId === 'string'
|
|
1076
|
+
? chunk.threadId
|
|
1077
|
+
: this.activeResumeThreadId
|
|
1078
|
+
|
|
1079
|
+
if (chunk.type === 'RUN_FINISHED' && chunk.outcome?.type === 'interrupt') {
|
|
1080
|
+
// Track the REQUEST run id (what the client sent) so a resume targets the
|
|
1081
|
+
// same run even when provider events carry their own run id.
|
|
1082
|
+
const interruptedRunId =
|
|
1083
|
+
this.currentRunId ?? runId ?? this.activeResumeRunId ?? ''
|
|
1084
|
+
this.lastResume = {
|
|
1085
|
+
threadId: threadId ?? this.threadId,
|
|
1086
|
+
runId: interruptedRunId,
|
|
1087
|
+
}
|
|
1088
|
+
this.interruptManager.hydrate({
|
|
1089
|
+
threadId: this.lastResume.threadId,
|
|
1090
|
+
interruptedRunId,
|
|
1091
|
+
generation: this.interruptGeneration(chunk.outcome.interrupts),
|
|
1092
|
+
interrupts: chunk.outcome.interrupts,
|
|
1093
|
+
})
|
|
1094
|
+
return
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const isRunlessSessionError = chunk.type === 'RUN_ERROR' && !runId
|
|
1098
|
+
const isTrackedRunTerminal = Boolean(
|
|
1099
|
+
runId && this.lastResume?.runId === runId,
|
|
1100
|
+
)
|
|
1101
|
+
const isCurrentRunTerminal = Boolean(
|
|
1102
|
+
(runId && this.currentRunId === runId) ||
|
|
1103
|
+
(this.currentRunId && this.lastResume?.runId === this.currentRunId),
|
|
1104
|
+
)
|
|
1105
|
+
// Provider adapters sometimes stamp a different run id on continuation
|
|
1106
|
+
// events than the client-generated request id. RUN_STARTED updates
|
|
1107
|
+
// `activeResumeRunId`, so match that too.
|
|
1108
|
+
const isActiveStreamRunTerminal = Boolean(
|
|
1109
|
+
this.isLoading &&
|
|
1110
|
+
runId &&
|
|
1111
|
+
(runId === this.activeResumeRunId || runId === this.currentRunId),
|
|
1112
|
+
)
|
|
1113
|
+
const isCurrentStreamTerminal =
|
|
1114
|
+
this.isLoading && chunk.type === 'RUN_FINISHED' && !runId
|
|
1115
|
+
// A resume batch that finishes successfully (or with a non-interrupt
|
|
1116
|
+
// terminal) must always clear pending interrupts — even when the provider
|
|
1117
|
+
// run id does not correlate. Otherwise Approve works once but the UI
|
|
1118
|
+
// keeps showing a stale prompt and blocks follow-up turns.
|
|
1119
|
+
const isActiveInterruptSubmissionTerminal = Boolean(
|
|
1120
|
+
this.activeInterruptSubmission &&
|
|
1121
|
+
this.isLoading &&
|
|
1122
|
+
chunk.type === 'RUN_FINISHED' &&
|
|
1123
|
+
chunk.outcome?.type !== 'interrupt',
|
|
1124
|
+
)
|
|
1125
|
+
if (
|
|
1126
|
+
isRunlessSessionError ||
|
|
1127
|
+
isTrackedRunTerminal ||
|
|
1128
|
+
isCurrentRunTerminal ||
|
|
1129
|
+
isActiveStreamRunTerminal ||
|
|
1130
|
+
isCurrentStreamTerminal ||
|
|
1131
|
+
isActiveInterruptSubmissionTerminal
|
|
1132
|
+
) {
|
|
1133
|
+
this.lastResume = null
|
|
1134
|
+
// Run settled without an interrupt: drop the durable resume snapshot so a
|
|
1135
|
+
// later reload does not try to rejoin a finished run.
|
|
1136
|
+
this.persistor?.persistResumeSnapshot(null)
|
|
1137
|
+
this.interruptManager.reset()
|
|
1138
|
+
return
|
|
1139
|
+
}
|
|
1140
|
+
this.notifyResumeStateChange()
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* The interrupt-resume state for the active/interrupted run (its run/thread
|
|
1145
|
+
* ids), or null when there is nothing to resume. Apps can persist this to
|
|
1146
|
+
* resume interrupts across a full reload.
|
|
1147
|
+
*/
|
|
1148
|
+
getResumeState(): ChatResumeState | null {
|
|
1149
|
+
return this.lastResume ? { ...this.lastResume } : null
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The id of the run this client has in flight — one it started via a send or
|
|
1154
|
+
* rejoined via `joinRun` — or null when there is none. Unlike
|
|
1155
|
+
* {@link getResumeState}, this tracks ordinary runs too, not only one that is
|
|
1156
|
+
* interrupted or being resumed. A run another client started and that arrives
|
|
1157
|
+
* over a live subscription is not this client's run and is not reported here.
|
|
1158
|
+
*/
|
|
1159
|
+
getCurrentRunId(): string | null {
|
|
1160
|
+
return this.currentRunId
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
private setCurrentRunId(runId: string | null): void {
|
|
1164
|
+
if (this.currentRunId === runId) return
|
|
1165
|
+
this.currentRunId = runId
|
|
1166
|
+
this.callbacksRef.current.onRunIdChange(runId)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
getInterruptState(): ChatInterruptState<TTools> {
|
|
1170
|
+
return this.interruptManager.getState()
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
getInterrupts(): BoundInterrupts<TTools> {
|
|
1174
|
+
return this.interruptManager.getInterrupts()
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** @deprecated Use getInterrupts(). */
|
|
1178
|
+
getPendingInterrupts(): BoundInterrupts<TTools> {
|
|
1179
|
+
return this.interruptManager.getInterrupts()
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
resolveInterrupts(approved: boolean): void
|
|
1183
|
+
resolveInterrupts(
|
|
1184
|
+
resolver: (interrupt: ChatInterrupt<TTools>) => undefined,
|
|
1185
|
+
): void
|
|
1186
|
+
resolveInterrupts(
|
|
1187
|
+
resolution: boolean | ((interrupt: ChatInterrupt<TTools>) => undefined),
|
|
1188
|
+
): void {
|
|
1189
|
+
// Branch so TypeScript can select the InterruptManager.resolve overloads.
|
|
1190
|
+
if (typeof resolution === 'boolean') {
|
|
1191
|
+
this.interruptManager.resolve(resolution)
|
|
1192
|
+
return
|
|
1193
|
+
}
|
|
1194
|
+
this.interruptManager.resolve(resolution)
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
cancelInterrupts(): void {
|
|
1198
|
+
this.interruptManager.cancel()
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
retryInterrupts(): void {
|
|
1202
|
+
this.interruptManager.retry()
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/** Unsafe low-level resume escape hatch. Prefer bound interrupt methods. */
|
|
1206
|
+
resumeInterruptsUnsafe(
|
|
1207
|
+
resume: Array<RunAgentResumeItem>,
|
|
1208
|
+
state?: ChatResumeState,
|
|
1209
|
+
): Promise<boolean> {
|
|
1210
|
+
const target = state ?? this.lastResume
|
|
1211
|
+
if (!target) return Promise.resolve(false)
|
|
1212
|
+
// Auto-executed client tools resolve during the parent stream's
|
|
1213
|
+
// `pendingToolExecutions` wait — while `isLoading` is still true.
|
|
1214
|
+
// Defer the child continuation until that stream settles so we do not
|
|
1215
|
+
// race the parent cleanup or return a false "could not start" failure.
|
|
1216
|
+
if (this.isLoading) {
|
|
1217
|
+
return new Promise<boolean>((resolve, reject) => {
|
|
1218
|
+
this.queuePostStreamAction(async () => {
|
|
1219
|
+
try {
|
|
1220
|
+
resolve(await this.resumeInterruptsUnsafe(resume, target))
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
reject(error)
|
|
1223
|
+
}
|
|
1224
|
+
})
|
|
1225
|
+
})
|
|
1226
|
+
}
|
|
1227
|
+
this.pendingResumeThreadId = target.threadId
|
|
1228
|
+
this.pendingResumeParentRunId = target.runId
|
|
1229
|
+
this.pendingResumeItems = [...resume]
|
|
1230
|
+
return this.streamResponse()
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** @deprecated Use bound interrupt methods or resumeInterruptsUnsafe(). */
|
|
1234
|
+
resumeInterrupts(
|
|
1235
|
+
resume: Array<RunAgentResumeItem>,
|
|
1236
|
+
state?: ChatResumeState,
|
|
1237
|
+
): Promise<boolean> {
|
|
1238
|
+
return this.resumeInterruptsUnsafe(resume, state)
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
private async submitInterruptBatch(
|
|
1242
|
+
submission: InterruptManagerSubmission,
|
|
1243
|
+
): Promise<void> {
|
|
1244
|
+
this.activeInterruptSubmission = submission
|
|
1245
|
+
this.interruptSubmissionFailure = undefined
|
|
1246
|
+
// Reflect approval decisions in the local message tree immediately so a
|
|
1247
|
+
// follow-up turn does not re-serialize tool-calls still stuck in
|
|
1248
|
+
// `approval-requested` (issue #532).
|
|
1249
|
+
for (const resolution of submission.resolutions) {
|
|
1250
|
+
const approved = readApprovalApproved(resolution.payload)
|
|
1251
|
+
if (approved === undefined) continue
|
|
1252
|
+
const approvalId = resolution.interruptId
|
|
1253
|
+
this.processor.addToolApprovalResponse(approvalId, approved)
|
|
1254
|
+
}
|
|
1255
|
+
const resumed = await this.resumeInterruptsUnsafe(
|
|
1256
|
+
[...submission.resolutions],
|
|
1257
|
+
{
|
|
1258
|
+
threadId: submission.threadId,
|
|
1259
|
+
runId: submission.interruptedRunId,
|
|
1260
|
+
},
|
|
1261
|
+
).finally(() => {
|
|
1262
|
+
this.activeInterruptSubmission = undefined
|
|
1263
|
+
})
|
|
1264
|
+
const failure = this.takeInterruptSubmissionFailure()
|
|
1265
|
+
if (failure !== undefined) {
|
|
1266
|
+
throw { errors: failure.errors }
|
|
1267
|
+
}
|
|
1268
|
+
if (!resumed) {
|
|
1269
|
+
throw new Error('Interrupt continuation could not be started.')
|
|
1270
|
+
}
|
|
1271
|
+
// Belt-and-suspenders: if the continuation stream finished successfully
|
|
1272
|
+
// but correlation failed to clear resume state, drop it now so the next
|
|
1273
|
+
// user turn is not blocked by a stale interrupt prompt.
|
|
1274
|
+
if (this.lastResume?.runId === submission.interruptedRunId) {
|
|
1275
|
+
this.lastResume = null
|
|
1276
|
+
this.interruptManager.reset()
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
private takeInterruptSubmissionFailure():
|
|
1281
|
+
| { errors: ReadonlyArray<InterruptSubmissionError> }
|
|
1282
|
+
| undefined {
|
|
1283
|
+
const failure = this.interruptSubmissionFailure
|
|
1284
|
+
this.interruptSubmissionFailure = undefined
|
|
1285
|
+
return failure
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
private interruptGeneration(
|
|
1289
|
+
interrupts: ReadonlyArray<ChatPendingInterrupt>,
|
|
1290
|
+
): number {
|
|
1291
|
+
let generation: number | undefined
|
|
1292
|
+
for (const interrupt of interrupts) {
|
|
1293
|
+
const candidate: unknown =
|
|
1294
|
+
interrupt.metadata?.['tanstack:interruptBinding']
|
|
1295
|
+
if (
|
|
1296
|
+
candidate === null ||
|
|
1297
|
+
typeof candidate !== 'object' ||
|
|
1298
|
+
!('generation' in candidate) ||
|
|
1299
|
+
typeof candidate.generation !== 'number' ||
|
|
1300
|
+
!Number.isInteger(candidate.generation) ||
|
|
1301
|
+
candidate.generation < 0
|
|
1302
|
+
) {
|
|
1303
|
+
return 0
|
|
1304
|
+
}
|
|
1305
|
+
if (generation !== undefined && generation !== candidate.generation) {
|
|
1306
|
+
return 0
|
|
1307
|
+
}
|
|
1308
|
+
generation = candidate.generation
|
|
1309
|
+
}
|
|
1310
|
+
return generation ?? 0
|
|
1311
|
+
}
|
|
1312
|
+
|
|
609
1313
|
private generateUniqueId(prefix: string): string {
|
|
610
1314
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
|
611
1315
|
}
|
|
@@ -641,9 +1345,47 @@ export class ChatClient<
|
|
|
641
1345
|
this.devtoolsBridge.emitSnapshot()
|
|
642
1346
|
}
|
|
643
1347
|
|
|
644
|
-
private
|
|
1348
|
+
private notifyResumeStateChange(): void {
|
|
1349
|
+
const resumeState = this.getResumeState()
|
|
1350
|
+
// Persist (or clear) the durable resume snapshot so a full page reload can
|
|
1351
|
+
// rehydrate pending interrupts and rejoin the run. Folded into the same
|
|
1352
|
+
// persistence adapter that stores messages (one record per chat).
|
|
1353
|
+
this.persistResumeSnapshot(resumeState)
|
|
1354
|
+
this.callbacksRef.current.onResumeStateChange(
|
|
1355
|
+
resumeState,
|
|
1356
|
+
this.interruptManager.getInterrupts(),
|
|
1357
|
+
)
|
|
1358
|
+
this.callbacksRef.current.onInterruptStateChange(
|
|
1359
|
+
this.interruptManager.getState(),
|
|
1360
|
+
)
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Build the durable resume snapshot from the current resume state + pending
|
|
1365
|
+
* interrupt descriptors and hand it to the persistor (null clears it).
|
|
1366
|
+
*/
|
|
1367
|
+
private persistResumeSnapshot(resumeState: ChatResumeState | null): void {
|
|
1368
|
+
if (!this.persistor) return
|
|
1369
|
+
if (!resumeState) {
|
|
1370
|
+
this.persistor.persistResumeSnapshot(null)
|
|
1371
|
+
return
|
|
1372
|
+
}
|
|
1373
|
+
const descriptors = this.interruptManager.getDescriptors()
|
|
1374
|
+
this.persistor.persistResumeSnapshot({
|
|
1375
|
+
resumeState,
|
|
1376
|
+
...(descriptors.length > 0
|
|
1377
|
+
? { pendingInterrupts: [...descriptors] }
|
|
1378
|
+
: {}),
|
|
1379
|
+
})
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
private resetSessionGenerating(options?: {
|
|
1383
|
+
preserveClearedStreamTracking?: boolean
|
|
1384
|
+
}): void {
|
|
645
1385
|
this.activeRunIds.clear()
|
|
646
|
-
|
|
1386
|
+
if (!options?.preserveClearedStreamTracking) {
|
|
1387
|
+
this.clearedStreamTracker.resetActiveRuns()
|
|
1388
|
+
}
|
|
647
1389
|
this.setSessionGenerating(false)
|
|
648
1390
|
}
|
|
649
1391
|
|
|
@@ -793,33 +1535,204 @@ export class ChatClient<
|
|
|
793
1535
|
const stream = this.connection.subscribe(signal)
|
|
794
1536
|
for await (const chunk of stream) {
|
|
795
1537
|
if (signal.aborted) break
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1538
|
+
await this.processIncomingChunk(chunk)
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Re-attach to an in-flight run after a full page reload, replaying its stream
|
|
1544
|
+
* from the server's delivery-durability log via `joinRun` (which returns the
|
|
1545
|
+
* whole run so far, then tails live to completion).
|
|
1546
|
+
*
|
|
1547
|
+
* The log is the single source of truth for the run, so we rebuild the
|
|
1548
|
+
* in-flight assistant bubble from it rather than trying to reconcile the
|
|
1549
|
+
* server-hydrated partial with the replay: on the first chunk that actually
|
|
1550
|
+
* (re)builds a message we drop the hydrated in-flight assistant, and the
|
|
1551
|
+
* replay reconstructs one clean bubble. Dropping only on real content (not on
|
|
1552
|
+
* `RUN_STARTED`) means a rejoin that connects but delivers nothing can never
|
|
1553
|
+
* leave an empty bubble behind.
|
|
1554
|
+
*
|
|
1555
|
+
* Bounded connect: a durable backend keeps a from-start join open waiting for
|
|
1556
|
+
* a producer, so a stale pointer to an unknown/evicted run would otherwise pin
|
|
1557
|
+
* the UI in a loading state for the backend's full first-chunk deadline. We
|
|
1558
|
+
* give up after {@link REJOIN_CONNECT_DEADLINE_MS} if no chunk arrives and
|
|
1559
|
+
* clear the dead pointer so it does not retry on the next load.
|
|
1560
|
+
*
|
|
1561
|
+
* Replay chunks are processed WITHOUT the per-chunk yield the live path uses,
|
|
1562
|
+
* so the buffered prefix snaps in and only the genuinely-live tail streams at
|
|
1563
|
+
* network speed — a reload looks like the run continued, not like it re-typed.
|
|
1564
|
+
*/
|
|
1565
|
+
private resumeInFlightRun(runId: string): void {
|
|
1566
|
+
const joinRun = this.connection.joinRun
|
|
1567
|
+
if (!joinRun) return
|
|
1568
|
+
const controller = new AbortController()
|
|
1569
|
+
this.abortController = controller
|
|
1570
|
+
this.setCurrentRunId(runId)
|
|
1571
|
+
// Record the resume state in-memory BEFORE replaying. Otherwise the
|
|
1572
|
+
// replayed `RUN_STARTED` (which carries the PROVIDER run id, not the
|
|
1573
|
+
// client/durability-log run id the pointer is keyed by) trips the
|
|
1574
|
+
// `!this.lastResume` guard in `updateRunLifecycle` and rewrites the
|
|
1575
|
+
// persisted pointer with the provider id — so a SECOND reload would
|
|
1576
|
+
// `joinRun` an id the log isn't keyed by and never re-attach.
|
|
1577
|
+
this.lastResume = { threadId: this.threadId, runId }
|
|
1578
|
+
this.setIsLoading(true)
|
|
1579
|
+
this.setStatus('streaming')
|
|
1580
|
+
void (async () => {
|
|
1581
|
+
let rebuilt = false
|
|
1582
|
+
let attached = false
|
|
1583
|
+
// Whether the join FAILED (a thrown non-abort error before any chunk), as
|
|
1584
|
+
// opposed to merely not delivering in time. Only a failure proves the
|
|
1585
|
+
// pointer dead — see the `finally`.
|
|
1586
|
+
let refused = false
|
|
1587
|
+
const connectTimer = setTimeout(() => {
|
|
1588
|
+
if (!attached) controller.abort()
|
|
1589
|
+
}, REJOIN_CONNECT_DEADLINE_MS)
|
|
1590
|
+
try {
|
|
1591
|
+
for await (const chunk of joinRun(runId, controller.signal)) {
|
|
1592
|
+
if (controller.signal.aborted) break
|
|
1593
|
+
if (!attached) {
|
|
1594
|
+
attached = true
|
|
1595
|
+
clearTimeout(connectTimer)
|
|
806
1596
|
}
|
|
1597
|
+
if (!rebuilt && REJOIN_REBUILD_TRIGGERS.has(chunk.type)) {
|
|
1598
|
+
rebuilt = true
|
|
1599
|
+
this.dropTrailingInFlightAssistant()
|
|
1600
|
+
}
|
|
1601
|
+
await this.processIncomingChunk(chunk, { defer: false })
|
|
1602
|
+
}
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
// Pre-attach failures (unknown/evicted run, connect deadline abort)
|
|
1605
|
+
// stay soft: keep the restored transcript. Post-attach transport/parser
|
|
1606
|
+
// failures are real stream errors and must surface so the UI is not
|
|
1607
|
+
// left truncated and silent.
|
|
1608
|
+
const isAbort =
|
|
1609
|
+
error instanceof Error &&
|
|
1610
|
+
(error.name === 'AbortError' || error.name === 'TimeoutError')
|
|
1611
|
+
if (!attached && !isAbort) refused = true
|
|
1612
|
+
if (attached && !isAbort) {
|
|
1613
|
+
this.reportStreamError(
|
|
1614
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
1615
|
+
)
|
|
1616
|
+
}
|
|
1617
|
+
} finally {
|
|
1618
|
+
clearTimeout(connectTimer)
|
|
1619
|
+
if (!attached && refused && this.tailing && !this.disposed) {
|
|
1620
|
+
// The server REFUSED the join (unknown / evicted run): the pointer is
|
|
1621
|
+
// dead. Clear it so it does not retry and re-pin the UI on the next
|
|
1622
|
+
// load. The server's persisted transcript is still loaded.
|
|
1623
|
+
//
|
|
1624
|
+
// A connect-deadline abort (or an external abort) deliberately does
|
|
1625
|
+
// NOT clear it: the run may simply not have produced yet — a durable
|
|
1626
|
+
// run whose middleware is still booting a sandbox emits nothing for
|
|
1627
|
+
// a while — and clearing on a timeout would permanently orphan a run
|
|
1628
|
+
// that is still going. The pointer survives for the next load, which
|
|
1629
|
+
// costs that load one more bounded connect attempt.
|
|
1630
|
+
//
|
|
1631
|
+
// `tailing`/`disposed` guard the same pointer from the other side: a
|
|
1632
|
+
// DETACH aborts before the first chunk exactly like an unreachable run
|
|
1633
|
+
// does, and a refusal that lands after the view is gone belongs to
|
|
1634
|
+
// nobody. `refused` already spares the timeout case; these two spare
|
|
1635
|
+
// the "no view is watching any more" case, so the pointer only ever
|
|
1636
|
+
// dies for a client that is still looking at the run.
|
|
1637
|
+
this.lastResume = null
|
|
1638
|
+
this.persistor?.persistResumeSnapshot(null)
|
|
1639
|
+
}
|
|
1640
|
+
if (this.abortController === controller) {
|
|
1641
|
+
this.abortController = null
|
|
1642
|
+
this.setIsLoading(false)
|
|
1643
|
+
if (this.status === 'streaming') this.setStatus('ready')
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
})()
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* Drop a hydrated, still-in-flight assistant turn so a resume replay can
|
|
1651
|
+
* rebuild it cleanly. Only touches a trailing assistant message (the shape a
|
|
1652
|
+
* reload-mid-stream leaves); a thread whose last turn is a user message (run
|
|
1653
|
+
* never produced, or already settled) is left untouched.
|
|
1654
|
+
*/
|
|
1655
|
+
private dropTrailingInFlightAssistant(): void {
|
|
1656
|
+
const messages = this.processor.getMessages()
|
|
1657
|
+
const last = messages[messages.length - 1]
|
|
1658
|
+
if (last && last.role === 'assistant') {
|
|
1659
|
+
this.processor.setMessages(messages.slice(0, -1))
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
private async processIncomingChunk(
|
|
1664
|
+
chunk: StreamChunk,
|
|
1665
|
+
options?: { defer?: boolean },
|
|
1666
|
+
): Promise<void> {
|
|
1667
|
+
if (
|
|
1668
|
+
chunk.type === 'RUN_ERROR' &&
|
|
1669
|
+
this.isActiveInterruptSubmissionFailure(chunk)
|
|
1670
|
+
) {
|
|
1671
|
+
this.interruptSubmissionFailure = {
|
|
1672
|
+
errors: chunk['tanstack:interruptErrors'] ?? [],
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
if (this.connectionStatus === 'connecting') {
|
|
1676
|
+
this.setConnectionStatus('connected')
|
|
1677
|
+
}
|
|
1678
|
+
const shouldIgnore = this.clearedStreamTracker.shouldIgnoreChunk(chunk)
|
|
1679
|
+
if (shouldIgnore) {
|
|
1680
|
+
if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
|
|
1681
|
+
if (getChunkRunId(chunk)) {
|
|
1682
|
+
this.updateRunLifecycle(chunk, { resolveProcessing: false })
|
|
1683
|
+
} else {
|
|
1684
|
+
this.drainIgnoredRunlessChunk(chunk)
|
|
807
1685
|
}
|
|
808
|
-
|
|
1686
|
+
this.retireIgnoredClearedTerminalChunk(chunk)
|
|
1687
|
+
this.resolveJoinedRun(chunk)
|
|
809
1688
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1689
|
+
return
|
|
1690
|
+
}
|
|
1691
|
+
this.callbacksRef.current.onChunk(chunk)
|
|
1692
|
+
this.devtoolsBridge.observeChunk(chunk)
|
|
1693
|
+
this.processor.processChunk(chunk)
|
|
1694
|
+
this.updateRunLifecycle(chunk)
|
|
1695
|
+
this.observeInterruptState(chunk)
|
|
1696
|
+
// The live path yields a macrotask between chunks so React can paint each
|
|
1697
|
+
// delta progressively. A resume replay passes `defer: false` to skip it, so
|
|
1698
|
+
// the buffered backlog applies in one batch (instant catch-up) instead of
|
|
1699
|
+
// re-typing the whole reply.
|
|
1700
|
+
if (options?.defer !== false) {
|
|
821
1701
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
822
1702
|
}
|
|
1703
|
+
this.resolveJoinedRun(chunk)
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
private isActiveInterruptSubmissionFailure(
|
|
1707
|
+
chunk: Extract<StreamChunk, { type: 'RUN_ERROR' }>,
|
|
1708
|
+
): boolean {
|
|
1709
|
+
const submission = this.activeInterruptSubmission
|
|
1710
|
+
const errors = chunk['tanstack:interruptErrors']
|
|
1711
|
+
if (!submission || !errors || errors.length === 0) return false
|
|
1712
|
+
const runId = getChunkRunId(chunk)
|
|
1713
|
+
if (runId !== undefined && runId !== this.currentRunId) return false
|
|
1714
|
+
if (
|
|
1715
|
+
typeof chunk.threadId === 'string' &&
|
|
1716
|
+
chunk.threadId !== submission.threadId
|
|
1717
|
+
) {
|
|
1718
|
+
return false
|
|
1719
|
+
}
|
|
1720
|
+
return errors.every(
|
|
1721
|
+
(error) =>
|
|
1722
|
+
error.threadId === submission.threadId &&
|
|
1723
|
+
error.interruptedRunId === submission.interruptedRunId &&
|
|
1724
|
+
error.generation === submission.generation,
|
|
1725
|
+
)
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
private resolveJoinedRun(chunk: StreamChunk): void {
|
|
1729
|
+
if (chunk.type !== 'RUN_FINISHED' && chunk.type !== 'RUN_ERROR') return
|
|
1730
|
+
const runId = getChunkRunId(chunk)
|
|
1731
|
+
if (runId === undefined) return
|
|
1732
|
+
const resolve = this.joinedRunWaiters.get(runId)
|
|
1733
|
+
if (resolve === undefined) return
|
|
1734
|
+
this.joinedRunWaiters.delete(runId)
|
|
1735
|
+
resolve()
|
|
823
1736
|
}
|
|
824
1737
|
|
|
825
1738
|
/**
|
|
@@ -890,7 +1803,7 @@ export class ChatClient<
|
|
|
890
1803
|
* ],
|
|
891
1804
|
* id: 'custom-message-id'
|
|
892
1805
|
* },
|
|
893
|
-
* { model: 'gpt-
|
|
1806
|
+
* { model: 'gpt-5.5' }
|
|
894
1807
|
* )
|
|
895
1808
|
* ```
|
|
896
1809
|
*/
|
|
@@ -904,6 +1817,11 @@ export class ChatClient<
|
|
|
904
1817
|
if (emptyMessage) {
|
|
905
1818
|
return
|
|
906
1819
|
}
|
|
1820
|
+
if (this.hasBlockingInterrupts()) {
|
|
1821
|
+
throw new Error(
|
|
1822
|
+
'ChatClient: cannot send normal input while pending interrupts exist. Use resumeInterrupts() instead.',
|
|
1823
|
+
)
|
|
1824
|
+
}
|
|
907
1825
|
|
|
908
1826
|
if (this.isSendBusy()) {
|
|
909
1827
|
const { action, id } = this.decideWhenBusy(content, sendOptions)
|
|
@@ -934,6 +1852,30 @@ export class ChatClient<
|
|
|
934
1852
|
}
|
|
935
1853
|
}
|
|
936
1854
|
|
|
1855
|
+
/**
|
|
1856
|
+
* True when the client still has user-actionable interrupts (or is mid
|
|
1857
|
+
* resume submission). Staged/submitting items that are already being
|
|
1858
|
+
* continued do not block a later turn once the resume stream has cleared
|
|
1859
|
+
* resume state.
|
|
1860
|
+
*/
|
|
1861
|
+
private hasBlockingInterrupts(): boolean {
|
|
1862
|
+
if (!this.lastResume && !this.activeInterruptSubmission) {
|
|
1863
|
+
return false
|
|
1864
|
+
}
|
|
1865
|
+
if (this.activeInterruptSubmission) {
|
|
1866
|
+
return true
|
|
1867
|
+
}
|
|
1868
|
+
return this.interruptManager
|
|
1869
|
+
.getInterrupts()
|
|
1870
|
+
.some(
|
|
1871
|
+
(item) =>
|
|
1872
|
+
item.status === 'pending' ||
|
|
1873
|
+
item.status === 'validating' ||
|
|
1874
|
+
item.status === 'error' ||
|
|
1875
|
+
item.status === 'staged',
|
|
1876
|
+
)
|
|
1877
|
+
}
|
|
1878
|
+
|
|
937
1879
|
/** True while a stream is active, a send is claiming the client, or the queue is draining. */
|
|
938
1880
|
private isSendBusy(): boolean {
|
|
939
1881
|
return this.isLoading || this.sendInFlight || this.messageQueueDraining
|
|
@@ -1043,6 +1985,11 @@ export class ChatClient<
|
|
|
1043
1985
|
*/
|
|
1044
1986
|
async append(message: UIMessage | ModelMessage): Promise<void> {
|
|
1045
1987
|
this.mountDevtools()
|
|
1988
|
+
if (this.hasBlockingInterrupts()) {
|
|
1989
|
+
throw new Error(
|
|
1990
|
+
'ChatClient: cannot append normal input while pending interrupts exist. Use resumeInterrupts() instead.',
|
|
1991
|
+
)
|
|
1992
|
+
}
|
|
1046
1993
|
// Normalize the message to ensure it has id and createdAt
|
|
1047
1994
|
const normalizedMessage = normalizeToUIMessage(message, generateMessageId)
|
|
1048
1995
|
|
|
@@ -1085,8 +2032,18 @@ export class ChatClient<
|
|
|
1085
2032
|
|
|
1086
2033
|
// Track generation so a superseded stream's cleanup doesn't clobber the new one
|
|
1087
2034
|
const generation = ++this.streamGeneration
|
|
2035
|
+
// Native interrupt continuation is a fresh child run. The interrupted run
|
|
2036
|
+
// is carried as parentRunId and the complete resolution batch as resume.
|
|
2037
|
+
const resumeThreadId = this.pendingResumeThreadId
|
|
2038
|
+
const resumeParentRunId = this.pendingResumeParentRunId
|
|
2039
|
+
const resumeItems = this.pendingResumeItems
|
|
2040
|
+
this.pendingResumeThreadId = null
|
|
2041
|
+
this.pendingResumeParentRunId = null
|
|
2042
|
+
this.pendingResumeItems = null
|
|
1088
2043
|
const runId = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
1089
|
-
this.
|
|
2044
|
+
this.setCurrentRunId(runId)
|
|
2045
|
+
this.activeResumeThreadId = resumeThreadId ?? this.threadId
|
|
2046
|
+
this.activeResumeRunId = runId
|
|
1090
2047
|
|
|
1091
2048
|
this.setIsLoading(true)
|
|
1092
2049
|
// Hand off from deliverClaim to isLoading so nested drain can call
|
|
@@ -1170,8 +2127,11 @@ export class ChatClient<
|
|
|
1170
2127
|
// JSON Schema; sending a Standard Schema instance directly would
|
|
1171
2128
|
// serialize to an unusable shape.
|
|
1172
2129
|
const runContext = {
|
|
1173
|
-
threadId: this.threadId,
|
|
2130
|
+
threadId: resumeThreadId ?? this.threadId,
|
|
1174
2131
|
runId,
|
|
2132
|
+
...(resumeParentRunId !== null
|
|
2133
|
+
? { parentRunId: resumeParentRunId }
|
|
2134
|
+
: {}),
|
|
1175
2135
|
clientTools: Array.from(clientTools.values()).map((t) => ({
|
|
1176
2136
|
name: t.name,
|
|
1177
2137
|
description: t.description,
|
|
@@ -1180,8 +2140,9 @@ export class ChatClient<
|
|
|
1180
2140
|
: { type: 'object' },
|
|
1181
2141
|
})),
|
|
1182
2142
|
forwardedProps: { ...mergedBody },
|
|
2143
|
+
...(resumeItems ? { resume: resumeItems } : {}),
|
|
1183
2144
|
}
|
|
1184
|
-
this.devtoolsBridge.beginRun(runContext.runId,
|
|
2145
|
+
this.devtoolsBridge.beginRun(runContext.runId, runContext.threadId)
|
|
1185
2146
|
activeDevtoolsRunId = runContext.runId
|
|
1186
2147
|
this.devtoolsBridge.emitRunLifecycle(
|
|
1187
2148
|
'run:created',
|
|
@@ -1198,6 +2159,11 @@ export class ChatClient<
|
|
|
1198
2159
|
// Send through normalized connection (pushes chunks to subscription queue)
|
|
1199
2160
|
await this.connection.send(messages, mergedBody, signal, runContext)
|
|
1200
2161
|
|
|
2162
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- mutated asynchronously during await
|
|
2163
|
+
if (generation !== this.streamGeneration || signal.aborted) {
|
|
2164
|
+
return false
|
|
2165
|
+
}
|
|
2166
|
+
|
|
1201
2167
|
// Wait for subscription loop to finish processing all chunks
|
|
1202
2168
|
await processingComplete
|
|
1203
2169
|
|
|
@@ -1264,7 +2230,7 @@ export class ChatClient<
|
|
|
1264
2230
|
this.currentStreamId = null
|
|
1265
2231
|
this.devtoolsBridge.setCurrentStreamId(null)
|
|
1266
2232
|
this.currentMessageId = null
|
|
1267
|
-
this.
|
|
2233
|
+
this.setCurrentRunId(null)
|
|
1268
2234
|
this.activeClientTools = null
|
|
1269
2235
|
this.activeContext = undefined
|
|
1270
2236
|
this.abortController = null
|
|
@@ -1424,25 +2390,30 @@ export class ChatClient<
|
|
|
1424
2390
|
* Clear all messages
|
|
1425
2391
|
*/
|
|
1426
2392
|
clear(): void {
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
}
|
|
1439
|
-
// Suppress persisting the empty snapshot that clearMessages emits, then
|
|
1440
|
-
// remove the stored conversation outright.
|
|
1441
|
-
this.persistor.beginClear()
|
|
2393
|
+
const hadLocalStream = this.abortController !== null
|
|
2394
|
+
this.clearedStreamTracker.snapshotClear({
|
|
2395
|
+
messages: this.processor.getMessages(),
|
|
2396
|
+
activeRunIds: this.activeRunIds,
|
|
2397
|
+
currentRunId: this.currentRunId,
|
|
2398
|
+
})
|
|
2399
|
+
// Always cancel in-flight work so clear works without message persistence.
|
|
2400
|
+
if (this.isLoading || hadLocalStream) {
|
|
2401
|
+
this.cancelInFlightStream({ setReadyStatus: true })
|
|
2402
|
+
this.resetSessionGenerating({ preserveClearedStreamTracking: true })
|
|
2403
|
+
} else if (this.activeRunIds.size > 0) {
|
|
2404
|
+
this.resetSessionGenerating({ preserveClearedStreamTracking: true })
|
|
1442
2405
|
}
|
|
2406
|
+
// Suppress persisting the empty snapshot that clearMessages emits, then
|
|
2407
|
+
// remove the stored conversation outright.
|
|
2408
|
+
this.persistor?.beginClear()
|
|
1443
2409
|
this.processor.clearMessages()
|
|
1444
2410
|
this.discardPendingSends()
|
|
1445
2411
|
this.persistor?.remove()
|
|
2412
|
+
this.lastResume = null
|
|
2413
|
+
this.interruptManager.reset()
|
|
2414
|
+
this.pendingResumeThreadId = null
|
|
2415
|
+
this.pendingResumeParentRunId = null
|
|
2416
|
+
this.pendingResumeItems = null
|
|
1446
2417
|
this.setError(undefined)
|
|
1447
2418
|
this.events.messagesCleared()
|
|
1448
2419
|
}
|
|
@@ -1484,9 +2455,8 @@ export class ChatClient<
|
|
|
1484
2455
|
context,
|
|
1485
2456
|
)
|
|
1486
2457
|
|
|
1487
|
-
//
|
|
1488
|
-
//
|
|
1489
|
-
// state); a stray errorText on a successful result must not signal an error.
|
|
2458
|
+
// Always update local message state so the tool-call part is terminal in
|
|
2459
|
+
// the UI even when the AG-UI interrupt path owns server continuation.
|
|
1490
2460
|
this.processor.addToolResult(
|
|
1491
2461
|
result.toolCallId,
|
|
1492
2462
|
result.output,
|
|
@@ -1494,6 +2464,19 @@ export class ChatClient<
|
|
|
1494
2464
|
? result.errorText || 'Tool execution failed'
|
|
1495
2465
|
: undefined,
|
|
1496
2466
|
)
|
|
2467
|
+
this.devtoolsBridge.emitSnapshot()
|
|
2468
|
+
|
|
2469
|
+
const resolvedViaInterrupt = this.interruptManager.resolveClientToolOutput(
|
|
2470
|
+
result.toolCallId,
|
|
2471
|
+
result.state === 'output-error'
|
|
2472
|
+
? { error: result.errorText || 'Tool execution failed' }
|
|
2473
|
+
: result.output,
|
|
2474
|
+
)
|
|
2475
|
+
if (resolvedViaInterrupt) {
|
|
2476
|
+
// Interrupt manager stages/submits the resume batch (deferred until the
|
|
2477
|
+
// parent stream settles when still loading). Skip legacy continuation.
|
|
2478
|
+
return
|
|
2479
|
+
}
|
|
1497
2480
|
|
|
1498
2481
|
// If stream is in progress, queue continuation check for after it ends
|
|
1499
2482
|
if (this.isLoading) {
|
|
@@ -1522,6 +2505,21 @@ export class ChatClient<
|
|
|
1522
2505
|
id: string // approval.id, not toolCallId
|
|
1523
2506
|
approved: boolean
|
|
1524
2507
|
}): Promise<void> {
|
|
2508
|
+
// Reflect the decision on the tool-call part so approval UIs that render
|
|
2509
|
+
// from `part.state` (the deprecated pre-interrupt pattern) clear the prompt
|
|
2510
|
+
// and show the response. The bound interrupt resolution below drives the
|
|
2511
|
+
// actual continuation; this keeps the legacy message-state surface in sync.
|
|
2512
|
+
this.processor.addToolApprovalResponse(response.id, response.approved)
|
|
2513
|
+
this.devtoolsBridge.emitSnapshot()
|
|
2514
|
+
|
|
2515
|
+
if (
|
|
2516
|
+
this.interruptManager.resolveToolApprovalDecision(
|
|
2517
|
+
response.id,
|
|
2518
|
+
response.approved,
|
|
2519
|
+
)
|
|
2520
|
+
) {
|
|
2521
|
+
return
|
|
2522
|
+
}
|
|
1525
2523
|
// Find the tool call ID from the approval ID
|
|
1526
2524
|
const messages = this.processor.getMessages()
|
|
1527
2525
|
let foundToolCallId: string | undefined
|
|
@@ -1866,6 +2864,7 @@ export class ChatClient<
|
|
|
1866
2864
|
this.context = options.context
|
|
1867
2865
|
}
|
|
1868
2866
|
if (options.tools !== undefined) {
|
|
2867
|
+
this.interruptManager.updateTools(options.tools)
|
|
1869
2868
|
this.clientToolsRef.current = new Map()
|
|
1870
2869
|
for (const tool of options.tools) {
|
|
1871
2870
|
this.clientToolsRef.current.set(tool.name, tool)
|
|
@@ -1902,12 +2901,31 @@ export class ChatClient<
|
|
|
1902
2901
|
if (options.onQueueChange !== undefined) {
|
|
1903
2902
|
this.callbacksRef.current.onQueueChange = options.onQueueChange
|
|
1904
2903
|
}
|
|
2904
|
+
if (options.onResumeStateChange !== undefined) {
|
|
2905
|
+
this.callbacksRef.current.onResumeStateChange =
|
|
2906
|
+
options.onResumeStateChange
|
|
2907
|
+
}
|
|
2908
|
+
if (options.onRunIdChange !== undefined) {
|
|
2909
|
+
this.callbacksRef.current.onRunIdChange = options.onRunIdChange
|
|
2910
|
+
}
|
|
2911
|
+
if (options.onInterruptStateChange !== undefined) {
|
|
2912
|
+
this.callbacksRef.current.onInterruptStateChange =
|
|
2913
|
+
options.onInterruptStateChange
|
|
2914
|
+
}
|
|
1905
2915
|
if (options.onCustomEvent !== undefined) {
|
|
1906
2916
|
this.callbacksRef.current.onCustomEvent = options.onCustomEvent
|
|
1907
2917
|
}
|
|
1908
2918
|
}
|
|
1909
2919
|
|
|
1910
2920
|
dispose(): void {
|
|
2921
|
+
// FIRST, and latched: everything below is teardown, and an async callback that
|
|
2922
|
+
// lands mid-teardown must not start new work. In particular a hydration fetch
|
|
2923
|
+
// that resolves after this point must not open a tail — see `hydrateFromServer`.
|
|
2924
|
+
this.disposed = true
|
|
2925
|
+
// `unsubscribe()` below already aborts the in-flight stream (it calls
|
|
2926
|
+
// `cancelInFlightStream({ abortSubscription: true })`), so disposal does drop
|
|
2927
|
+
// an open tail. Verified by mutation: removing an extra abort here changes
|
|
2928
|
+
// nothing, because unsubscribe covers it.
|
|
1911
2929
|
this.unsubscribe()
|
|
1912
2930
|
this.devtoolsBridge.dispose()
|
|
1913
2931
|
this.devtoolsMounted = false
|