@truefoundry/assistant-ui-runtime 0.1.6 → 0.1.8

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 (46) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +22 -17
  3. package/dist/chunk-CXBZ6WLZ.js +636 -0
  4. package/dist/chunk-CXBZ6WLZ.js.map +1 -0
  5. package/dist/index.d.ts +20 -24
  6. package/dist/index.js +269 -166
  7. package/dist/index.js.map +1 -1
  8. package/dist/plugins/truefoundry-agent-server-adapter/index.d.ts +82 -39
  9. package/dist/plugins/truefoundry-agent-server-adapter/index.js +3 -1
  10. package/dist/server/index.d.ts +2 -2
  11. package/dist/{types-BfiFf8O1.d.ts → types-B_z-FsDS.d.ts} +208 -10
  12. package/package.json +1 -1
  13. package/src/convertTurnMessages.ts +4 -0
  14. package/src/{private → draft}/agentSpec.ts +14 -17
  15. package/src/{private → draft}/draftSessionBridge.ts +1 -2
  16. package/src/{private → draft}/truefoundryDraftThreadListAdapter.test.ts +1 -1
  17. package/src/{private → draft}/truefoundryDraftThreadListAdapter.ts +6 -2
  18. package/src/{private → draft}/useDraftAgentSpec.ts +16 -5
  19. package/src/draftAgentConfig.test.ts +2 -1
  20. package/src/harness.temp.ts +85 -0
  21. package/src/index.ts +43 -7
  22. package/src/plugins/truefoundry-agent-server-adapter/README.md +83 -44
  23. package/src/plugins/truefoundry-agent-server-adapter/chatServer.ts +365 -0
  24. package/src/plugins/truefoundry-agent-server-adapter/cp.test.ts +444 -0
  25. package/src/plugins/truefoundry-agent-server-adapter/cp.ts +482 -0
  26. package/src/plugins/truefoundry-agent-server-adapter/createTrueFoundryAgentUIServer.ts +94 -0
  27. package/src/plugins/truefoundry-agent-server-adapter/guards.ts +1 -1
  28. package/src/plugins/truefoundry-agent-server-adapter/index.ts +20 -351
  29. package/src/plugins/truefoundry-agent-server-adapter/normalizeAgentSpec.test.ts +85 -0
  30. package/src/plugins/truefoundry-agent-server-adapter/normalizeAgentSpec.ts +84 -0
  31. package/src/plugins/truefoundry-agent-server-adapter/types.ts +7 -5
  32. package/src/server/index.ts +29 -0
  33. package/src/server/types.ts +264 -12
  34. package/src/streamTurn.test.ts +27 -27
  35. package/src/streamTurn.ts +2 -2
  36. package/src/truefoundryExtras.ts +4 -1
  37. package/src/truefoundryOwnedSessionsThreadListAdapter.ts +5 -2
  38. package/src/truefoundryThreadListAdapter.test.ts +22 -0
  39. package/src/truefoundryThreadListAdapter.ts +4 -1
  40. package/src/types.ts +1 -2
  41. package/src/useTrueFoundryAgentMessages.test.tsx +262 -2
  42. package/src/useTrueFoundryAgentMessages.ts +284 -176
  43. package/src/useTrueFoundryAgentRuntime.ts +31 -21
  44. package/dist/chunk-Q2SHKMLM.js +0 -270
  45. package/dist/chunk-Q2SHKMLM.js.map +0 -1
  46. /package/src/{private → draft}/useDraftAgentSpec.test.tsx +0 -0
