@kortyx/react 0.4.1 → 0.5.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/dist/index.js CHANGED
@@ -41,7 +41,11 @@ function buildAssistantMessage(args) {
41
41
  role: "assistant",
42
42
  content: plainTextContent,
43
43
  debug: args.debug,
44
- ...args.trace ? args.trace : {}
44
+ ...args.trace ? args.trace : {},
45
+ ...args.checkpoint ? {
46
+ checkpointId: args.checkpoint.id,
47
+ checkpointTurnIndex: args.checkpoint.turnIndex
48
+ } : {}
45
49
  };
46
50
  return args.pieces.length > 0 ? { ...base, contentPieces: args.pieces } : base;
47
51
  }
@@ -51,7 +55,9 @@ var DEFAULT_STORAGE_KEYS = {
51
55
  messages: "chat.messages.v1",
52
56
  includeHistory: "chat.includeHistory.v1",
53
57
  sessionId: "chat.sessionId",
54
- workflowId: "chat.workflowId"
58
+ workflowId: "chat.workflowId",
59
+ branches: "chat.branches.v1",
60
+ responseVariants: "chat.responseVariants.v1"
55
61
  };
56
62
  var getLocalStorage = () => {
57
63
  const candidate = globalThis;
@@ -78,13 +84,40 @@ function createBrowserChatStorage(options) {
78
84
  const workflowId = storage.getItem(keys.workflowId) ?? "";
79
85
  const includeHistoryRaw = storage.getItem(keys.includeHistory);
80
86
  const rawMessages = storage.getItem(keys.messages);
87
+ const rawBranches = storage.getItem(keys.branches);
88
+ const rawResponseVariants = storage.getItem(keys.responseVariants);
81
89
  const parsedMessages = rawMessages ? JSON.parse(rawMessages) : [];
82
90
  const messages = Array.isArray(parsedMessages) ? parsedMessages.map((item) => parseMessage(item)).filter((message) => message !== null) : [];
91
+ const parsedBranches = rawBranches ? JSON.parse(rawBranches) : void 0;
92
+ const branches = parsedBranches && typeof parsedBranches === "object" ? Object.fromEntries(
93
+ Object.entries(
94
+ parsedBranches
95
+ ).flatMap(([sessionId2, branch]) => {
96
+ if (typeof branch.sessionId !== "string") return [];
97
+ const rawBranchMessages = Array.isArray(branch.messages) ? branch.messages : [];
98
+ const branchMessages = rawBranchMessages.map((item) => parseMessage(item)).filter((message) => message !== null).slice(-maxMessages);
99
+ return [
100
+ [
101
+ sessionId2,
102
+ {
103
+ sessionId: branch.sessionId,
104
+ ...typeof branch.parentSessionId === "string" ? { parentSessionId: branch.parentSessionId } : {},
105
+ ...typeof branch.forkedFrom === "string" ? { forkedFrom: branch.forkedFrom } : {},
106
+ messages: branchMessages,
107
+ ...Array.isArray(branch.checkpoints) ? { checkpoints: branch.checkpoints } : {}
108
+ }
109
+ ]
110
+ ];
111
+ })
112
+ ) : void 0;
113
+ const parsedResponseVariants = rawResponseVariants ? JSON.parse(rawResponseVariants) : void 0;
83
114
  return {
84
115
  sessionId,
85
116
  workflowId,
86
117
  includeHistory: includeHistoryRaw !== "0",
87
- messages
118
+ messages,
119
+ ...branches ? { branches } : {},
120
+ ...Array.isArray(parsedResponseVariants) ? { responseVariants: parsedResponseVariants } : {}
88
121
  };
89
122
  } catch {
90
123
  return {};
@@ -103,6 +136,28 @@ function createBrowserChatStorage(options) {
103
136
  storage.setItem(keys.includeHistory, state.includeHistory ? "1" : "0");
104
137
  const trimmedMessages = state.messages.slice(-maxMessages).map((message) => serializeMessage(message));
105
138
  storage.setItem(keys.messages, JSON.stringify(trimmedMessages));
139
+ if (state.branches) {
140
+ const serializedBranches = Object.fromEntries(
141
+ Object.entries(state.branches).map(([sessionId, branch]) => [
142
+ sessionId,
143
+ {
144
+ ...branch,
145
+ messages: branch.messages.slice(-maxMessages).map((message) => serializeMessage(message))
146
+ }
147
+ ])
148
+ );
149
+ storage.setItem(keys.branches, JSON.stringify(serializedBranches));
150
+ } else {
151
+ storage.removeItem(keys.branches);
152
+ }
153
+ if (state.responseVariants) {
154
+ storage.setItem(
155
+ keys.responseVariants,
156
+ JSON.stringify(state.responseVariants)
157
+ );
158
+ } else {
159
+ storage.removeItem(keys.responseVariants);
160
+ }
106
161
  } catch {
107
162
  }
108
163
  },
@@ -111,6 +166,8 @@ function createBrowserChatStorage(options) {
111
166
  const storage = getLocalStorage();
112
167
  if (!storage) return;
113
168
  storage.removeItem(keys.messages);
169
+ storage.removeItem(keys.branches);
170
+ storage.removeItem(keys.responseVariants);
114
171
  } catch {
115
172
  }
116
173
  }
@@ -118,6 +175,7 @@ function createBrowserChatStorage(options) {
118
175
  }
119
176
 
120
177
  // src/chat-transport.ts
178
+ var import_errors = require("@kortyx/core/errors");
121
179
  var import_browser = require("@kortyx/stream/browser");
