@tanstack/ai-client 0.23.3 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -1
- package/dist/esm/chat-client.d.ts +17 -13
- package/dist/esm/chat-client.js +49 -18
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/connection-adapters.d.ts +49 -0
- package/dist/esm/connection-adapters.js +269 -29
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/devtools.d.ts +0 -1
- package/dist/esm/devtools.js +4 -10
- package/dist/esm/devtools.js.map +1 -1
- package/dist/esm/generation-client.d.ts +5 -11
- package/dist/esm/generation-client.js +18 -5
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-types.d.ts +15 -45
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/index.d.ts +2 -2
- package/dist/esm/index.js +2 -2
- package/dist/esm/interrupt-manager.d.ts +15 -10
- package/dist/esm/interrupt-manager.js +176 -83
- package/dist/esm/interrupt-manager.js.map +1 -1
- package/dist/esm/types.d.ts +66 -48
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +5 -11
- package/dist/esm/video-generation-client.js +18 -5
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +3 -3
- package/src/chat-client.ts +148 -53
- package/src/connection-adapters.ts +505 -47
- package/src/devtools.ts +6 -22
- package/src/generation-client.ts +36 -24
- package/src/generation-types.ts +15 -47
- package/src/index.ts +6 -0
- package/src/interrupt-manager.ts +386 -145
- package/src/types.ts +133 -50
- package/src/video-generation-client.ts +33 -20
package/src/chat-client.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { InterruptManager } from './interrupt-manager'
|
|
|
18
18
|
import type {
|
|
19
19
|
AnyClientTool,
|
|
20
20
|
ContentPart,
|
|
21
|
+
InterruptDefinition,
|
|
21
22
|
InterruptSubmissionError,
|
|
22
23
|
ModelMessage,
|
|
23
24
|
RunAgentResumeItem,
|
|
@@ -42,8 +43,8 @@ import type {
|
|
|
42
43
|
ChatClientOptions,
|
|
43
44
|
ChatClientState,
|
|
44
45
|
ChatFetcher,
|
|
45
|
-
ChatInterrupt,
|
|
46
46
|
ChatInterruptState,
|
|
47
|
+
ResolvableChatInterrupt,
|
|
47
48
|
ChatPendingInterrupt,
|
|
48
49
|
ChatResumeSnapshot,
|
|
49
50
|
ChatResumeState,
|
|
@@ -59,15 +60,34 @@ import type {
|
|
|
59
60
|
UIMessage,
|
|
60
61
|
WhenBusy,
|
|
61
62
|
} from './types'
|
|
62
|
-
import type {
|
|
63
|
+
import type {
|
|
64
|
+
InterruptManagerChangeSource,
|
|
65
|
+
InterruptManagerSubmission,
|
|
66
|
+
} from './interrupt-manager'
|
|
63
67
|
|
|
64
68
|
/** Internal queue entry — public {@link QueuedMessage} plus optional per-send body. */
|
|
65
69
|
interface InternalQueuedMessage extends QueuedMessage {
|
|
66
70
|
body?: Record<string, any>
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
function assertUniqueInterruptDefinitions(
|
|
74
|
+
interrupts:
|
|
75
|
+
| ReadonlyArray<InterruptDefinition<any, any, any, any>>
|
|
76
|
+
| undefined,
|
|
77
|
+
): void {
|
|
78
|
+
const ids = new Set<string>()
|
|
79
|
+
for (const interrupt of interrupts ?? []) {
|
|
80
|
+
if (ids.has(interrupt.id)) {
|
|
81
|
+
throw new Error(`Duplicate interrupt definition id: ${interrupt.id}`)
|
|
82
|
+
}
|
|
83
|
+
ids.add(interrupt.id)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
69
87
|
type ChatClientUpdateOptionsWithoutContext<
|
|
70
88
|
TTools extends ReadonlyArray<AnyClientTool>,
|
|
89
|
+
TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
|
|
90
|
+
readonly [],
|
|
71
91
|
> = {
|
|
72
92
|
connection?: ConnectionAdapter
|
|
73
93
|
fetcher?: ChatFetcher
|
|
@@ -75,6 +95,7 @@ type ChatClientUpdateOptionsWithoutContext<
|
|
|
75
95
|
body?: Record<string, any>
|
|
76
96
|
forwardedProps?: Record<string, any>
|
|
77
97
|
tools?: TTools
|
|
98
|
+
interrupts?: TInterrupts
|
|
78
99
|
queue?: QueueOption
|
|
79
100
|
onResponse?: (response?: Response) => void | Promise<void>
|
|
80
101
|
onChunk?: (chunk: StreamChunk) => void
|
|
@@ -86,14 +107,17 @@ type ChatClientUpdateOptionsWithoutContext<
|
|
|
86
107
|
onQueueChange?: (queue: Array<QueuedMessage>) => void
|
|
87
108
|
onResumeStateChange?: (
|
|
88
109
|
resumeState: ChatResumeState | null,
|
|
89
|
-
pendingInterrupts: BoundInterrupts<TTools>,
|
|
110
|
+
pendingInterrupts: BoundInterrupts<TTools, TInterrupts>,
|
|
90
111
|
) => void
|
|
91
112
|
/**
|
|
92
113
|
* Fires whenever the id of the run in flight changes: the new id when a run
|
|
93
114
|
* starts (including a rejoin), `null` when it settles.
|
|
94
115
|
*/
|
|
95
116
|
onRunIdChange?: (runId: string | null) => void
|
|
96
|
-
onInterruptStateChange?: (
|
|
117
|
+
onInterruptStateChange?: (
|
|
118
|
+
state: ChatInterruptState<TTools, TInterrupts>,
|
|
119
|
+
context: { source: 'hydrate' | 'live' },
|
|
120
|
+
) => void
|
|
97
121
|
onCustomEvent?: (
|
|
98
122
|
eventType: string,
|
|
99
123
|
data: unknown,
|
|
@@ -271,11 +295,13 @@ const REJOIN_REBUILD_TRIGGERS = new Set<string>([
|
|
|
271
295
|
export class ChatClient<
|
|
272
296
|
TTools extends ReadonlyArray<AnyClientTool> = any,
|
|
273
297
|
TContext = unknown,
|
|
298
|
+
TInterrupts extends ReadonlyArray<InterruptDefinition<any, any, any, any>> =
|
|
299
|
+
any,
|
|
274
300
|
> {
|
|
275
301
|
private readonly processor: StreamProcessor
|
|
276
302
|
private connection: SubscribeConnectionAdapter
|
|
277
|
-
private
|
|
278
|
-
private
|
|
303
|
+
private uniqueId: string
|
|
304
|
+
private threadId: string
|
|
279
305
|
// Durable chat persistence (optional): messages + resume snapshot as one
|
|
280
306
|
// combined record, so a full page reload restores the transcript, rehydrates
|
|
281
307
|
// pending interrupts, and rejoins an in-flight run. Clear-during-stream
|
|
@@ -292,7 +318,7 @@ export class ChatClient<
|
|
|
292
318
|
// run is rejoined at most once even when both the sync read and the async
|
|
293
319
|
// hydrate surface the same resume pointer.
|
|
294
320
|
private rejoinedRunId: string | null = null
|
|
295
|
-
private readonly interruptManager: InterruptManager<TTools>
|
|
321
|
+
private readonly interruptManager: InterruptManager<TTools, TInterrupts>
|
|
296
322
|
private activeInterruptSubmission: InterruptManagerSubmission | undefined
|
|
297
323
|
private interruptSubmissionFailure:
|
|
298
324
|
| { errors: ReadonlyArray<InterruptSubmissionError> }
|
|
@@ -397,10 +423,13 @@ export class ChatClient<
|
|
|
397
423
|
onQueueChange: (queue: Array<QueuedMessage>) => void
|
|
398
424
|
onResumeStateChange: (
|
|
399
425
|
resumeState: ChatResumeState | null,
|
|
400
|
-
pendingInterrupts: BoundInterrupts<TTools>,
|
|
426
|
+
pendingInterrupts: BoundInterrupts<TTools, TInterrupts>,
|
|
401
427
|
) => void
|
|
402
428
|
onRunIdChange: (runId: string | null) => void
|
|
403
|
-
onInterruptStateChange: (
|
|
429
|
+
onInterruptStateChange: (
|
|
430
|
+
state: ChatInterruptState<TTools, TInterrupts>,
|
|
431
|
+
context: { source: 'hydrate' | 'live' },
|
|
432
|
+
) => void
|
|
404
433
|
onCustomEvent: (
|
|
405
434
|
eventType: string,
|
|
406
435
|
data: unknown,
|
|
@@ -409,13 +438,14 @@ export class ChatClient<
|
|
|
409
438
|
}
|
|
410
439
|
}
|
|
411
440
|
|
|
412
|
-
constructor(options: ChatClientOptions<TTools, TContext>) {
|
|
413
|
-
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
this.
|
|
441
|
+
constructor(options: ChatClientOptions<TTools, TContext, TInterrupts>) {
|
|
442
|
+
assertUniqueInterruptDefinitions(options.interrupts)
|
|
443
|
+
// Do not mint a random thread id during construct. Framework hooks build
|
|
444
|
+
// this client during render (SSR included). The wire/devtools identity is
|
|
445
|
+
// `threadId`; it is assigned here when the caller passed one, or later in
|
|
446
|
+
// `ensureThreadId()` from attach / mount / send.
|
|
447
|
+
this.threadId = options.threadId || ''
|
|
448
|
+
this.uniqueId = this.threadId
|
|
419
449
|
// `persistence` is `false`/omitted (ephemeral, in-memory), `true`
|
|
420
450
|
// (server-authoritative: cache nothing client-side, hydrate the thread from
|
|
421
451
|
// the server by `threadId` on mount), or a storage adapter
|
|
@@ -424,17 +454,24 @@ export class ChatClient<
|
|
|
424
454
|
// the mount hydration and keeps a client record from shadowing server history.
|
|
425
455
|
let cachesMessages = true
|
|
426
456
|
if (options.persistence === true) {
|
|
457
|
+
if (!options.threadId) {
|
|
458
|
+
throw new Error(
|
|
459
|
+
'[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).',
|
|
460
|
+
)
|
|
461
|
+
}
|
|
427
462
|
cachesMessages = false
|
|
428
463
|
} else if (options.persistence) {
|
|
429
464
|
// A storage adapter: keep the combined record (transcript + resume pointer)
|
|
430
465
|
// in the browser. Persistence keys on `threadId` (the conversation
|
|
431
|
-
// identity) so a reload with the same `threadId` finds the same record
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
466
|
+
// identity) so a reload with the same `threadId` finds the same record.
|
|
467
|
+
if (!options.threadId) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
'[TanStack AI] persistence needs a stable `threadId` to key on. Pass a threadId from your app (for example support-42).',
|
|
470
|
+
)
|
|
471
|
+
}
|
|
435
472
|
this.persistor = new ChatPersistor(
|
|
436
473
|
options.persistence,
|
|
437
|
-
|
|
474
|
+
options.threadId,
|
|
438
475
|
(messages) => this.processor.setMessages(messages),
|
|
439
476
|
(snapshot) => this.applyPersistedResume(snapshot),
|
|
440
477
|
)
|
|
@@ -486,10 +523,13 @@ export class ChatClient<
|
|
|
486
523
|
},
|
|
487
524
|
}
|
|
488
525
|
|
|
489
|
-
this.interruptManager = new InterruptManager({
|
|
526
|
+
this.interruptManager = new InterruptManager<TTools, TInterrupts>({
|
|
490
527
|
...(options.tools !== undefined ? { tools: options.tools } : {}),
|
|
528
|
+
...(options.interrupts !== undefined
|
|
529
|
+
? { interrupts: options.interrupts }
|
|
530
|
+
: {}),
|
|
491
531
|
submit: (submission) => this.submitInterruptBatch(submission),
|
|
492
|
-
onChange: () => this.notifyResumeStateChange(),
|
|
532
|
+
onChange: (source) => this.notifyResumeStateChange(source),
|
|
493
533
|
})
|
|
494
534
|
|
|
495
535
|
// In-memory rehydrate of interrupt descriptors (e.g. after a page reload
|
|
@@ -798,6 +838,7 @@ export class ChatClient<
|
|
|
798
838
|
*/
|
|
799
839
|
attach(): void {
|
|
800
840
|
if (this.disposed || this.tailing) return
|
|
841
|
+
this.ensureThreadId()
|
|
801
842
|
this.tailing = true
|
|
802
843
|
|
|
803
844
|
// Full page reload with an in-flight run persisted (synchronous store):
|
|
@@ -848,7 +889,7 @@ export class ChatClient<
|
|
|
848
889
|
private applyResumeSnapshot(snapshot: ChatResumeSnapshot): void {
|
|
849
890
|
const resumeState = readResumeState(snapshot)
|
|
850
891
|
if (resumeState === undefined) {
|
|
851
|
-
this.interruptManager.reset()
|
|
892
|
+
this.interruptManager.reset({ source: 'hydrate' })
|
|
852
893
|
return
|
|
853
894
|
}
|
|
854
895
|
this.lastResume = resumeState
|
|
@@ -856,16 +897,19 @@ export class ChatClient<
|
|
|
856
897
|
? snapshot.pendingInterrupts
|
|
857
898
|
: []
|
|
858
899
|
if (pendingInterrupts.length === 0) {
|
|
859
|
-
this.interruptManager.reset()
|
|
900
|
+
this.interruptManager.reset({ source: 'hydrate' })
|
|
860
901
|
return
|
|
861
902
|
}
|
|
862
903
|
const generation = this.interruptGeneration(pendingInterrupts)
|
|
863
|
-
this.interruptManager.hydrate(
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
904
|
+
this.interruptManager.hydrate(
|
|
905
|
+
{
|
|
906
|
+
threadId: resumeState.threadId,
|
|
907
|
+
interruptedRunId: resumeState.runId,
|
|
908
|
+
generation,
|
|
909
|
+
interrupts: pendingInterrupts,
|
|
910
|
+
},
|
|
911
|
+
'hydrate',
|
|
912
|
+
)
|
|
869
913
|
}
|
|
870
914
|
|
|
871
915
|
/**
|
|
@@ -973,6 +1017,7 @@ export class ChatClient<
|
|
|
973
1017
|
}
|
|
974
1018
|
|
|
975
1019
|
mountDevtools(): void {
|
|
1020
|
+
this.ensureThreadId()
|
|
976
1021
|
if (this.devtoolsMounted) {
|
|
977
1022
|
return
|
|
978
1023
|
}
|
|
@@ -981,6 +1026,14 @@ export class ChatClient<
|
|
|
981
1026
|
this.devtoolsBridge.mountWithTools(this.processor.getMessages().length)
|
|
982
1027
|
}
|
|
983
1028
|
|
|
1029
|
+
private ensureThreadId(): string {
|
|
1030
|
+
if (!this.threadId) {
|
|
1031
|
+
this.threadId = this.generateUniqueId('thread')
|
|
1032
|
+
}
|
|
1033
|
+
this.uniqueId = this.threadId
|
|
1034
|
+
return this.threadId
|
|
1035
|
+
}
|
|
1036
|
+
|
|
984
1037
|
/**
|
|
985
1038
|
* Drain a runId-less RUN_ERROR that belongs to a cleared run the client is
|
|
986
1039
|
* still tracking. The persistor owns the cleared-run bookkeeping; the client
|
|
@@ -1085,12 +1138,15 @@ export class ChatClient<
|
|
|
1085
1138
|
threadId: threadId ?? this.threadId,
|
|
1086
1139
|
runId: interruptedRunId,
|
|
1087
1140
|
}
|
|
1088
|
-
this.interruptManager.hydrate(
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1141
|
+
this.interruptManager.hydrate(
|
|
1142
|
+
{
|
|
1143
|
+
threadId: this.lastResume.threadId,
|
|
1144
|
+
interruptedRunId,
|
|
1145
|
+
generation: this.interruptGeneration(chunk.outcome.interrupts),
|
|
1146
|
+
interrupts: chunk.outcome.interrupts,
|
|
1147
|
+
},
|
|
1148
|
+
'live',
|
|
1149
|
+
)
|
|
1094
1150
|
return
|
|
1095
1151
|
}
|
|
1096
1152
|
|
|
@@ -1137,7 +1193,7 @@ export class ChatClient<
|
|
|
1137
1193
|
this.interruptManager.reset()
|
|
1138
1194
|
return
|
|
1139
1195
|
}
|
|
1140
|
-
this.notifyResumeStateChange()
|
|
1196
|
+
this.notifyResumeStateChange('live')
|
|
1141
1197
|
}
|
|
1142
1198
|
|
|
1143
1199
|
/**
|
|
@@ -1166,25 +1222,37 @@ export class ChatClient<
|
|
|
1166
1222
|
this.callbacksRef.current.onRunIdChange(runId)
|
|
1167
1223
|
}
|
|
1168
1224
|
|
|
1169
|
-
getInterruptState(): ChatInterruptState<TTools> {
|
|
1225
|
+
getInterruptState(): ChatInterruptState<TTools, TInterrupts> {
|
|
1170
1226
|
return this.interruptManager.getState()
|
|
1171
1227
|
}
|
|
1172
1228
|
|
|
1173
|
-
getInterrupts(): BoundInterrupts<TTools> {
|
|
1174
|
-
return this.interruptManager.getInterrupts()
|
|
1229
|
+
getInterrupts(): BoundInterrupts<TTools, TInterrupts> {
|
|
1230
|
+
return this.interruptManager.getInterrupts() as BoundInterrupts<
|
|
1231
|
+
TTools,
|
|
1232
|
+
TInterrupts
|
|
1233
|
+
>
|
|
1175
1234
|
}
|
|
1176
1235
|
|
|
1177
1236
|
/** @deprecated Use getInterrupts(). */
|
|
1178
|
-
getPendingInterrupts(): BoundInterrupts<TTools> {
|
|
1179
|
-
return this.interruptManager.getInterrupts()
|
|
1237
|
+
getPendingInterrupts(): BoundInterrupts<TTools, TInterrupts> {
|
|
1238
|
+
return this.interruptManager.getInterrupts() as BoundInterrupts<
|
|
1239
|
+
TTools,
|
|
1240
|
+
TInterrupts
|
|
1241
|
+
>
|
|
1180
1242
|
}
|
|
1181
1243
|
|
|
1182
1244
|
resolveInterrupts(approved: boolean): void
|
|
1183
1245
|
resolveInterrupts(
|
|
1184
|
-
resolver: (
|
|
1246
|
+
resolver: (
|
|
1247
|
+
interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
|
|
1248
|
+
) => undefined,
|
|
1185
1249
|
): void
|
|
1186
1250
|
resolveInterrupts(
|
|
1187
|
-
resolution:
|
|
1251
|
+
resolution:
|
|
1252
|
+
| boolean
|
|
1253
|
+
| ((
|
|
1254
|
+
interrupt: ResolvableChatInterrupt<TTools, TInterrupts>,
|
|
1255
|
+
) => undefined),
|
|
1188
1256
|
): void {
|
|
1189
1257
|
// Branch so TypeScript can select the InterruptManager.resolve overloads.
|
|
1190
1258
|
if (typeof resolution === 'boolean') {
|
|
@@ -1345,19 +1413,20 @@ export class ChatClient<
|
|
|
1345
1413
|
this.devtoolsBridge.emitSnapshot()
|
|
1346
1414
|
}
|
|
1347
1415
|
|
|
1348
|
-
private notifyResumeStateChange(): void {
|
|
1416
|
+
private notifyResumeStateChange(source: InterruptManagerChangeSource): void {
|
|
1349
1417
|
const resumeState = this.getResumeState()
|
|
1418
|
+
// Capture state before invoking callbacks so a synchronous nested change
|
|
1419
|
+
// cannot pair this publication's source with a later manager snapshot.
|
|
1420
|
+
const interruptState = this.interruptManager.getState()
|
|
1350
1421
|
// Persist (or clear) the durable resume snapshot so a full page reload can
|
|
1351
1422
|
// rehydrate pending interrupts and rejoin the run. Folded into the same
|
|
1352
1423
|
// persistence adapter that stores messages (one record per chat).
|
|
1353
1424
|
this.persistResumeSnapshot(resumeState)
|
|
1354
1425
|
this.callbacksRef.current.onResumeStateChange(
|
|
1355
1426
|
resumeState,
|
|
1356
|
-
|
|
1357
|
-
)
|
|
1358
|
-
this.callbacksRef.current.onInterruptStateChange(
|
|
1359
|
-
this.interruptManager.getState(),
|
|
1427
|
+
interruptState.interrupts,
|
|
1360
1428
|
)
|
|
1429
|
+
this.callbacksRef.current.onInterruptStateChange(interruptState, { source })
|
|
1361
1430
|
}
|
|
1362
1431
|
|
|
1363
1432
|
/**
|
|
@@ -1398,10 +1467,17 @@ export class ChatClient<
|
|
|
1398
1467
|
private buildDevtoolsBridgeOptions(
|
|
1399
1468
|
devtools: ChatClientOptions['devtools'],
|
|
1400
1469
|
): ChatDevtoolsBridgeOptions {
|
|
1470
|
+
const client = this
|
|
1401
1471
|
return {
|
|
1402
|
-
hookId
|
|
1403
|
-
|
|
1404
|
-
|
|
1472
|
+
get hookId() {
|
|
1473
|
+
return client.uniqueId
|
|
1474
|
+
},
|
|
1475
|
+
get clientId() {
|
|
1476
|
+
return client.uniqueId
|
|
1477
|
+
},
|
|
1478
|
+
get threadId() {
|
|
1479
|
+
return client.threadId
|
|
1480
|
+
},
|
|
1405
1481
|
metadata: {
|
|
1406
1482
|
hookName: devtools?.hookName ?? 'useChat',
|
|
1407
1483
|
outputKind: devtools?.outputKind ?? 'chat',
|
|
@@ -1600,6 +1676,12 @@ export class ChatClient<
|
|
|
1600
1676
|
}
|
|
1601
1677
|
await this.processIncomingChunk(chunk, { defer: false })
|
|
1602
1678
|
}
|
|
1679
|
+
// Same contract as `streamResponse`: client tools may finish (and
|
|
1680
|
+
// queue a resume) while `isLoading` is still true. Wait for them
|
|
1681
|
+
// before teardown so `drainPostStreamActions` below sees the queue.
|
|
1682
|
+
if (this.pendingToolExecutions.size > 0) {
|
|
1683
|
+
await Promise.all(this.pendingToolExecutions.values())
|
|
1684
|
+
}
|
|
1603
1685
|
} catch (error) {
|
|
1604
1686
|
// Pre-attach failures (unknown/evicted run, connect deadline abort)
|
|
1605
1687
|
// stay soft: keep the restored transcript. Post-attach transport/parser
|
|
@@ -1641,6 +1723,7 @@ export class ChatClient<
|
|
|
1641
1723
|
this.abortController = null
|
|
1642
1724
|
this.setIsLoading(false)
|
|
1643
1725
|
if (this.status === 'streaming') this.setStatus('ready')
|
|
1726
|
+
await this.drainPostStreamActions()
|
|
1644
1727
|
}
|
|
1645
1728
|
}
|
|
1646
1729
|
})()
|
|
@@ -2607,6 +2690,18 @@ export class ChatClient<
|
|
|
2607
2690
|
* a text-only response has nothing to auto-send.
|
|
2608
2691
|
*/
|
|
2609
2692
|
private shouldAutoSend(): boolean {
|
|
2693
|
+
// A pending interrupt owns the next send. Auto-continuing after a
|
|
2694
|
+
// completed server tool would start a sibling run and hide the card.
|
|
2695
|
+
if (this.lastResume) return false
|
|
2696
|
+
// Ownership follows the descriptors, not the submission handle. Generic
|
|
2697
|
+
// interrupts settle the resume stream through a post-stream action that
|
|
2698
|
+
// runs before `submitInterruptBatch`'s `finally` clears the handle, so
|
|
2699
|
+
// gating on the handle alone would strand a legacy client tool that the
|
|
2700
|
+
// native resume itself emitted (#1106).
|
|
2701
|
+
if (this.activeInterruptSubmission && this.hasPendingInterrupts()) {
|
|
2702
|
+
return false
|
|
2703
|
+
}
|
|
2704
|
+
if (this.interruptManager.getInterrupts().length > 0) return false
|
|
2610
2705
|
const messages = this.processor.getMessages()
|
|
2611
2706
|
const lastAssistant = messages.findLast(
|
|
2612
2707
|
(m: UIMessage) => m.role === 'assistant',
|