@@ -58,6 +58,8 @@ export type UseTrueFoundryAgentMessagesOptions = {
58
58
  sessionId: string | undefined;
59
59
  /** When true the thread is the currently selected (main) thread. */
60
60
  isMain?: boolean | undefined;
61
+ /** URL-selected session may load before the thread list marks it as main. */
62
+ isInitialSession?: boolean | undefined;
61
63
  listEventsConcurrency?: number | undefined;
62
64
  onError?: ((error: unknown) => void) | undefined;
63
65
  initializeSession?: () => Promise<{
@@ -77,12 +79,16 @@ export type SendTurnOptions =
77
79
  | {
78
80
  userMessage: UserMessageContent;
79
81
  previousTurnId?: string | null;
82
+ /** Invoked only when the user turn fails before the gateway registers it. */
83
+ onPreTurnFailure?: () => void;
80
84
  /**
81
85
  * When branching (edit/reset), the already-rewound history to send from.
82
86
  * Applied atomically with `pendingUser` so a stale React snapshot cannot
83
87
  * keep pre-branch turns while the new user message is appended.
84
88
  */
85
89
  branchFromSnapshot?: SessionSnapshot;
90
+ /** Original history restored when a branch fails before turn.created. */
91
+ branchRollbackSnapshot?: SessionSnapshot;
86
92
  }
87
93
  | { inputs: RequiredActionInput[] }
88
94
  | { resumeMcpAuth: true };
@@ -286,6 +292,7 @@ export function useTrueFoundryAgentMessages({
286
292
  server,
287
293
  sessionId,
288
294
  isMain,
295
+ isInitialSession,
289
296
  listEventsConcurrency,
290
297
  onError,
291
298
  initializeSession,
@@ -294,7 +301,12 @@ export function useTrueFoundryAgentMessages({
294
301
  }: UseTrueFoundryAgentMessagesOptions) {
295
302
  const [snapshot, setSnapshot] = useState<SessionSnapshot>(createEmptySessionSnapshot);
296
303
  const [isRunning, setIsRunning] = useState(false);
297
- const [isLoading, setIsLoading] = useState(false);
304
+ // Existing sessions have history pending from the first render. Starting at
305
+ // false causes consumers to briefly render an empty thread before the load
306
+ // effect runs and flips this flag to true.
307
+ const [isLoading, setIsLoading] = useState(
308
+ sessionId != null && (isMain !== false || isInitialSession === true),
309
+ );
298
310
  const [isLoadingOlderHistory, setIsLoadingOlderHistory] = useState(false);
299
311
  const [loadRetryTrigger, setLoadRetryTrigger] = useState(0);
300
312
 
@@ -318,6 +330,8 @@ export function useTrueFoundryAgentMessages({
318
330
  const loadGenerationRef = useRef(0);
319
331
  const streamGenerationRef = useRef(0);
320
332
  const lazilyCreatedSessionIdRef = useRef<string | undefined>(undefined);
333
+ const initialLoadStartedForRef = useRef<string | undefined>(undefined);
334
+ const skipInitialPromotionLoadForRef = useRef<string | undefined>(undefined);
321
335
 
322
336
  const projectOptions = useMemo(
323
337
  () => ({
@@ -459,17 +473,34 @@ export function useTrueFoundryAgentMessages({
459
473
  );
460
474
 
461
475
  const load = useCallback(async () => {
476
+ // Reading the retry counter intentionally makes retryLoad recreate this callback.
477
+ void loadRetryTrigger;
462
478
  if (sessionId == null) {
463
479
  createdAtByMessageIdRef.current = new Map();
464
480
  setSnapshot(createEmptySessionSnapshot());
465
481
  return;
466
482
  }
467
483
 
468
- // Thread components are never unmounted on navigation isMain going
469
- // false→true is the only reliable signal that the user has clicked on
470
- // this thread. Skip the load when this thread is not the active one
471
- // so that isMain being a dep triggers a fresh load on every selection.
472
- if (isMain === false) return;
484
+ // Allow the URL-selected session one early load before assistant-ui marks
485
+ // it main. Suppress only that first promotion; later selections still reload.
486
+ const isEarlyInitialLoad =
487
+ isMain === false &&
488
+ isInitialSession === true &&
489
+ initialLoadStartedForRef.current !== sessionId;
490
+ if (isMain === false) {
491
+ if (!isEarlyInitialLoad) return;
492
+ initialLoadStartedForRef.current = sessionId;
493
+ skipInitialPromotionLoadForRef.current = sessionId;
494
+ } else if (
495
+ isMain === true &&
496
+ skipInitialPromotionLoadForRef.current === sessionId
497
+ ) {
498
+ skipInitialPromotionLoadForRef.current = undefined;
499
+ return;
500
+ }
501
+ if (isInitialSession === true) {
502
+ initialLoadStartedForRef.current = sessionId;
503
+ }
473
504
 
474
505
  // When we are loading a *different* session the user has navigated away
475
506
  // from the lazily-created one — clear the guard so navigating back to it
@@ -484,6 +515,7 @@ export function useTrueFoundryAgentMessages({
484
515
 
485
516
  const generation = ++loadGenerationRef.current;
486
517
  ++streamGenerationRef.current;
518
+ setIsRunning(false);
487
519
  abortControllerRef.current?.abort();
488
520
  loadOlderInflightRef.current = null;
489
521
  createdAtByMessageIdRef.current = new Map();
@@ -544,6 +576,11 @@ export function useTrueFoundryAgentMessages({
544
576
  }
545
577
  } catch (error) {
546
578
  if (generation === loadGenerationRef.current) {
579
+ if (isEarlyInitialLoad) {
580
+ // Allow retryLoad while still backgrounded (before isMain promotion).
581
+ initialLoadStartedForRef.current = undefined;
582
+ skipInitialPromotionLoadForRef.current = undefined;
583
+ }
547
584
  onErrorRef.current?.(error);
548
585
  }
549
586
  throw error;
@@ -552,7 +589,14 @@ export function useTrueFoundryAgentMessages({
552
589
  setIsLoading(false);
553
590
  }
554
591
  }
555
- }, [server, runStream, sessionId, loadRetryTrigger, isMain]);
592
+ }, [
593
+ server,
594
+ runStream,
595
+ sessionId,
596
+ loadRetryTrigger,
597
+ isMain,
598
+ isInitialSession,
599
+ ]);
556
600
 
557
601
  useEffect(() => {
558
602
  void load().catch(() => undefined);
@@ -560,163 +604,211 @@ export function useTrueFoundryAgentMessages({
560
604
 
561
605
  const sendTurn = useCallback(
562
606
  async (options: SendTurnOptions) => {
563
- let activeSessionId = sessionId;
564
- if (activeSessionId == null) {
565
- if (initializeSessionRef.current == null) {
566
- throw new Error("Cannot send a turn without an active session.");
607
+ // A turn.created event means the gateway registered the user message.
608
+ // Errors after that point must keep the message in chat.
609
+ let gatewayTurnAccepted = false;
610
+ let pendingUserWasSet = false;
611
+ let runStreamStarted = false;
612
+ let pendingUserTurnId: string | undefined;
613
+
614
+ try {
615
+ let activeSessionId = sessionId;
616
+ if (activeSessionId == null) {
617
+ if (initializeSessionRef.current == null) {
618
+ throw new Error("Cannot send a turn without an active session.");
619
+ }
620
+ const { remoteId } = await initializeSessionRef.current();
621
+ activeSessionId = remoteId;
622
+ lazilyCreatedSessionIdRef.current = remoteId;
567
623
  }
568
- const { remoteId } = await initializeSessionRef.current();
569
- activeSessionId = remoteId;
570
- lazilyCreatedSessionIdRef.current = remoteId;
571
- }
572
624
 
573
- const conversationSessionId = await resolveActiveSessionId(
574
- activeSessionId,
575
- resolveConversationSessionIdRef.current,
576
- );
577
- const turnHeaders = await getTurnHeadersRef.current?.();
578
- const streamHeaders =
579
- turnHeaders != null ? { headers: turnHeaders } : {};
580
- const isContinuation =
581
- "inputs" in options ||
582
- ("resumeMcpAuth" in options && options.resumeMcpAuth === true);
583
- const continuationTurnId = snapshotRef.current.activeStream?.turnId;
584
- const turnId =
585
- isContinuation && continuationTurnId != null
586
- ? continuationTurnId
587
- : generateId();
588
- // First turns must send previousTurnId: "none".
589
- const isFirstTurnInSession =
590
- "userMessage" in options &&
591
- options.previousTurnId === undefined &&
592
- snapshotRef.current.turns.length === 0 &&
593
- snapshotRef.current.pendingUser == null &&
594
- snapshotRef.current.activeStream == null;
595
-
596
- // Mutable ref so runStream always reads the latest ID. For new
597
- // user-message turns the local `generateId()` value is replaced
598
- // with the gateway-assigned ID once the first SSE event arrives.
599
- const turnIdRef = { current: turnId };
600
-
601
- if ("inputs" in options) {
602
- applyUserToolResponsesToFold(
603
- snapshotRef.current.fold,
604
- options.inputs,
625
+ const conversationSessionId = await resolveActiveSessionId(
626
+ activeSessionId,
627
+ resolveConversationSessionIdRef.current,
605
628
  );
606
- }
629
+ const turnHeaders = await getTurnHeadersRef.current?.();
630
+ const streamHeaders =
631
+ turnHeaders != null ? { headers: turnHeaders } : {};
632
+ const isContinuation =
633
+ "inputs" in options ||
634
+ ("resumeMcpAuth" in options && options.resumeMcpAuth === true);
635
+ const continuationTurnId = snapshotRef.current.activeStream?.turnId;
636
+ const turnId =
637
+ isContinuation && continuationTurnId != null
638
+ ? continuationTurnId
639
+ : generateId();
640
+ // First turns must send previousTurnId: "none".
641
+ const isFirstTurnInSession =
642
+ "userMessage" in options &&
643
+ options.previousTurnId === undefined &&
644
+ snapshotRef.current.turns.length === 0 &&
645
+ snapshotRef.current.pendingUser == null &&
646
+ snapshotRef.current.activeStream == null;
647
+
648
+ // Mutable ref so runStream always reads the latest ID. For new
649
+ // user-message turns the local `generateId()` value is replaced
650
+ // with the gateway-assigned ID once the first SSE event arrives.
651
+ const turnIdRef = { current: turnId };
652
+
653
+ if ("inputs" in options) {
654
+ applyUserToolResponsesToFold(
655
+ snapshotRef.current.fold,
656
+ options.inputs,
657
+ );
658
+ }
607
659
 
608
- const branchBase =
609
- "userMessage" in options ? options.branchFromSnapshot : undefined;
610
-
611
- let groupRootBaseline: readonly string[] | undefined;
612
-
613
- if (branchBase != null && "userMessage" in options) {
614
- // Atomic apply: never merge pendingUser onto a stale React `prev`
615
- // that still holds pre-branch turns (edit would show old + new).
616
- const rootBucket = branchBase.fold.threads.get(ROOT_THREAD_ID);
617
- groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
618
- const nextSnapshot = replaceSessionSnapshot(branchBase, {
619
- pendingUser: {
620
- turnId,
621
- content: options.userMessage,
622
- createdAt: new Date(),
623
- },
624
- activeStream: undefined,
625
- groupRootBaseline,
626
- });
627
- snapshotRef.current = nextSnapshot;
628
- setSnapshot(nextSnapshot);
629
- } else {
630
- setSnapshot((prev) =>
631
- commitActiveStream(
632
- prev,
633
- "inputs" in options ? options.inputs : undefined,
634
- ),
635
- );
660
+ const branchBase =
661
+ "userMessage" in options ? options.branchFromSnapshot : undefined;
662
+
663
+ let groupRootBaseline: readonly string[] | undefined;
636
664
 
637
- if ("userMessage" in options) {
638
- const rootBucket =
639
- snapshotRef.current.fold.threads.get(ROOT_THREAD_ID);
665
+ if (branchBase != null && "userMessage" in options) {
666
+ // Atomic apply: never merge pendingUser onto a stale React `prev`
667
+ // that still holds pre-branch turns (edit would show old + new).
668
+ const rootBucket = branchBase.fold.threads.get(ROOT_THREAD_ID);
640
669
  groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
641
- setSnapshot((prev) => {
642
- const next = replaceSessionSnapshot(prev, {
643
- pendingUser: {
644
- turnId,
645
- content: options.userMessage,
646
- createdAt: new Date(),
647
- },
648
- activeStream: undefined,
649
- groupRootBaseline,
650
- });
651
- snapshotRef.current = next;
652
- return next;
670
+ const nextSnapshot = replaceSessionSnapshot(branchBase, {
671
+ pendingUser: {
672
+ turnId,
673
+ content: options.userMessage,
674
+ createdAt: new Date(),
675
+ },
676
+ activeStream: undefined,
677
+ groupRootBaseline,
653
678
  });
679
+ snapshotRef.current = nextSnapshot;
680
+ setSnapshot(nextSnapshot);
681
+ pendingUserWasSet = true;
682
+ pendingUserTurnId = turnId;
654
683
  } else {
655
- groupRootBaseline =
656
- snapshotRef.current.groupRootBaseline ??
657
- computeGroupRootBaseline(snapshotRef.current.turns);
658
- }
659
- }
684
+ setSnapshot((prev) =>
685
+ commitActiveStream(
686
+ prev,
687
+ "inputs" in options ? options.inputs : undefined,
688
+ ),
689
+ );
660
690
 
661
- await runStream(
662
- (signal) => {
663
- if ("inputs" in options) {
664
- return streamTurnContent(
665
- server,
666
- conversationSessionId,
667
- snapshotRef.current.fold,
668
- { inputs: options.inputs, ...streamHeaders },
669
- signal,
670
- groupRootBaseline,
671
- );
691
+ if ("userMessage" in options) {
692
+ const rootBucket =
693
+ snapshotRef.current.fold.threads.get(ROOT_THREAD_ID);
694
+ groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
695
+ setSnapshot((prev) => {
696
+ const next = replaceSessionSnapshot(prev, {
697
+ pendingUser: {
698
+ turnId,
699
+ content: options.userMessage,
700
+ createdAt: new Date(),
701
+ },
702
+ activeStream: undefined,
703
+ groupRootBaseline,
704
+ });
705
+ snapshotRef.current = next;
706
+ return next;
707
+ });
708
+ pendingUserWasSet = true;
709
+ pendingUserTurnId = turnId;
710
+ } else {
711
+ groupRootBaseline =
712
+ snapshotRef.current.groupRootBaseline ??
713
+ computeGroupRootBaseline(snapshotRef.current.turns);
672
714
  }
673
- if ("resumeMcpAuth" in options) {
715
+ }
716
+
717
+ runStreamStarted = true;
718
+ await runStream(
719
+ (signal) => {
720
+ if ("inputs" in options) {
721
+ return streamTurnContent(
722
+ server,
723
+ conversationSessionId,
724
+ snapshotRef.current.fold,
725
+ { inputs: options.inputs, ...streamHeaders },
726
+ signal,
727
+ groupRootBaseline,
728
+ );
729
+ }
730
+ if ("resumeMcpAuth" in options) {
731
+ return streamTurnContent(
732
+ server,
733
+ conversationSessionId,
734
+ snapshotRef.current.fold,
735
+ { resumeMcpAuth: true, ...streamHeaders },
736
+ signal,
737
+ groupRootBaseline,
738
+ );
739
+ }
674
740
  return streamTurnContent(
675
741
  server,
676
742
  conversationSessionId,
677
743
  snapshotRef.current.fold,
678
- { resumeMcpAuth: true, ...streamHeaders },
744
+ {
745
+ userMessage: options.userMessage,
746
+ ...(options.previousTurnId !== undefined
747
+ ? { previousTurnId: options.previousTurnId ?? "none" }
748
+ : isFirstTurnInSession
749
+ ? { previousTurnId: "none" }
750
+ : {}),
751
+ ...streamHeaders,
752
+ },
679
753
  signal,
680
754
  groupRootBaseline,
755
+ // Rename the optimistic local ID to the gateway turn ID
756
+ // so that edit/retry can resolve the turn via the gateway.
757
+ (gatewayTurnId) => {
758
+ const oldId = turnIdRef.current;
759
+ // turn.created proves the gateway registered the message.
760
+ gatewayTurnAccepted = true;
761
+ if (gatewayTurnId === oldId) return;
762
+ turnIdRef.current = gatewayTurnId;
763
+ // Rename in the ref immediately so any synchronous read
764
+ // (e.g. commitActiveStream) sees the correct ID.
765
+ const renamePendingUser = (prev: SessionSnapshot): SessionSnapshot => {
766
+ if (prev.pendingUser?.turnId !== oldId) return prev;
767
+ return replaceSessionSnapshot(prev, {
768
+ pendingUser: { ...prev.pendingUser, turnId: gatewayTurnId },
769
+ });
770
+ };
771
+ snapshotRef.current = renamePendingUser(snapshotRef.current);
772
+ setSnapshot(renamePendingUser);
773
+ },
681
774
  );
775
+ },
776
+ turnIdRef,
777
+ isContinuation,
778
+ );
779
+ } catch (error) {
780
+ if ("userMessage" in options && !gatewayTurnAccepted) {
781
+ const branchRollbackSnapshot =
782
+ options.branchRollbackSnapshot;
783
+ const canRestoreBranch =
784
+ branchRollbackSnapshot != null &&
785
+ (snapshotRef.current === options.branchFromSnapshot ||
786
+ snapshotRef.current.pendingUser?.turnId ===
787
+ pendingUserTurnId);
788
+ if (canRestoreBranch) {
789
+ snapshotRef.current = branchRollbackSnapshot;
790
+ setSnapshot(branchRollbackSnapshot);
791
+ } else if (pendingUserWasSet) {
792
+ const clearPendingUser = (
793
+ previous: SessionSnapshot,
794
+ ): SessionSnapshot => {
795
+ if (previous.pendingUser?.turnId !== pendingUserTurnId) {
796
+ return previous;
797
+ }
798
+ return replaceSessionSnapshot(previous, {
799
+ pendingUser: undefined,
800
+ });
801
+ };
802
+ snapshotRef.current = clearPendingUser(snapshotRef.current);
803
+ setSnapshot(clearPendingUser);
682
804
  }
683
- return streamTurnContent(
684
- server,
685
- conversationSessionId,
686
- snapshotRef.current.fold,
687
- {
688
- userMessage: options.userMessage,
689
- ...(options.previousTurnId !== undefined
690
- ? { previousTurnId: options.previousTurnId ?? "none" }
691
- : isFirstTurnInSession
692
- ? { previousTurnId: "none" }
693
- : {}),
694
- ...streamHeaders,
695
- },
696
- signal,
697
- groupRootBaseline,
698
- // Rename the optimistic local ID to the gateway turn ID
699
- // so that edit/retry can resolve the turn via the gateway.
700
- (gatewayTurnId) => {
701
- const oldId = turnIdRef.current;
702
- if (gatewayTurnId === oldId) return;
703
- turnIdRef.current = gatewayTurnId;
704
- // Rename in the ref immediately so any synchronous read
705
- // (e.g. commitActiveStream) sees the correct ID.
706
- const renamePendingUser = (prev: SessionSnapshot): SessionSnapshot => {
707
- if (prev.pendingUser?.turnId !== oldId) return prev;
708
- return replaceSessionSnapshot(prev, {
709
- pendingUser: { ...prev.pendingUser, turnId: gatewayTurnId },
710
- });
711
- };
712
- snapshotRef.current = renamePendingUser(snapshotRef.current);
713
- setSnapshot(renamePendingUser);
714
- },
715
- );
716
- },
717
- turnIdRef,
718
- isContinuation,
719
- );
805
+ options.onPreTurnFailure?.();
806
+ }
807
+ if (!runStreamStarted) {
808
+ onErrorRef.current?.(error);
809
+ }
810
+ throw error;
811
+ }
720
812
  },
721
813
  [server, runStream, sessionId],
722
814
  );
@@ -756,7 +848,8 @@ export function useTrueFoundryAgentMessages({
756
848
  }
757
849
  const inputs = collectRequiredActionInputs(paused);
758
850
  if (inputs.length > 0) {
759
- void sendTurn({ inputs }).catch((error) => onErrorRef.current?.(error));
851
+ // sendTurn/runStream already report via onError; swallow to avoid duplicates.
852
+ void sendTurn({ inputs }).catch(() => undefined);
760
853
  }
761
854
  },
762
855
  [projectOptions, sendTurn],
@@ -823,41 +916,52 @@ export function useTrueFoundryAgentMessages({
823
916
 
824
917
  const branchFromTurn = useCallback(
825
918
  async (turnId: string, userMessage: UserMessageContent) => {
826
- let activeSessionId = sessionId;
827
- if (activeSessionId == null) {
828
- throw new Error("Cannot branch from a turn without an active session.");
829
- }
919
+ let committed: SessionSnapshot;
920
+ let previousTurnId: string;
921
+ let rewound: SessionSnapshot;
922
+ try {
923
+ let activeSessionId = sessionId;
924
+ if (activeSessionId == null) {
925
+ throw new Error("Cannot branch from a turn without an active session.");
926
+ }
830
927
 
831
- const committed = commitActiveStream(snapshotRef.current);
832
- setSnapshot(committed);
928
+ committed = commitActiveStream(snapshotRef.current);
929
+ setSnapshot(committed);
833
930
 
834
- await cancel();
931
+ await cancel();
835
932
 
836
- const conversationSessionId = await resolveActiveSessionId(
837
- activeSessionId,
838
- resolveConversationSessionIdRef.current,
839
- );
840
- const previousTurnId = await resolveGatewayBranchPreviousTurnIdForTurn(
841
- server,
842
- conversationSessionId,
843
- turnId,
844
- );
845
- const rewound = await buildSnapshotBeforeTurn(
846
- server,
847
- conversationSessionId,
848
- turnId,
849
- listEventsConcurrency,
850
- );
851
- createdAtByMessageIdRef.current = new Map();
852
- // Keep the ref aligned before awaiting sendTurn so any intermediate
853
- // reads (and the atomic pendingUser apply) see the rewound history.
854
- snapshotRef.current = rewound;
855
- setSnapshot(rewound);
933
+ const conversationSessionId = await resolveActiveSessionId(
934
+ activeSessionId,
935
+ resolveConversationSessionIdRef.current,
936
+ );
937
+ previousTurnId = await resolveGatewayBranchPreviousTurnIdForTurn(
938
+ server,
939
+ conversationSessionId,
940
+ turnId,
941
+ );
942
+ rewound = await buildSnapshotBeforeTurn(
943
+ server,
944
+ conversationSessionId,
945
+ turnId,
946
+ listEventsConcurrency,
947
+ );
948
+ createdAtByMessageIdRef.current = new Map();
949
+ // Keep the ref aligned before awaiting sendTurn so any intermediate
950
+ // reads (and the atomic pendingUser apply) see the rewound history.
951
+ snapshotRef.current = rewound;
952
+ setSnapshot(rewound);
953
+ } catch (error) {
954
+ // Setup failures never reach sendTurn/runStream reporting.
955
+ onErrorRef.current?.(error);
956
+ throw error;
957
+ }
856
958
 
959
+ // sendTurn/runStream own error reporting for the turn itself.
857
960
  await sendTurn({
858
961
  userMessage,
859
962
  previousTurnId,
860
963
  branchFromSnapshot: rewound,
964
+ branchRollbackSnapshot: committed,
861
965
  });
862
966
  },
863
967
  [
@@ -874,7 +978,9 @@ export function useTrueFoundryAgentMessages({
874
978
  const committed = commitActiveStream(snapshotRef.current);
875
979
  const originalInput = resolveTurnInput(committed, turnId);
876
980
  if (originalInput == null) {
877
- throw new Error(`Turn ${turnId} not found in session snapshot`);
981
+ const error = new Error(`Turn ${turnId} not found in session snapshot`);
982
+ onErrorRef.current?.(error);
983
+ throw error;
878
984
  }
879
985
  const userMessage = extractTurnUserMessageContent(originalInput);
880
986
  await branchFromTurn(turnId, userMessage);
@@ -887,7 +993,9 @@ export function useTrueFoundryAgentMessages({
887
993
  const committed = commitActiveStream(snapshotRef.current);
888
994
  const originalInput = resolveTurnInput(committed, turnId);
889
995
  if (originalInput == null) {
890
- throw new Error(`Turn ${turnId} not found in session snapshot`);
996
+ const error = new Error(`Turn ${turnId} not found in session snapshot`);
997
+ onErrorRef.current?.(error);
998
+ throw error;
891
999
  }
892
1000
  const userMessage = buildEditedUserMessageContent(
893
1001
  editedText,