@truefoundry/assistant-ui-runtime 0.1.0-rc.1 → 0.1.2

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.
Files changed (33) hide show
  1. package/README.md +45 -77
  2. package/dist/index.d.ts +14 -2
  3. package/dist/index.js +697 -147
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/agentSessionModule.d.ts +14 -2
  7. package/src/convertTurnMessages.test.ts +502 -1
  8. package/src/convertTurnMessages.ts +471 -28
  9. package/src/createSubAgent.ts +13 -5
  10. package/src/draftAgentConfig.test.ts +1 -1
  11. package/src/foldPeerThreads.test.ts +56 -0
  12. package/src/foldPeerThreads.ts +7 -1
  13. package/src/hooks.ts +24 -0
  14. package/src/index.ts +7 -5
  15. package/src/loadSessionSnapshot.test.ts +21 -6
  16. package/src/loadSessionSnapshot.ts +9 -5
  17. package/src/{bindDraftAgentSession.test.ts → private/bindDraftAgentSession.test.ts} +3 -2
  18. package/src/{draftSessionBridge.ts → private/draftSessionBridge.ts} +8 -2
  19. package/src/{truefoundryDraftThreadListAdapter.ts → private/truefoundryDraftThreadListAdapter.ts} +1 -1
  20. package/src/private/useDraftAgentSpec.test.tsx +153 -0
  21. package/src/{useDraftAgentSpec.ts → private/useDraftAgentSpec.ts} +80 -3
  22. package/src/sessionSnapshot.ts +48 -2
  23. package/src/sessions.ts +1 -1
  24. package/src/streamTurn.test.ts +34 -0
  25. package/src/streamTurn.ts +9 -1
  26. package/src/truefoundryExtras.ts +5 -1
  27. package/src/types.ts +1 -1
  28. package/src/useTrueFoundryAgentMessages.test.tsx +275 -1
  29. package/src/useTrueFoundryAgentMessages.ts +263 -70
  30. package/src/useTrueFoundryAgentRuntime.ts +51 -13
  31. /package/src/{agentSpec.ts → private/agentSpec.ts} +0 -0
  32. /package/src/{bindDraftAgentSession.ts → private/bindDraftAgentSession.ts} +0 -0
  33. /package/src/{truefoundryDraftThreadListAdapter.test.ts → private/truefoundryDraftThreadListAdapter.test.ts} +0 -0
@@ -16,11 +16,12 @@ import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
16
16
  import { ROOT_THREAD_ID } from "./constants.js";
