@tanstack/ai-client 0.32.0 → 0.32.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.
@@ -14,6 +14,7 @@ import {
14
14
  ByokMissingError,
15
15
  ByokUnresolvedProviderError,
16
16
  } from '@tanstack/ai/byok'
17
+ import { byokFallbackProviderId } from './byok/client'
17
18
  import {
18
19
  prepareResolvedByokHeaders,
19
20
  resolveByokProviderId,
@@ -331,6 +332,9 @@ const REJOIN_REBUILD_TRIGGERS = new Set<string>([
331
332
  'MESSAGES_SNAPSHOT',
332
333
  ])
333
334
 
335
+ // Cap parent-chain walks so a cyclic replayed parentRunId cannot loop.
336
+ const MAX_RUN_LINEAGE_DEPTH = 64
337
+
334
338
  export class ChatClient<
335
339
  TTools extends ReadonlyArray<AnyClientTool> = any,
336
340
  TContext = unknown,
@@ -353,6 +357,9 @@ export class ChatClient<
353
357
  // run, so approvals/client-tool results can be sent back. Cleared when the
354
358
  // run terminates. This is STATE (interrupt) resume, not delivery/cursor.
355
359
  private lastResume: ChatResumeState | null = null
360
+ // Replay can deliver a parent pause after a descendant already finished.
361
+ private readonly runParents = new Map<string, string>()
362
+ private readonly supersededInterruptRunIds = new Set<string>()
356
363
  // The in-flight run id already handed to `resumeInFlightRun`, so a persisted
357
364
  // run is rejoined at most once even when both the sync read and the async
358
365
  // hydrate surface the same resume pointer.
@@ -1157,14 +1164,30 @@ export class ChatClient<
1157
1164
  ): void {
1158
1165
  if (chunk.type === 'RUN_STARTED') {
1159
1166
  const chunkRunId = getChunkRunId(chunk) ?? chunk.runId
1167
+ const parentRunId =
1168
+ 'parentRunId' in chunk && typeof chunk.parentRunId === 'string'
1169
+ ? chunk.parentRunId
1170
+ : undefined
1171
+ if (parentRunId) {
1172
+ this.runParents.set(chunkRunId, parentRunId)
1173
+ if (this.supersededInterruptRunIds.has(chunkRunId)) {
1174
+ this.markLineageAnswered(parentRunId)
1175
+ }
1176
+ }
1160
1177
  this.activeResumeThreadId =
1161
1178
  'threadId' in chunk && typeof chunk.threadId === 'string'
1162
1179
  ? chunk.threadId
1163
1180
  : this.activeResumeThreadId
1164
1181
  this.activeResumeRunId = chunkRunId
1165
- this.activeRunIds.add(chunkRunId)
1166
- this.clearedStreamTracker.onRunStarted(chunkRunId)
1167
- this.setSessionGenerating(true)
1182
+ // Late RUN_STARTED for a run that already finished is replay, not a live
1183
+ // turn. A new send can reuse a finished run id while isLoading is true.
1184
+ const isAnsweredReplayStart =
1185
+ this.supersededInterruptRunIds.has(chunkRunId) && !this.isLoading
1186
+ if (!isAnsweredReplayStart) {
1187
+ this.activeRunIds.add(chunkRunId)
1188
+ this.clearedStreamTracker.onRunStarted(chunkRunId)
1189
+ this.setSessionGenerating(true)
1190
+ }
1168
1191
  // Persist a live-run resume snapshot so a full page reload can rejoin this
1169
1192
  // in-flight run via joinRun. Only a persistor writes it, and a persistor
1170
1193
  // exists only in client-authoritative mode; server-authoritative reconnect
@@ -1172,12 +1195,25 @@ export class ChatClient<
1172
1195
  // client-cached run pointer (which goes stale the moment a turn spans a
1173
1196
  // second run) is ever written. Interrupt/terminal handling overwrites or
1174
1197
  // clears it in observeInterruptState.
1175
- if (this.persistor && this.connection.joinRun && !this.lastResume) {
1198
+ if (
1199
+ this.persistor &&
1200
+ this.connection.joinRun &&
1201
+ !this.lastResume &&
1202
+ !isAnsweredReplayStart
1203
+ ) {
1176
1204
  this.persistResumeSnapshot({
1177
1205
  threadId: this.activeResumeThreadId ?? this.threadId,
1178
1206
  runId: chunkRunId,
1179
1207
  })
1180
1208
  }
1209
+ if (
1210
+ this.lastResume &&
1211
+ this.supersededInterruptRunIds.has(this.lastResume.runId)
1212
+ ) {
1213
+ this.lastResume = null
1214
+ this.persistor?.persistResumeSnapshot(null)
1215
+ this.interruptManager.reset()
1216
+ }
1181
1217
  return
1182
1218
  }
1183
1219
 
@@ -1222,11 +1258,29 @@ export class ChatClient<
1222
1258
  ? chunk.threadId
1223
1259
  : this.activeResumeThreadId
1224
1260
 
1261
+ // Record answered lineage before hydrating a pause. Intermediate
1262
+ // tool_calls RUN_FINISHED is a mid-turn handoff, not an answered pause.
1263
+ if (
1264
+ runId &&
1265
+ !isIntermediateToolTurn(chunk) &&
1266
+ (chunk.type === 'RUN_ERROR' ||
1267
+ (chunk.type === 'RUN_FINISHED' && chunk.outcome?.type !== 'interrupt'))
1268
+ ) {
1269
+ this.markLineageAnswered(runId)
1270
+ }
1271
+
1225
1272
  if (chunk.type === 'RUN_FINISHED' && chunk.outcome?.type === 'interrupt') {
1226
1273
  // Track the REQUEST run id (what the client sent) so a resume targets the
1227
1274
  // same run even when provider events carry their own run id.
1228
1275
  const interruptedRunId =
1229
1276
  this.currentRunId ?? runId ?? this.activeResumeRunId ?? ''
1277
+ // Replay re-emits a pause that a continuation already answered.
1278
+ if (
1279
+ interruptedRunId &&
1280
+ this.supersededInterruptRunIds.has(interruptedRunId)
1281
+ ) {
1282
+ return
1283
+ }
1230
1284
  this.lastResume = {
1231
1285
  threadId: threadId ?? this.threadId,
1232
1286
  runId: interruptedRunId,
@@ -1271,13 +1325,20 @@ export class ChatClient<
1271
1325
  chunk.type === 'RUN_FINISHED' &&
1272
1326
  chunk.outcome?.type !== 'interrupt',
1273
1327
  )
1328
+ // Replay can deliver a parent pause after a descendant already finished,
1329
+ // and this terminal's run id may not match lastResume.
1330
+ const isLineageDescendantTerminal = Boolean(
1331
+ this.lastResume &&
1332
+ this.supersededInterruptRunIds.has(this.lastResume.runId),
1333
+ )
1274
1334
  if (
1275
1335
  isRunlessSessionError ||
1276
1336
  isTrackedRunTerminal ||
1277
1337
  isCurrentRunTerminal ||
1278
1338
  isActiveStreamRunTerminal ||
1279
1339
  isCurrentStreamTerminal ||
1280
- isActiveInterruptSubmissionTerminal
1340
+ isActiveInterruptSubmissionTerminal ||
1341
+ isLineageDescendantTerminal
1281
1342
  ) {
1282
1343
  this.lastResume = null
1283
1344
  // Run settled without an interrupt: drop the durable resume snapshot so a
@@ -1289,6 +1350,18 @@ export class ChatClient<
1289
1350
  this.notifyResumeStateChange('live')
1290
1351
  }
1291
1352
 
1353
+ /** Walk parentRunId links and mark this run plus its ancestors as answered. */
1354
+ private markLineageAnswered(runId: string): void {
1355
+ let current: string | undefined = runId
1356
+ let hops = 0
1357
+ while (current !== undefined && hops < MAX_RUN_LINEAGE_DEPTH) {
1358
+ if (this.supersededInterruptRunIds.has(current)) return
1359
+ this.supersededInterruptRunIds.add(current)
1360
+ current = this.runParents.get(current)
1361
+ hops++
1362
+ }
1363
+ }
1364
+
1292
1365
  /**
1293
1366
  * The interrupt-resume state for the active/interrupted run (its run/thread
1294
1367
  * ids), or null when there is nothing to resume. Apps can persist this to
@@ -2350,6 +2423,7 @@ export class ChatClient<
2350
2423
  const provider = resolveByokProviderId(
2351
2424
  this.byokProvider,
2352
2425
  mergedBody.provider,
2426
+ byokFallbackProviderId(this.byok),
2353
2427
  )
2354
2428
  byokHeaders = await prepareResolvedByokHeaders(this.byok, provider)
2355
2429
  }
@@ -1,4 +1,5 @@
1
1
  import { ByokBlockedError, ByokMissingError } from '@tanstack/ai/byok'
2
+ import { byokFallbackProviderId } from './byok/client'
2
3
  import {
3
4
  prepareResolvedByokHeaders,
4
5
  resolveByokProviderId,
@@ -280,6 +281,7 @@ export class GenerationClient<
280
281
  const provider = resolveByokProviderId(
281
282
  this.byokProvider,
282
283
  this.body.provider,
284
+ byokFallbackProviderId(this.byok),
283
285
  )
284
286
  headers = await prepareResolvedByokHeaders(this.byok, provider)
285
287
  }
@@ -1,4 +1,5 @@
1
1
  import { ByokBlockedError, ByokMissingError } from '@tanstack/ai/byok'
2
+ import { byokFallbackProviderId } from './byok/client'
2
3
  import {
3
4
  prepareResolvedByokHeaders,
4
5
  resolveByokProviderId,
@@ -273,6 +274,7 @@ export class VideoGenerationClient<TOutput = VideoGenerateResult> {
273
274
  const provider = resolveByokProviderId(
274
275
  this.byokProvider,
275
276
  this.body.provider,
277
+ byokFallbackProviderId(this.byok),
276
278
  )
277
279
  headers = await prepareResolvedByokHeaders(this.byok, provider)
278
280
  }