122
180
  function createChatTransport(args) {
123
181
  return {
@@ -132,7 +190,7 @@ function createChatTransport(args) {
132
190
  };
133
191
  }
134
192
  function createRouteChatTransport(args) {
135
- return createChatTransport({
193
+ const transport = createChatTransport({
136
194
  stream: (context) => {
137
195
  const body = args.createBody?.(context) ?? {
138
196
  sessionId: context.sessionId,
@@ -153,6 +211,47 @@ function createRouteChatTransport(args) {
153
211
  });
154
212
  }
155
213
  });
214
+ if (!args.checkpointEndpoint) return transport;
215
+ const fetchJson = async (body) => {
216
+ const fetchImpl = args.fetchImpl ?? fetch;
217
+ const response = await fetchImpl(args.checkpointEndpoint, {
218
+ method: "POST",
219
+ headers: {
220
+ "content-type": "application/json",
221
+ ...args.headers ?? {}
222
+ },
223
+ body: JSON.stringify(body)
224
+ });
225
+ if (!response.ok) {
226
+ let failure;
227
+ let message = `Checkpoint request failed with status ${response.status}`;
228
+ try {
229
+ const parsed = await response.json();
230
+ failure = parsed.failure;
231
+ if (typeof parsed.error === "string") message = parsed.error;
232
+ } catch {
233
+ }
234
+ throw (0, import_errors.isFailureDescriptor)(failure) ? (0, import_errors.errorFromFailure)(failure) : new import_errors.KortyxError("CHECKPOINT_REQUEST_FAILED", message, {
235
+ category: "transport",
236
+ status: response.status,
237
+ safeMessage: message
238
+ });
239
+ }
240
+ return await response.json();
241
+ };
242
+ return {
243
+ ...transport,
244
+ listCheckpoints: (sessionId) => fetchJson({ action: "list", sessionId }),
245
+ rollbackTo: (checkpointId) => fetchJson({
246
+ action: "rollback",
247
+ checkpointId
248
+ }),
249
+ fork: (checkpointId, options) => fetchJson({
250
+ action: "fork",
251
+ checkpointId,
252
+ ...options?.newSessionId ? { newSessionId: options.newSessionId } : {}
253
+ })
254
+ };
156
255
  }
157
256
 
158
257
  // src/create-live-chat-pieces.ts
@@ -262,7 +361,8 @@ function createLiveChatPieces(args) {
262
361
  pushPiece({
263
362
  id: args.createId(),
264
363
  type: "error",
265
- content: chunk.message ?? "An error occurred"
364
+ content: chunk.message ?? "An error occurred",
365
+ ...chunk.failure ? { failure: chunk.failure } : {}
266
366
  });
267
367
  return true;
268
368
  }
@@ -325,6 +425,7 @@ function toHumanInputPiece(args) {
325
425
  }
326
426
 
327
427
  // src/use-chat.ts
428
+ var import_errors2 = require("@kortyx/core/errors");
328
429
  var import_react3 = require("react");
329
430
 
330
431
  // src/use-chat-stream-debug.ts
@@ -437,6 +538,17 @@ function useStructuredStreams(options) {
437
538
  const get = (0, import_react2.useCallback)((streamId) => {
438
539
  return itemsRef.current.find((item) => item.streamId === streamId);
439
540
  }, []);
541
+ const deleteStream = (0, import_react2.useCallback)(
542
+ (streamId) => {
543
+ const deleted = accumulatorRef.current.delete(streamId);
544
+ if (!deleted) return false;
545
+ setNextItems(
546
+ itemsRef.current.filter((item) => item.streamId !== streamId)
547
+ );
548
+ return true;
549
+ },
550
+ [setNextItems]
551
+ );
440
552
  const byStreamId = (0, import_react2.useMemo)(
441
553
  () => Object.fromEntries(
442
554
  items.map((item) => [item.streamId, item.state])
@@ -448,7 +560,8 @@ function useStructuredStreams(options) {
448
560
  byStreamId,
449
561
  applyStreamChunk,
450
562
  clear,
451
- get
563
+ get,
564
+ delete: deleteStream
452
565
  };
453
566
  }
454
567
 
@@ -481,6 +594,62 @@ function findActiveTextInterrupt(args) {
481
594
  (piece) => piece.type === "interrupt" && piece.kind === "text"
482
595
  );
483
596
  }
597
+ function trimMessagesToCheckpoint(args) {
598
+ const hasCheckpointMetadata = args.messages.some(
599
+ (message) => typeof message.checkpointTurnIndex === "number"
600
+ );
601
+ if (!hasCheckpointMetadata && args.turnIndex > 0) return args.messages;
602
+ const keepThroughIndex = args.messages.reduce((lastIndex, message, index) => {
603
+ return typeof message.checkpointTurnIndex === "number" && message.checkpointTurnIndex <= args.turnIndex ? index : lastIndex;
604
+ }, -1);
605
+ return keepThroughIndex >= 0 ? args.messages.slice(0, keepThroughIndex + 1) : [];
606
+ }
607
+ function applyForkedPendingRequests(args) {
608
+ if (args.pendingRequests.length === 0) return args.messages;
609
+ const interruptPositions = args.messages.flatMap(
610
+ (message, messageIndex) => (message.contentPieces ?? []).flatMap(
611
+ (piece, pieceIndex) => piece.type === "interrupt" ? [{ messageIndex, pieceIndex }] : []
612
+ )
613
+ );
614
+ const positionsToPatch = interruptPositions.slice(
615
+ -args.pendingRequests.length
616
+ );
617
+ if (positionsToPatch.length === 0) return args.messages;
618
+ return args.messages.map((message, messageIndex) => {
619
+ if (!message.contentPieces) return message;
620
+ let changed = false;
621
+ const contentPieces = message.contentPieces.map((piece, pieceIndex) => {
622
+ if (piece.type !== "interrupt") return piece;
623
+ const patchIndex = positionsToPatch.findIndex(
624
+ (position) => position.messageIndex === messageIndex && position.pieceIndex === pieceIndex
625
+ );
626
+ const pending = args.pendingRequests[patchIndex];
627
+ if (!pending) return piece;
628
+ changed = true;
629
+ return {
630
+ ...piece,
631
+ resumeToken: pending.token,
632
+ requestId: pending.requestId,
633
+ ...pending.schema?.kind ? { kind: pending.schema.kind } : {},
634
+ ...typeof pending.schema?.multiple === "boolean" ? { multiple: pending.schema.multiple } : {},
635
+ ...typeof pending.schema?.question === "string" ? { question: pending.schema.question } : {},
636
+ ...typeof pending.schema?.schemaId === "string" ? { schemaId: pending.schema.schemaId } : {},
637
+ ...typeof pending.schema?.schemaVersion === "string" ? { schemaVersion: pending.schema.schemaVersion } : {},
638
+ ...typeof pending.schema?.id === "string" ? { interruptId: pending.schema.id } : {},
639
+ ...pending.schema?.meta ? { meta: pending.schema.meta } : {},
640
+ ...pending.options ? { options: pending.options } : {}
641
+ };
642
+ });
643
+ return changed ? { ...message, contentPieces } : message;
644
+ });
645
+ }
646
+ function latestAssistantMessageId(messages) {
647
+ for (let index = messages.length - 1; index >= 0; index--) {
648
+ const message = messages[index];
649
+ if (message?.role === "assistant") return message.id;
650
+ }
651
+ return null;
652
+ }
484
653
  function useChat(options) {
485
654
  const resolvedStorage = options.storage ?? defaultStorage;
486
655
  const transportRef = useLatestRef(options.transport);
@@ -495,13 +664,20 @@ function useChat(options) {
495
664
  const [error, setError] = (0, import_react3.useState)(null);
496
665
  const [streamContentPieces, setStreamContentPieces] = (0, import_react3.useState)([]);
497
666
  const [lastAssistantId, setLastAssistantId] = (0, import_react3.useState)(null);
667
+ const [checkpoints, setCheckpoints] = (0, import_react3.useState)([]);
498
668
  const [includeHistory, setIncludeHistory] = (0, import_react3.useState)(true);
499
669
  const [workflowId, setWorkflowId] = (0, import_react3.useState)("");
500
670
  const [sessionId, setSessionId] = (0, import_react3.useState)(null);
671
+ const [branches, setBranches] = (0, import_react3.useState)({});
672
+ const [responseVariants, setResponseVariants] = (0, import_react3.useState)([]);
501
673
  const [didHydrateStorage, setDidHydrateStorage] = (0, import_react3.useState)(false);
502
674
  const abortControllerRef = (0, import_react3.useRef)(null);
503
675
  const { streamDebug, clearStreamDebug, createRecorder } = useChatStreamDebug();
504
- const { applyStreamChunk, clear: clearStructuredStreams } = useStructuredStreams({
676
+ const {
677
+ applyStreamChunk,
678
+ clear: clearStructuredStreams,
679
+ delete: deleteStructuredStream
680
+ } = useStructuredStreams({
505
681
  createId
506
682
  });
507
683
  (0, import_react3.useEffect)(() => {
@@ -521,12 +697,28 @@ function useChat(options) {
521
697
  if (Array.isArray(storedState.messages) && storedState.messages.length > 0) {
522
698
  setMessages(storedState.messages);
523
699
  }
700
+ if (storedState.branches && typeof storedState.branches === "object") {
701
+ setBranches(
702
+ storedState.branches
703
+ );
704
+ }
705
+ if (Array.isArray(storedState.responseVariants)) {
706
+ setResponseVariants(
707
+ storedState.responseVariants
708
+ );
709
+ }
524
710
  setDidHydrateStorage(true);
525
711
  });
526
712
  return () => {
527
713
  cancelled = true;
528
714
  };
529
715
  }, [resolvedStorage]);
716
+ (0, import_react3.useEffect)(() => {
717
+ const nextLastAssistantId = latestAssistantMessageId(messages);
718
+ if (lastAssistantId !== nextLastAssistantId) {
719
+ setLastAssistantId(nextLastAssistantId);
720
+ }
721
+ }, [lastAssistantId, messages]);
530
722
  (0, import_react3.useEffect)(() => {
531
723
  if (!didHydrateStorage) return;
532
724
  const storage = storageRef.current;
@@ -534,21 +726,64 @@ function useChat(options) {
534
726
  sessionId,
535
727
  workflowId,
536
728
  includeHistory,
537
- messages
729
+ messages,
730
+ branches,
731
+ responseVariants
538
732
  });
539
733
  }, [
734
+ branches,
540
735
  didHydrateStorage,
541
736
  includeHistory,
542
737
  messages,
738
+ responseVariants,
543
739
  sessionId,
544
740
  storageRef,
545
741
  workflowId
546
742
  ]);
743
+ (0, import_react3.useEffect)(() => {
744
+ if (!didHydrateStorage || !sessionId) return;
745
+ setBranches((current) => ({
746
+ ...current,
747
+ [sessionId]: {
748
+ ...current[sessionId] ?? {},
749
+ sessionId,
750
+ messages,
751
+ checkpoints
752
+ }
753
+ }));
754
+ }, [checkpoints, didHydrateStorage, messages, sessionId]);
755
+ (0, import_react3.useEffect)(() => {
756
+ if (!didHydrateStorage || !sessionId) return;
757
+ setResponseVariants(
758
+ (current) => current.map((group) => {
759
+ const selectedVariant = group.variants.find(
760
+ (variant) => variant.id === group.selectedVariantId && variant.sessionId === sessionId
761
+ );
762
+ if (!selectedVariant) return group;
763
+ if (selectedVariant.messages === messages && selectedVariant.checkpoints === checkpoints) {
764
+ return group;
765
+ }
766
+ return {
767
+ ...group,
768
+ variants: group.variants.map(
769
+ (variant) => variant.id === selectedVariant.id ? {
770
+ ...variant,
771
+ messages,
772
+ checkpoints
773
+ } : variant
774
+ )
775
+ };
776
+ })
777
+ );
778
+ }, [checkpoints, didHydrateStorage, messages, sessionId]);
547
779
  const clearActiveStream = () => {
548
780
  abortControllerRef.current?.abort();
549
781
  abortControllerRef.current = null;
550
782
  setIsStreaming(false);
551
783
  setMessages([]);
784
+ setCheckpoints([]);
785
+ setBranches({});
786
+ setResponseVariants([]);
552
787
  setStreamContentPieces([]);
553
788
  clearStreamDebug();
554
789
  clearStructuredStreams();
@@ -560,6 +795,7 @@ function useChat(options) {
560
795
  };
561
796
  const resetSession = () => {
562
797
  setSessionId(null);
798
+ setCheckpoints([]);
563
799
  };
564
800
  const resetChat = () => {
565
801
  clearActiveStream();
@@ -577,7 +813,12 @@ function useChat(options) {
577
813
  const persistSessionId = (sid) => {
578
814
  setSessionId(sid);
579
815
  };
580
- const resolveSessionId = () => {
816
+ const resolveSessionId = (preferredSessionId) => {
817
+ if (preferredSessionId) {
818
+ if (sessionId !== preferredSessionId)
819
+ persistSessionId(preferredSessionId);
820
+ return preferredSessionId;
821
+ }
581
822
  const sid = sessionId ?? createId();
582
823
  if (!sessionId) persistSessionId(sid);
583
824
  return sid;
@@ -587,13 +828,14 @@ function useChat(options) {
587
828
  content: message.content
588
829
  });
589
830
  const prepareMessagesToSend = async (args) => {
831
+ const baseMessages = args.baseMessages ?? messages;
590
832
  const prepared = options.prepareContextMessages ? await options.prepareContextMessages({
591
- messages,
833
+ messages: baseMessages,
592
834
  sessionId: args.sid,
593
835
  workflowId,
594
836
  reason: args.reason,
595
837
  context: requestContext
596
- }) : includeHistory ? messages.map(toOutgoingHistoryMessage) : [];
838
+ }) : includeHistory ? baseMessages.map(toOutgoingHistoryMessage) : [];
597
839
  return [...prepared, args.outgoingMessage];
598
840
  };
599
841
  const streamAssistantResponse = async (args) => {
@@ -602,6 +844,7 @@ function useChat(options) {
602
844
  const debug = createRecorder(`${args.debugLabel} sessionId=${args.sid}`);
603
845
  let currentTrace;
604
846
  let completedTrace;
847
+ let currentCheckpoint;
605
848
  const pieces = createLiveChatPieces({
606
849
  createId,
607
850
  onChange: setStreamContentPieces,
@@ -637,8 +880,35 @@ function useChat(options) {
637
880
  }
638
881
  return;
639
882
  }
883
+ if (chunk.type === "checkpoint") {
884
+ currentCheckpoint = {
885
+ id: chunk.id,
886
+ turnIndex: chunk.turnIndex
887
+ };
888
+ setCheckpoints((prev) => {
889
+ const next = {
890
+ id: chunk.id,
891
+ sessionId: chunk.sessionId,
892
+ turnIndex: chunk.turnIndex,
893
+ createdAt: Date.now(),
894
+ nodes: [],
895
+ workflow: workflowId,
896
+ ...chunk.label ? { label: chunk.label } : {}
897
+ };
898
+ return [...prev.filter((item) => item.id !== chunk.id), next].sort(
899
+ (a, b) => a.turnIndex - b.turnIndex
900
+ );
901
+ });
902
+ return;
903
+ }
904
+ if (chunk.type === "structured-data-invalidated") {
905
+ deleteStructuredStream(chunk.streamId);
906
+ return;
907
+ }
640
908
  if (chunk.type === "error") {
641
- setError(new Error(chunk.message));
909
+ setError(
910
+ (0, import_errors2.isFailureDescriptor)(chunk.failure) ? (0, import_errors2.errorFromFailure)(chunk.failure) : new Error(chunk.message)
911
+ );
642
912
  }
643
913
  if (chunk.type === "done") {
644
914
  completedTrace = currentTrace;
@@ -649,36 +919,54 @@ function useChat(options) {
649
919
  });
650
920
  }
651
921
  });
652
- if (args.signal.aborted) return;
922
+ if (args.signal.aborted) return void 0;
653
923
  const assistant = buildAssistantMessage({
654
924
  createId,
655
925
  pieces: pieces.getPieces(),
656
926
  debug: debug.getAll(),
657
- trace: completedTrace ?? currentTrace
927
+ trace: completedTrace ?? currentTrace,
928
+ checkpoint: currentCheckpoint
658
929
  });
659
- setMessages((prev) => [...prev, assistant]);
930
+ setMessages(
931
+ (prev) => args.messagesBeforeAssistant ? [...args.messagesBeforeAssistant, assistant] : [...prev, assistant]
932
+ );
660
933
  setLastAssistantId(assistant.id);
661
934
  clearStructuredStreams();
662
935
  setStreamContentPieces([]);
663
936
  clearStreamDebug();
937
+ return assistant;
664
938
  };
665
939
  const isAbortError = (value, signal) => {
666
940
  if (signal.aborted) return true;
667
941
  if (!(value instanceof Error)) return false;
668
942
  return value.name === "AbortError";
669
943
  };
670
- const respondToHumanInput = async ({
944
+ const respondToHumanInputFromBase = async ({
671
945
  resumeToken,
672
946
  requestId,
673
947
  selected,
674
- text
948
+ text,
949
+ baseMessages,
950
+ sid
675
951
  }) => {
676
- if (isStreaming) return;
952
+ if (isStreaming) return void 0;
677
953
  setError(null);
678
954
  const label = typeof text === "string" && text.length > 0 ? text : selected.length > 0 ? selected.length === 1 ? selected[0] : selected.join(", ") : "(selection)";
679
955
  const userId = createId();
680
- const userMsg = { id: userId, role: "user", content: label };
681
- setMessages((prev) => [...prev, userMsg]);
956
+ const userMsg = {
957
+ id: userId,
958
+ role: "user",
959
+ content: label,
960
+ source: {
961
+ type: "interrupt-response",
962
+ resumeToken,
963
+ requestId,
964
+ selected,
965
+ ...text !== void 0 ? { text } : {}
966
+ }
967
+ };
968
+ const nextMessages = [...baseMessages ?? messages, userMsg];
969
+ setMessages(nextMessages);
682
970
  setIsStreaming(true);
683
971
  setStreamContentPieces([]);
684
972
  clearStreamDebug();
@@ -694,18 +982,25 @@ function useChat(options) {
694
982
  content: label,
695
983
  metadata: resumePayload
696
984
  };
697
- const sid = resolveSessionId();
985
+ const resolvedSessionId = resolveSessionId(sid);
698
986
  const messagesToSend = await prepareMessagesToSend({
699
- sid,
987
+ sid: resolvedSessionId,
700
988
  outgoingMessage: outgoing,
701
- reason: "resume"
989
+ reason: "resume",
990
+ baseMessages
702
991
  });
703
- await streamAssistantResponse({
704
- sid,
992
+ const assistantMessage = await streamAssistantResponse({
993
+ sid: resolvedSessionId,
705
994
  messagesToSend,
995
+ messagesBeforeAssistant: nextMessages,
706
996
  debugLabel: "runChat (resume)",
707
997
  signal: abortController.signal
708
998
  });
999
+ return {
1000
+ userMessage: userMsg,
1001
+ ...assistantMessage ? { assistantMessage } : {},
1002
+ messages: assistantMessage ? [...nextMessages, assistantMessage] : nextMessages
1003
+ };
709
1004
  } catch (err) {
710
1005
  if (isAbortError(err, abortController.signal)) return;
711
1006
  const nextError = err instanceof Error ? err : new Error(String(err));
@@ -718,36 +1013,45 @@ function useChat(options) {
718
1013
  setIsStreaming(false);
719
1014
  }
720
1015
  };
1016
+ const respondToHumanInput = async (args) => {
1017
+ await respondToHumanInputFromBase(args);
1018
+ };
721
1019
  const respondToInterrupt = async (piece, response) => {
722
1020
  const text = response?.text;
723
1021
  const selected = response?.selected ?? (typeof text === "string" && text.length > 0 ? [text] : []);
724
- return respondToHumanInput({
1022
+ await respondToHumanInput({
725
1023
  resumeToken: piece.resumeToken,
726
1024
  requestId: piece.requestId,
727
1025
  selected,
728
1026
  ...text !== void 0 ? { text } : {}
729
1027
  });
730
1028
  };
731
- const send = async (text) => {
1029
+ const sendFromBase = async (text, baseMessages, sid) => {
732
1030
  const content = text.trim();
733
- if (!content || isStreaming) return;
1031
+ if (!content || isStreaming) return void 0;
734
1032
  setError(null);
735
1033
  const activeTextInterrupt = findActiveTextInterrupt({
736
- messages,
1034
+ messages: baseMessages ?? messages,
737
1035
  streamContentPieces
738
1036
  });
739
1037
  if (activeTextInterrupt) {
740
- await respondToHumanInput({
1038
+ return respondToHumanInputFromBase({
741
1039
  resumeToken: activeTextInterrupt.resumeToken,
742
1040
  requestId: activeTextInterrupt.requestId,
743
1041
  selected: [content],
744
- text: content
1042
+ text: content,
1043
+ baseMessages,
1044
+ sid
745
1045
  });
746
- return;
747
1046
  }
748
1047
  const userId = createId();
749
- const userMsg = { id: userId, role: "user", content };
750
- setMessages((prev) => [...prev, userMsg]);
1048
+ const userMsg = {
1049
+ id: userId,
1050
+ role: "user",
1051
+ content
1052
+ };
1053
+ const nextMessages = [...baseMessages ?? messages, userMsg];
1054
+ setMessages(nextMessages);
751
1055
  setIsStreaming(true);
752
1056
  setStreamContentPieces([]);
753
1057
  clearStreamDebug();
@@ -755,19 +1059,26 @@ function useChat(options) {
755
1059
  const abortController = new AbortController();
756
1060
  abortControllerRef.current = abortController;
757
1061
  try {
758
- const sid = resolveSessionId();
1062
+ const resolvedSessionId = resolveSessionId(sid);
759
1063
  const messagesToSend = await prepareMessagesToSend({
760
- sid,
1064
+ sid: resolvedSessionId,
761
1065
  outgoingMessage: { role: "user", content },
762
- reason: "send"
1066
+ reason: "send",
1067
+ baseMessages
763
1068
  });
764
- await streamAssistantResponse({
765
- sid,
1069
+ const assistantMessage = await streamAssistantResponse({
1070
+ sid: resolvedSessionId,
766
1071
  messagesToSend,
1072
+ messagesBeforeAssistant: nextMessages,
767
1073
  debugLabel: "runChat (send)",
768
1074
  openDebugOnInterrupt: true,
769
1075
  signal: abortController.signal
770
1076
  });
1077
+ return {
1078
+ userMessage: userMsg,
1079
+ ...assistantMessage ? { assistantMessage } : {},
1080
+ messages: assistantMessage ? [...nextMessages, assistantMessage] : nextMessages
1081
+ };
771
1082
  } catch (err) {
772
1083
  if (isAbortError(err, abortController.signal)) return;
773
1084
  const nextError = err instanceof Error ? err : new Error(String(err));
@@ -780,6 +1091,375 @@ function useChat(options) {
780
1091
  setIsStreaming(false);
781
1092
  }
782
1093
  };
1094
+ const send = async (text) => {
1095
+ await sendFromBase(text);
1096
+ };
1097
+ const requireCheckpointTransport = () => {
1098
+ const transport = transportRef.current;
1099
+ if (!transport.listCheckpoints || !transport.rollbackTo || !transport.fork) {
1100
+ throw new Error(
1101
+ "Checkpoint helpers require a transport with checkpoint methods."
1102
+ );
1103
+ }
1104
+ return transport;
1105
+ };
1106
+ const refreshCheckpoints = async (sid) => {
1107
+ const transport = requireCheckpointTransport();
1108
+ const next = await transport.listCheckpoints(sid);
1109
+ setCheckpoints(next);
1110
+ return next;
1111
+ };
1112
+ const applyStructuredInvalidations = (streamIds) => {
1113
+ for (const streamId of streamIds) {
1114
+ deleteStructuredStream(streamId);
1115
+ }
1116
+ };
1117
+ const rollbackTo = async (id) => {
1118
+ if (isStreaming) {
1119
+ throw new Error("Cannot rollback while a stream is active.");
1120
+ }
1121
+ const transport = requireCheckpointTransport();
1122
+ const result = await transport.rollbackTo(id);
1123
+ applyStructuredInvalidations(result.invalidatedStructuredStreamIds);
1124
+ const refreshedCheckpoints = await refreshCheckpoints(result.sessionId);
1125
+ const target = refreshedCheckpoints.find(
1126
+ (checkpoint) => checkpoint.id === result.head
1127
+ );
1128
+ if (target) {
1129
+ setMessages((current) => {
1130
+ return trimMessagesToCheckpoint({
1131
+ messages: current,
1132
+ turnIndex: target.turnIndex
1133
+ });
1134
+ });
1135
+ }
1136
+ return result;
1137
+ };
1138
+ const fork = async (id, options2) => {
1139
+ if (isStreaming) {
1140
+ throw new Error("Cannot fork while a stream is active.");
1141
+ }
1142
+ const transport = requireCheckpointTransport();
1143
+ const sid = resolveSessionId();
1144
+ let availableCheckpoints = checkpoints.length > 0 ? checkpoints : await refreshCheckpoints(sid);
1145
+ let target = availableCheckpoints.find(
1146
+ (checkpoint) => checkpoint.id === id
1147
+ );
1148
+ if (!target && checkpoints.length > 0) {
1149
+ availableCheckpoints = await refreshCheckpoints(sid);
1150
+ target = availableCheckpoints.find((checkpoint) => checkpoint.id === id);
1151
+ }
1152
+ const result = await transport.fork(id, options2);
1153
+ setSessionId(result.sessionId);
1154
+ const childCheckpoints = await refreshCheckpoints(result.sessionId);
1155
+ const childHead = childCheckpoints.at(-1);
1156
+ const forkTurnIndex = target?.turnIndex ?? childHead?.turnIndex;
1157
+ if (typeof forkTurnIndex === "number") {
1158
+ setMessages(
1159
+ (current) => applyForkedPendingRequests({
1160
+ messages: trimMessagesToCheckpoint({
1161
+ messages: current,
1162
+ turnIndex: forkTurnIndex
1163
+ }),
1164
+ pendingRequests: result.checkpoint?.activePendingRequests ?? []
1165
+ })
1166
+ );
1167
+ }
1168
+ setStreamContentPieces([]);
1169
+ clearStructuredStreams();
1170
+ return result;
1171
+ };
1172
+ const checkpointForMessage = (messageId) => {
1173
+ return messages.find((message) => message.id === messageId)?.checkpointId ?? null;
1174
+ };
1175
+ const variantForMessage = (messageId) => {
1176
+ return responseVariants.find(
1177
+ (group) => group.sourceAssistantMessageId === messageId || group.variants.some(
1178
+ (variant) => variant.assistantMessageId === messageId
1179
+ )
1180
+ ) ?? null;
1181
+ };
1182
+ const findResumeOverrideForFork = (baseMessages, forkedBaseMessages, userMessage) => {
1183
+ if (userMessage.source?.type !== "interrupt-response") return void 0;
1184
+ const userSource = userMessage.source;
1185
+ const interruptPositions = baseMessages.flatMap(
1186
+ (message, messageIndex) => (message.contentPieces ?? []).flatMap(
1187
+ (piece, pieceIndex) => piece.type === "interrupt" ? [{ messageIndex, pieceIndex, piece }] : []
1188
+ )
1189
+ );
1190
+ const matchedPosition = [...interruptPositions].reverse().find(
1191
+ ({ piece }) => piece.resumeToken === userSource.resumeToken || piece.requestId === userSource.requestId
1192
+ );
1193
+ const forkedPiece = typeof matchedPosition?.messageIndex === "number" ? forkedBaseMessages[matchedPosition.messageIndex]?.contentPieces?.[matchedPosition.pieceIndex] : void 0;
1194
+ if (forkedPiece?.type === "interrupt") {
1195
+ return {
1196
+ resumeToken: forkedPiece.resumeToken,
1197
+ requestId: forkedPiece.requestId
1198
+ };
1199
+ }
1200
+ const latestForkedInterrupt = forkedBaseMessages.flatMap((message) => message.contentPieces ?? []).filter((piece) => piece.type === "interrupt").at(-1);
1201
+ return latestForkedInterrupt ? {
1202
+ resumeToken: latestForkedInterrupt.resumeToken,
1203
+ requestId: latestForkedInterrupt.requestId
1204
+ } : void 0;
1205
+ };
1206
+ const resolveRegenerateTarget = async (assistantMessageId) => {
1207
+ const assistantIndex = messages.findIndex(
1208
+ (message) => message.id === assistantMessageId
1209
+ );
1210
+ if (assistantIndex < 0) {
1211
+ throw new Error(`Assistant message "${assistantMessageId}" not found.`);
1212
+ }
1213
+ const assistant = messages[assistantIndex];
1214
+ if (!assistant) {
1215
+ throw new Error(`Assistant message "${assistantMessageId}" not found.`);
1216
+ }
1217
+ if (assistant.role !== "assistant") {
1218
+ throw new Error("Regenerate expects an assistant message id.");
1219
+ }
1220
+ let userIndex = -1;
1221
+ for (let index = assistantIndex - 1; index >= 0; index--) {
1222
+ if (messages[index]?.role === "user") {
1223
+ userIndex = index;
1224
+ break;
1225
+ }
1226
+ }
1227
+ if (userIndex < 0) {
1228
+ throw new Error("No preceding user message found for regenerate.");
1229
+ }
1230
+ const previousUser = messages[userIndex];
1231
+ const sid = resolveSessionId();
1232
+ let availableCheckpoints = checkpoints.length > 0 ? checkpoints : await refreshCheckpoints(sid);
1233
+ const assistantTurn = assistant.checkpointTurnIndex;
1234
+ const targetTurn = typeof assistantTurn === "number" ? assistantTurn - 1 : void 0;
1235
+ let target = targetTurn !== void 0 ? availableCheckpoints.find(
1236
+ (checkpoint) => checkpoint.turnIndex === targetTurn
1237
+ ) : availableCheckpoints.filter(
1238
+ (checkpoint) => checkpoint.turnIndex < Number.MAX_SAFE_INTEGER
1239
+ ).at(-2);
1240
+ if (!target && checkpoints.length > 0) {
1241
+ availableCheckpoints = await refreshCheckpoints(sid);
1242
+ target = targetTurn !== void 0 ? availableCheckpoints.find(
1243
+ (checkpoint) => checkpoint.turnIndex === targetTurn
1244
+ ) : availableCheckpoints.filter(
1245
+ (checkpoint) => checkpoint.turnIndex < Number.MAX_SAFE_INTEGER
1246
+ ).at(-2);
1247
+ }
1248
+ if (!target) {
1249
+ throw new Error("No rollback checkpoint found for regenerate.");
1250
+ }
1251
+ const baseMessages = trimMessagesToCheckpoint({
1252
+ messages,
1253
+ turnIndex: target.turnIndex
1254
+ });
1255
+ return { target, assistant, previousUser, baseMessages };
1256
+ };
1257
+ const replayUserMessage = async (userMessage, baseMessages, editedContent, options2) => {
1258
+ const content = editedContent ?? userMessage.content;
1259
+ if (userMessage.source?.type === "interrupt-response") {
1260
+ return respondToHumanInputFromBase({
1261
+ resumeToken: options2?.resumeOverride?.resumeToken ?? userMessage.source.resumeToken,
1262
+ requestId: options2?.resumeOverride?.requestId ?? userMessage.source.requestId,
1263
+ selected: editedContent !== void 0 ? [editedContent] : userMessage.source.selected,
1264
+ baseMessages,
1265
+ sid: options2?.sid,
1266
+ ...editedContent !== void 0 ? { text: editedContent } : userMessage.source.text !== void 0 ? { text: userMessage.source.text } : {}
1267
+ });
1268
+ }
1269
+ return sendFromBase(content, baseMessages, options2?.sid);
1270
+ };
1271
+ const buildVariant = (args) => ({
1272
+ id: args.id,
1273
+ sessionId: args.sessionId,
1274
+ assistantMessageId: args.assistantMessage.id,
1275
+ createdAt: Date.now(),
1276
+ messages: args.messages,
1277
+ checkpoints: args.checkpoints,
1278
+ ...args.assistantMessage.checkpointId ? { checkpointId: args.assistantMessage.checkpointId } : {},
1279
+ ...typeof args.assistantMessage.checkpointTurnIndex === "number" ? { checkpointTurnIndex: args.assistantMessage.checkpointTurnIndex } : {},
1280
+ ...args.label ? { label: args.label } : {}
1281
+ });
1282
+ const updateVariantSnapshot = (args) => ({
1283
+ ...args.variant,
1284
+ sessionId: args.sessionId,
1285
+ assistantMessageId: args.assistantMessage.id,
1286
+ messages: args.messages,
1287
+ checkpoints: args.checkpoints,
1288
+ ...args.assistantMessage.checkpointId ? { checkpointId: args.assistantMessage.checkpointId } : {},
1289
+ ...typeof args.assistantMessage.checkpointTurnIndex === "number" ? { checkpointTurnIndex: args.assistantMessage.checkpointTurnIndex } : {}
1290
+ });
1291
+ const replaceResponseVariantGroup = (nextGroup) => {
1292
+ setResponseVariants((current) => [
1293
+ ...current.filter((group) => group.id !== nextGroup.id),
1294
+ nextGroup
1295
+ ]);
1296
+ };
1297
+ const regenerateVariant = async (assistantMessageId) => {
1298
+ if (isStreaming) {
1299
+ throw new Error("Cannot regenerate a variant while a stream is active.");
1300
+ }
1301
+ const { target, assistant, previousUser, baseMessages } = await resolveRegenerateTarget(assistantMessageId);
1302
+ const transport = requireCheckpointTransport();
1303
+ const parentSessionId = resolveSessionId();
1304
+ const existingGroup = variantForMessage(assistantMessageId);
1305
+ const sourceAssistantMessageId = existingGroup?.sourceAssistantMessageId ?? assistantMessageId;
1306
+ const existingVariants = existingGroup ? existingGroup.variants.map(
1307
+ (variant) => variant.id === existingGroup.selectedVariantId || variant.assistantMessageId === assistantMessageId ? updateVariantSnapshot({
1308
+ variant,
1309
+ sessionId: parentSessionId,
1310
+ assistantMessage: assistant,
1311
+ messages,
1312
+ checkpoints
1313
+ }) : variant
1314
+ ) : void 0;
1315
+ const originalVariant = existingVariants?.[0] ?? buildVariant({
1316
+ id: `${sourceAssistantMessageId}:original`,
1317
+ sessionId: parentSessionId,
1318
+ assistantMessage: assistant,
1319
+ messages,
1320
+ checkpoints,
1321
+ label: "Original"
1322
+ });
1323
+ const forkResult = await transport.fork(target.id);
1324
+ const forkedSessionId = forkResult.sessionId;
1325
+ setSessionId(forkedSessionId);
1326
+ const childCheckpointsBeforeReplay = await refreshCheckpoints(forkedSessionId);
1327
+ const forkedBaseMessages = applyForkedPendingRequests({
1328
+ messages: baseMessages,
1329
+ pendingRequests: forkResult.checkpoint?.activePendingRequests ?? []
1330
+ });
1331
+ const resumeOverride = findResumeOverrideForFork(
1332
+ baseMessages,
1333
+ forkedBaseMessages,
1334
+ previousUser
1335
+ );
1336
+ setMessages(forkedBaseMessages);
1337
+ setStreamContentPieces([]);
1338
+ clearStructuredStreams();
1339
+ const replay = await replayUserMessage(
1340
+ previousUser,
1341
+ forkedBaseMessages,
1342
+ void 0,
1343
+ {
1344
+ sid: forkedSessionId,
1345
+ resumeOverride
1346
+ }
1347
+ );
1348
+ const childCheckpointsAfterReplay = await refreshCheckpoints(forkedSessionId);
1349
+ const variantMessages = replay?.messages ?? forkedBaseMessages;
1350
+ const variantAssistant = replay?.assistantMessage;
1351
+ if (!variantAssistant) {
1352
+ throw new Error("Regenerate variant did not produce an assistant reply.");
1353
+ }
1354
+ const nextVariant = buildVariant({
1355
+ id: createId(),
1356
+ sessionId: forkedSessionId,
1357
+ assistantMessage: variantAssistant,
1358
+ messages: variantMessages,
1359
+ checkpoints: childCheckpointsAfterReplay.length > 0 ? childCheckpointsAfterReplay : childCheckpointsBeforeReplay,
1360
+ label: `Variant ${(existingGroup?.variants.length ?? 1) + 1}`
1361
+ });
1362
+ const nextVariants = existingVariants ? [...existingVariants, nextVariant] : [originalVariant, nextVariant];
1363
+ const nextGroup = {
1364
+ id: existingGroup?.id ?? createId(),
1365
+ sourceAssistantMessageId,
1366
+ checkpointId: existingGroup?.checkpointId ?? target.id,
1367
+ checkpointTurnIndex: existingGroup?.checkpointTurnIndex ?? target.turnIndex,
1368
+ selectedVariantId: nextVariant.id,
1369
+ variants: nextVariants
1370
+ };
1371
+ replaceResponseVariantGroup(nextGroup);
1372
+ setBranches((current) => ({
1373
+ ...current,
1374
+ [parentSessionId]: {
1375
+ ...current[parentSessionId] ?? {},
1376
+ sessionId: parentSessionId,
1377
+ messages,
1378
+ checkpoints
1379
+ },
1380
+ [forkedSessionId]: {
1381
+ ...current[forkedSessionId] ?? {},
1382
+ sessionId: forkedSessionId,
1383
+ parentSessionId: forkResult.parentSessionId,
1384
+ forkedFrom: forkResult.forkedFrom,
1385
+ messages: variantMessages,
1386
+ checkpoints: nextVariant.checkpoints
1387
+ }
1388
+ }));
1389
+ return nextGroup;
1390
+ };
1391
+ const selectVariant = async (assistantMessageId, variantId) => {
1392
+ const group = variantForMessage(assistantMessageId);
1393
+ if (!group) {
1394
+ throw new Error(
1395
+ `No response variants found for "${assistantMessageId}".`
1396
+ );
1397
+ }
1398
+ const selectedVariant = group.variants.find(
1399
+ (variant) => variant.id === variantId
1400
+ );
1401
+ if (!selectedVariant) {
1402
+ throw new Error(`Response variant "${variantId}" not found.`);
1403
+ }
1404
+ setSessionId(selectedVariant.sessionId);
1405
+ setMessages(selectedVariant.messages);
1406
+ setCheckpoints(selectedVariant.checkpoints);
1407
+ setStreamContentPieces([]);
1408
+ clearStructuredStreams();
1409
+ setBranches((current) => ({
1410
+ ...current,
1411
+ [selectedVariant.sessionId]: {
1412
+ ...current[selectedVariant.sessionId] ?? {},
1413
+ sessionId: selectedVariant.sessionId,
1414
+ messages: selectedVariant.messages,
1415
+ checkpoints: selectedVariant.checkpoints
1416
+ }
1417
+ }));
1418
+ const nextGroup = {
1419
+ ...group,
1420
+ selectedVariantId: selectedVariant.id
1421
+ };
1422
+ replaceResponseVariantGroup(nextGroup);
1423
+ };
1424
+ const regenerate = async (assistantMessageId) => {
1425
+ const { target, previousUser, baseMessages } = await resolveRegenerateTarget(assistantMessageId);
1426
+ await rollbackTo(target.id);
1427
+ setMessages(baseMessages);
1428
+ await replayUserMessage(previousUser, baseMessages);
1429
+ };
1430
+ const regenerateFromCheckpoint = async (checkpointId) => {
1431
+ const sid = resolveSessionId();
1432
+ let availableCheckpoints = checkpoints.length > 0 ? checkpoints : await refreshCheckpoints(sid);
1433
+ let target = availableCheckpoints.find(
1434
+ (checkpoint) => checkpoint.id === checkpointId
1435
+ );
1436
+ if (!target && checkpoints.length > 0) {
1437
+ availableCheckpoints = await refreshCheckpoints(sid);
1438
+ target = availableCheckpoints.find(
1439
+ (checkpoint) => checkpoint.id === checkpointId
1440
+ );
1441
+ }
1442
+ if (!target) {
1443
+ throw new Error(`Checkpoint "${checkpointId}" not found.`);
1444
+ }
1445
+ const baseMessages = trimMessagesToCheckpoint({
1446
+ messages,
1447
+ turnIndex: target.turnIndex
1448
+ });
1449
+ const previousUser = messages.slice(baseMessages.length).find((message) => message.role === "user");
1450
+ if (!previousUser) {
1451
+ throw new Error("No user message found after checkpoint for regenerate.");
1452
+ }
1453
+ await rollbackTo(target.id);
1454
+ setMessages(baseMessages);
1455
+ await replayUserMessage(previousUser, baseMessages);
1456
+ };
1457
+ const retryWithEdit = async (assistantMessageId, newUserContent) => {
1458
+ const { target, previousUser, baseMessages } = await resolveRegenerateTarget(assistantMessageId);
1459
+ await rollbackTo(target.id);
1460
+ setMessages(baseMessages);
1461
+ await replayUserMessage(previousUser, baseMessages, newUserContent);
1462
+ };
783
1463
  return {
784
1464
  messages,
785
1465
  isStreaming,
@@ -788,12 +1468,25 @@ function useChat(options) {
788
1468
  streamContentPieces,
789
1469
  streamDebug,
790
1470
  lastAssistantId,
1471
+ checkpoints,
1472
+ activeSessionId: sessionId,
1473
+ branches,
1474
+ responseVariants,
791
1475
  workflowId,
792
1476
  setWorkflowId,
793
1477
  send,
794
1478
  respondToHumanInput,
795
1479
  respondToInterrupt,
796
1480
  setMessages,
1481
+ checkpointForMessage,
1482
+ rollbackTo,
1483
+ fork,
1484
+ regenerate,
1485
+ regenerateVariant,
1486
+ selectVariant,
1487
+ variantForMessage,
1488
+ regenerateFromCheckpoint,
1489
+ retryWithEdit,
797
1490
  clearMessages,
798
1491
  resetSession,
799
1492
  resetChat,