17
17
  import {
18
18
  buildEditedUserMessageContent,
19
- buildSnapshotBeforeTurnIndex,
19
+ buildSnapshotBeforeTurn,
20
20
  computeGroupRootBaseline,
21
21
  extractTurnUserMessageContent,
22
+ prependOlderSessionHistory,
22
23
  projectSessionMessages,
23
- resolveGatewayBranchPreviousTurnId,
24
+ resolveGatewayBranchPreviousTurnIdForTurn,
24
25
  rootModelMessageIdsSinceBaseline,
25
26
  userMessageContentToText,
26
27
  type UserMessageContent,
@@ -57,6 +58,8 @@ import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
57
58
  export type UseTrueFoundryAgentMessagesOptions = {
58
59
  client: AgentSessionClient;
59
60
  sessionId: string | undefined;
61
+ /** When true the thread is the currently selected (main) thread. */
62
+ isMain?: boolean | undefined;
60
63
  listEventsConcurrency?: number | undefined;
61
64
  onError?: ((error: unknown) => void) | undefined;
62
65
  initializeSession?: () => Promise<{
@@ -67,10 +70,24 @@ export type UseTrueFoundryAgentMessagesOptions = {
67
70
  resolveConversationSessionId?: (remoteId: string) => Promise<string>;
68
71
  /** When set, turns bind to `/agents/sessions/{draftSessionId}/turns` after draft validation. */
69
72
  draftGateway?: TrueFoundryGateway;
73
+ /**
74
+ * Optional per-turn headers for createTurn. Invoked once per `sendTurn` after
75
+ * the session is resolved; return value is forwarded to `turn.execute`.
76
+ */
77
+ getTurnHeaders?: () => Promise<Record<string, string> | undefined>;
70
78
  };
71
79
 
72
80
  export type SendTurnOptions =
73
- | { userMessage: UserMessageContent; previousTurnId?: string | null }
81
+ | {
82
+ userMessage: UserMessageContent;
83
+ previousTurnId?: string | null;
84
+ /**
85
+ * When branching (edit/reset), the already-rewound history to send from.
86
+ * Applied atomically with `pendingUser` so a stale React snapshot cannot
87
+ * keep pre-branch turns while the new user message is appended.
88
+ */
89
+ branchFromSnapshot?: SessionSnapshot;
90
+ }
74
91
  | { inputs: RequiredActionInput[] }
75
92
  | { resumeMcpAuth: true };
76
93
 
@@ -272,11 +289,13 @@ function resolveTurnInput(
272
289
  export function useTrueFoundryAgentMessages({
273
290
  client,
274
291
  sessionId,
292
+ isMain,
275
293
  listEventsConcurrency,
276
294
  onError,
277
295
  initializeSession,
278
296
  resolveConversationSessionId,
279
297
  draftGateway,
298
+ getTurnHeaders,
280
299
  }: UseTrueFoundryAgentMessagesOptions) {
281
300
  const sessionOptions = useMemo<GetSessionOptions | undefined>(
282
301
  () =>
@@ -287,15 +306,28 @@ export function useTrueFoundryAgentMessages({
287
306
  const [snapshot, setSnapshot] = useState<SessionSnapshot>(createEmptySessionSnapshot);
288
307
  const [isRunning, setIsRunning] = useState(false);
289
308
  const [isLoading, setIsLoading] = useState(false);
309
+ const [isLoadingOlderHistory, setIsLoadingOlderHistory] = useState(false);
310
+ const [loadRetryTrigger, setLoadRetryTrigger] = useState(0);
290
311
 
291
312
  const snapshotRef = useRef(snapshot);
292
313
  snapshotRef.current = snapshot;
314
+ const loadOlderInflightRef = useRef<Promise<void> | null>(null);
315
+
316
+ const onErrorRef = useRef(onError);
317
+ onErrorRef.current = onError;
318
+ const resolveConversationSessionIdRef = useRef(resolveConversationSessionId);
319
+ resolveConversationSessionIdRef.current = resolveConversationSessionId;
320
+ const initializeSessionRef = useRef(initializeSession);
321
+ initializeSessionRef.current = initializeSession;
322
+ const getTurnHeadersRef = useRef(getTurnHeaders);
323
+ getTurnHeadersRef.current = getTurnHeaders;
293
324
 
294
325
  const createdAtByMessageIdRef = useRef(new Map<string, Date>());
295
326
  const abortControllerRef = useRef<AbortController | null>(null);
296
327
  const activeRunRef = useRef<Promise<void> | null>(null);
297
328
  const runningTurnRef = useRef<Turn | undefined>(undefined);
298
329
  const loadGenerationRef = useRef(0);
330
+ const streamGenerationRef = useRef(0);
299
331
  const lazilyCreatedSessionIdRef = useRef<string | undefined>(undefined);
300
332
 
301
333
  const projectOptions = useMemo(
@@ -318,47 +350,81 @@ export function useTrueFoundryAgentMessages({
318
350
  [snapshot, projectOptions],
319
351
  );
320
352
 
321
- const applyStreamUpdate = useCallback(
322
- (update: TurnStreamUpdate, turnId: string, isContinuation: boolean) => {
323
- setSnapshot((prev) =>
324
- replaceSessionSnapshot(prev, {
325
- activeStream: {
326
- turnId,
327
- update,
328
- isContinuation,
329
- },
330
- }),
331
- );
332
- },
333
- [],
334
- );
335
-
336
353
  const runStream = useCallback(
337
354
  (
338
355
  createStream: (signal: AbortSignal) => AsyncGenerator<TurnStreamUpdate>,
339
356
  turnId: string,
340
357
  isContinuation: boolean,
341
358
  ): Promise<void> => {
359
+ const streamGeneration = ++streamGenerationRef.current;
342
360
  abortControllerRef.current?.abort();
343
361
  const abortController = new AbortController();
344
362
  abortControllerRef.current = abortController;
345
363
  setIsRunning(true);
346
364
 
347
365
  const run = (async () => {
366
+ // Sub-agent turns can emit 100+ stream events per frame. Coalesce to one
367
+ // setSnapshot per animation frame so assistant-ui does not remount the whole
368
+ // message tree (UI hang). The buffer belongs to this stream only.
369
+ let pendingStreamUpdate: {
370
+ update: TurnStreamUpdate;
371
+ turnId: string;
372
+ isContinuation: boolean;
373
+ } | null = null;
374
+ let streamUpdateRaf: number | null = null;
375
+
376
+ const flushPendingStreamUpdate = () => {
377
+ streamUpdateRaf = null;
378
+ const pending = pendingStreamUpdate;
379
+ pendingStreamUpdate = null;
380
+ if (
381
+ pending == null ||
382
+ streamGeneration !== streamGenerationRef.current
383
+ ) {
384
+ return;
385
+ }
386
+ const { update, turnId: pendingTurnId, isContinuation: pendingIsContinuation } =
387
+ pending;
388
+ setSnapshot((prev) =>
389
+ replaceSessionSnapshot(prev, {
390
+ activeStream: {
391
+ turnId: pendingTurnId,
392
+ update,
393
+ isContinuation: pendingIsContinuation,
394
+ },
395
+ }),
396
+ );
397
+ };
398
+
399
+ const applyStreamUpdate = (update: TurnStreamUpdate) => {
400
+ pendingStreamUpdate = { update, turnId, isContinuation };
401
+ if (streamUpdateRaf == null) {
402
+ streamUpdateRaf = requestAnimationFrame(flushPendingStreamUpdate);
403
+ }
404
+ };
405
+
348
406
  try {
349
407
  for await (const update of createStream(abortController.signal)) {
350
408
  if (abortController.signal.aborted) {
351
409
  return;
352
410
  }
353
- applyStreamUpdate(update, turnId, isContinuation);
411
+ applyStreamUpdate(update);
354
412
  }
355
413
  } catch (error) {
356
414
  if (error instanceof Error && error.name === "AbortError") {
357
415
  return;
358
416
  }
359
- onError?.(error);
417
+ onErrorRef.current?.(error);
360
418
  throw error;
361
419
  } finally {
420
+ if (streamUpdateRaf != null) {
421
+ cancelAnimationFrame(streamUpdateRaf);
422
+ streamUpdateRaf = null;
423
+ }
424
+ if (streamGeneration !== streamGenerationRef.current) {
425
+ return;
426
+ }
427
+ flushPendingStreamUpdate();
362
428
  if (abortControllerRef.current === abortController) {
363
429
  abortControllerRef.current = null;
364
430
  }
@@ -392,7 +458,7 @@ export function useTrueFoundryAgentMessages({
392
458
  });
393
459
  return run;
394
460
  },
395
- [applyStreamUpdate, onError],
461
+ [onError],
396
462
  );
397
463
 
398
464
  const load = useCallback(async () => {
@@ -401,24 +467,47 @@ export function useTrueFoundryAgentMessages({
401
467
  setSnapshot(createEmptySessionSnapshot());
402
468
  return;
403
469
  }
470
+
471
+ // Thread components are never unmounted on navigation — isMain going
472
+ // false→true is the only reliable signal that the user has clicked on
473
+ // this thread. Skip the load when this thread is not the active one
474
+ // so that isMain being a dep triggers a fresh load on every selection.
475
+ if (isMain === false) return;
476
+
477
+ // When we are loading a *different* session the user has navigated away
478
+ // from the lazily-created one — clear the guard so navigating back to it
479
+ // later triggers a proper reload instead of silently skipping.
480
+ if (lazilyCreatedSessionIdRef.current != null && sessionId !== lazilyCreatedSessionIdRef.current) {
481
+ lazilyCreatedSessionIdRef.current = undefined;
482
+ }
483
+
404
484
  if (sessionId === lazilyCreatedSessionIdRef.current) {
405
485
  return;
406
486
  }
407
487
 
408
488
  const generation = ++loadGenerationRef.current;
489
+ ++streamGenerationRef.current;
409
490
  abortControllerRef.current?.abort();
491
+ loadOlderInflightRef.current = null;
492
+ createdAtByMessageIdRef.current = new Map();
493
+ setSnapshot(createEmptySessionSnapshot());
410
494
  setIsLoading(true);
495
+ setIsLoadingOlderHistory(false);
411
496
 
412
497
  try {
413
498
  const conversationSessionId = await resolveActiveSessionId(
414
499
  sessionId,
415
- resolveConversationSessionId,
500
+ resolveConversationSessionIdRef.current,
416
501
  );
417
502
  const loadedSnapshot = await loadSessionSnapshot(
418
503
  client,
419
504
  conversationSessionId,
420
- listEventsConcurrency,
421
505
  sessionOptions,
506
+ (snap) => {
507
+ if (generation === loadGenerationRef.current) {
508
+ setSnapshot(snap);
509
+ }
510
+ },
422
511
  );
423
512
  if (generation !== loadGenerationRef.current) {
424
513
  return;
@@ -428,32 +517,44 @@ export function useTrueFoundryAgentMessages({
428
517
  setSnapshot(loadedSnapshot);
429
518
  runningTurnRef.current = loadedSnapshot.runningTurn;
430
519
 
520
+ // History (and any seeded pendingUser for the in-flight turn) is
521
+ // ready — clear loading before resuming. Awaiting the subscribe
522
+ // stream here previously kept isLoading true for the entire
523
+ // backend run, so reconnect UIs stayed on shimmers even though
524
+ // subscribe was already live.
525
+ setIsLoading(false);
526
+
431
527
  if (loadedSnapshot.runningTurn != null) {
432
528
  const turn = loadedSnapshot.runningTurn;
433
529
  const isContinuation = !extractTurnUserText(turn.input);
434
530
  // TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers.
435
- await runStream(
531
+ // Use loadedSnapshot directly — snapshotRef.current still points at
532
+ // the empty snapshot cleared above until the setSnapshot(loadedSnapshot)
533
+ // call re-renders.
534
+ void runStream(
436
535
  (signal) =>
437
536
  resumeTurnStream(
438
537
  turn,
439
- snapshotRef.current.fold,
538
+ loadedSnapshot.fold,
440
539
  signal,
441
540
  undefined,
442
- snapshotRef.current.groupRootBaseline,
541
+ loadedSnapshot.groupRootBaseline,
443
542
  ),
444
543
  turn.id,
445
544
  isContinuation,
446
- );
545
+ ).catch(() => undefined);
447
546
  }
448
547
  } catch (error) {
449
- onError?.(error);
548
+ if (generation === loadGenerationRef.current) {
549
+ onErrorRef.current?.(error);
550
+ }
450
551
  throw error;
451
552
  } finally {
452
553
  if (generation === loadGenerationRef.current) {
453
554
  setIsLoading(false);
454
555
  }
455
556
  }
456
- }, [client, listEventsConcurrency, onError, resolveConversationSessionId, runStream, sessionId, sessionOptions]);
557
+ }, [client, runStream, sessionId, sessionOptions, loadRetryTrigger, isMain]);
457
558
 
458
559
  useEffect(() => {
459
560
  void load().catch(() => undefined);
@@ -463,19 +564,22 @@ export function useTrueFoundryAgentMessages({
463
564
  async (options: SendTurnOptions) => {
464
565
  let activeSessionId = sessionId;
465
566
  if (activeSessionId == null) {
466
- if (initializeSession == null) {
567
+ if (initializeSessionRef.current == null) {
467
568
  throw new Error("Cannot send a turn without an active session.");
468
569
  }
469
- const { remoteId } = await initializeSession();
570
+ const { remoteId } = await initializeSessionRef.current();
470
571
  activeSessionId = remoteId;
471
572
  lazilyCreatedSessionIdRef.current = remoteId;
472
573
  }
473
574
 
474
575
  const conversationSessionId = await resolveActiveSessionId(
475
576
  activeSessionId,
476
- resolveConversationSessionId,
577
+ resolveConversationSessionIdRef.current,
477
578
  );
478
579
  const session = await getSession(client, conversationSessionId, sessionOptions);
580
+ const turnHeaders = await getTurnHeadersRef.current?.();
581
+ const streamHeaders =
582
+ turnHeaders != null ? { headers: turnHeaders } : {};
479
583
  const isContinuation =
480
584
  "inputs" in options ||
481
585
  ("resumeMcpAuth" in options && options.resumeMcpAuth === true);
@@ -492,34 +596,57 @@ export function useTrueFoundryAgentMessages({
492
596
  );
493
597
  }
494
598
 
495
- setSnapshot((prev) =>
496
- commitActiveStream(
497
- prev,
498
- "inputs" in options ? options.inputs : undefined,
499
- ),
500
- );
599
+ const branchBase =
600
+ "userMessage" in options ? options.branchFromSnapshot : undefined;
501
601
 
502
602
  let groupRootBaseline: readonly string[] | undefined;
503
603
 
504
- if ("userMessage" in options) {
505
- const rootBucket =
506
- snapshotRef.current.fold.threads.get(ROOT_THREAD_ID);
604
+ if (branchBase != null && "userMessage" in options) {
605
+ // Atomic apply: never merge pendingUser onto a stale React `prev`
606
+ // that still holds pre-branch turns (edit would show old + new).
607
+ const rootBucket = branchBase.fold.threads.get(ROOT_THREAD_ID);
507
608
  groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
609
+ const nextSnapshot = replaceSessionSnapshot(branchBase, {
610
+ pendingUser: {
611
+ turnId,
612
+ content: options.userMessage,
613
+ createdAt: new Date(),
614
+ },
615
+ activeStream: undefined,
616
+ groupRootBaseline,
617
+ });
618
+ snapshotRef.current = nextSnapshot;
619
+ setSnapshot(nextSnapshot);
620
+ } else {
508
621
  setSnapshot((prev) =>
509
- replaceSessionSnapshot(prev, {
510
- pendingUser: {
511
- turnId,
512
- content: options.userMessage,
513
- createdAt: new Date(),
514
- },
515
- activeStream: undefined,
516
- groupRootBaseline,
517
- }),
622
+ commitActiveStream(
623
+ prev,
624
+ "inputs" in options ? options.inputs : undefined,
625
+ ),
518
626
  );
519
- } else {
520
- groupRootBaseline =
521
- snapshotRef.current.groupRootBaseline ??
522
- computeGroupRootBaseline(snapshotRef.current.turns);
627
+
628
+ if ("userMessage" in options) {
629
+ const rootBucket =
630
+ snapshotRef.current.fold.threads.get(ROOT_THREAD_ID);
631
+ groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
632
+ setSnapshot((prev) => {
633
+ const next = replaceSessionSnapshot(prev, {
634
+ pendingUser: {
635
+ turnId,
636
+ content: options.userMessage,
637
+ createdAt: new Date(),
638
+ },
639
+ activeStream: undefined,
640
+ groupRootBaseline,
641
+ });
642
+ snapshotRef.current = next;
643
+ return next;
644
+ });
645
+ } else {
646
+ groupRootBaseline =
647
+ snapshotRef.current.groupRootBaseline ??
648
+ computeGroupRootBaseline(snapshotRef.current.turns);
649
+ }
523
650
  }
524
651
 
525
652
  await runStream(
@@ -528,7 +655,7 @@ export function useTrueFoundryAgentMessages({
528
655
  return streamTurnContent(
529
656
  session,
530
657
  snapshotRef.current.fold,
531
- { inputs: options.inputs },
658
+ { inputs: options.inputs, ...streamHeaders },
532
659
  signal,
533
660
  groupRootBaseline,
534
661
  );
@@ -537,7 +664,7 @@ export function useTrueFoundryAgentMessages({
537
664
  return streamTurnContent(
538
665
  session,
539
666
  snapshotRef.current.fold,
540
- { resumeMcpAuth: true },
667
+ { resumeMcpAuth: true, ...streamHeaders },
541
668
  signal,
542
669
  groupRootBaseline,
543
670
  );
@@ -550,6 +677,7 @@ export function useTrueFoundryAgentMessages({
550
677
  ...(options.previousTurnId !== undefined
551
678
  ? { previousTurnId: options.previousTurnId }
552
679
  : {}),
680
+ ...streamHeaders,
553
681
  },
554
682
  signal,
555
683
  groupRootBaseline,
@@ -559,7 +687,7 @@ export function useTrueFoundryAgentMessages({
559
687
  isContinuation,
560
688
  );
561
689
  },
562
- [client, initializeSession, resolveConversationSessionId, runStream, sessionId, sessionOptions],
690
+ [client, runStream, sessionId, sessionOptions],
563
691
  );
564
692
 
565
693
  const cancel = useCallback(async () => {
@@ -569,7 +697,7 @@ export function useTrueFoundryAgentMessages({
569
697
  }
570
698
  const conversationSessionId = await resolveActiveSessionId(
571
699
  sessionId,
572
- resolveConversationSessionId,
700
+ resolveConversationSessionIdRef.current,
573
701
  );
574
702
  const session = await getSession(client, conversationSessionId, sessionOptions);
575
703
  // Request cancellation but keep consuming the stream. After cancel(),
@@ -581,7 +709,7 @@ export function useTrueFoundryAgentMessages({
581
709
  // reconcile is needed here — the cancelled turn is terminal and local
582
710
  // state reconciles against the event log on the next session load.
583
711
  await activeRunRef.current?.catch(() => undefined);
584
- }, [client, resolveConversationSessionId, sessionId, sessionOptions]);
712
+ }, [client, sessionId, sessionOptions]);
585
713
 
586
714
  const isRunningRef = useRef(isRunning);
587
715
  isRunningRef.current = isRunning;
@@ -598,10 +726,10 @@ export function useTrueFoundryAgentMessages({
598
726
  }
599
727
  const inputs = collectRequiredActionInputs(paused);
600
728
  if (inputs.length > 0) {
601
- void sendTurn({ inputs }).catch((error) => onError?.(error));
729
+ void sendTurn({ inputs }).catch((error) => onErrorRef.current?.(error));
602
730
  }
603
731
  },
604
- [onError, projectOptions, sendTurn],
732
+ [projectOptions, sendTurn],
605
733
  );
606
734
 
607
735
  const respondToToolApproval = useCallback(
@@ -671,37 +799,38 @@ export function useTrueFoundryAgentMessages({
671
799
  const committed = commitActiveStream(snapshotRef.current);
672
800
  setSnapshot(committed);
673
801
 
674
- const turnIndex = findTurnIndex(committed, turnId);
675
-
676
802
  await cancel();
677
803
 
678
804
  const conversationSessionId = await resolveActiveSessionId(
679
805
  activeSessionId,
680
- resolveConversationSessionId,
806
+ resolveConversationSessionIdRef.current,
681
807
  );
682
808
  const session = await getSession(client, conversationSessionId, sessionOptions);
683
- const previousTurnId = await resolveGatewayBranchPreviousTurnId(
809
+ const previousTurnId = await resolveGatewayBranchPreviousTurnIdForTurn(
684
810
  session,
685
- turnIndex,
811
+ turnId,
686
812
  );
687
- const rewound = await buildSnapshotBeforeTurnIndex(
813
+ const rewound = await buildSnapshotBeforeTurn(
688
814
  session,
689
- turnIndex,
815
+ turnId,
690
816
  listEventsConcurrency,
691
817
  );
692
818
  createdAtByMessageIdRef.current = new Map();
819
+ // Keep the ref aligned before awaiting sendTurn so any intermediate
820
+ // reads (and the atomic pendingUser apply) see the rewound history.
821
+ snapshotRef.current = rewound;
693
822
  setSnapshot(rewound);
694
823
 
695
824
  await sendTurn({
696
825
  userMessage,
697
826
  previousTurnId,
827
+ branchFromSnapshot: rewound,
698
828
  });
699
829
  },
700
830
  [
701
831
  cancel,
702
832
  client,
703
833
  listEventsConcurrency,
704
- resolveConversationSessionId,
705
834
  sendTurn,
706
835
  sessionId,
707
836
  sessionOptions,
@@ -737,11 +866,75 @@ export function useTrueFoundryAgentMessages({
737
866
  [branchFromTurn],
738
867
  );
739
868
 
869
+ const retryLoad = useCallback(() => {
870
+ setLoadRetryTrigger((n) => n + 1);
871
+ }, []);
872
+
873
+ const hasOlderHistory = snapshot.historyPagination?.hasOlder === true;
874
+
875
+ const loadOlderHistory = useCallback(async () => {
876
+ if (sessionId == null || isMain === false) {
877
+ return;
878
+ }
879
+ if (loadOlderInflightRef.current != null) {
880
+ return loadOlderInflightRef.current;
881
+ }
882
+
883
+ const current = snapshotRef.current;
884
+ if (current.historyPagination?.hasOlder !== true) {
885
+ return;
886
+ }
887
+ if (current.historyPagination.olderPageToken == null) {
888
+ return;
889
+ }
890
+
891
+ const generation = loadGenerationRef.current;
892
+ setIsLoadingOlderHistory(true);
893
+
894
+ const run = (async () => {
895
+ try {
896
+ const conversationSessionId = await resolveActiveSessionId(
897
+ sessionId,
898
+ resolveConversationSessionIdRef.current,
899
+ );
900
+ const session = await getSession(
901
+ client,
902
+ conversationSessionId,
903
+ sessionOptions,
904
+ );
905
+ const next = await prependOlderSessionHistory(
906
+ session,
907
+ snapshotRef.current,
908
+ );
909
+ if (generation !== loadGenerationRef.current) {
910
+ return;
911
+ }
912
+ setSnapshot(next);
913
+ } catch (error) {
914
+ if (generation === loadGenerationRef.current) {
915
+ onErrorRef.current?.(error);
916
+ }
917
+ throw error;
918
+ } finally {
919
+ if (generation === loadGenerationRef.current) {
920
+ setIsLoadingOlderHistory(false);
921
+ }
922
+ loadOlderInflightRef.current = null;
923
+ }
924
+ })();
925
+
926
+ loadOlderInflightRef.current = run;
927
+ return run;
928
+ }, [client, isMain, sessionId, sessionOptions]);
929
+
740
930
  return {
741
931
  messages,
742
932
  isRunning,
743
933
  isLoading,
744
- load,
934
+ isLoadingOlderHistory,
935
+ hasOlderHistory,
936
+ loadOlderHistory,
937
+ retryLoad,
745
938
  sendTurn,
746
939
  cancel,
747
940
  respondToToolApproval,