@assistant-ui/react-langchain 0.0.34 → 0.0.36

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 (37) hide show
  1. package/dist/LangChainThreadController.d.ts +9 -0
  2. package/dist/LangChainThreadController.d.ts.map +1 -0
  3. package/dist/LangChainThreadController.js +21 -0
  4. package/dist/LangChainThreadController.js.map +1 -0
  5. package/dist/convertMessages.d.ts +2 -1
  6. package/dist/convertMessages.d.ts.map +1 -1
  7. package/dist/convertMessages.js +12 -12
  8. package/dist/convertMessages.js.map +1 -1
  9. package/dist/converter.d.ts +24 -1
  10. package/dist/converter.d.ts.map +1 -1
  11. package/dist/converter.js +84 -18
  12. package/dist/converter.js.map +1 -1
  13. package/dist/langChainThreadState.d.ts +54 -0
  14. package/dist/langChainThreadState.d.ts.map +1 -0
  15. package/dist/langChainThreadState.js +135 -0
  16. package/dist/langChainThreadState.js.map +1 -0
  17. package/dist/sdkIdentity.js +1 -1
  18. package/dist/types.d.ts +9 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/useStreamRuntime.d.ts +3 -2
  21. package/dist/useStreamRuntime.d.ts.map +1 -1
  22. package/dist/useStreamRuntime.js +159 -144
  23. package/dist/useStreamRuntime.js.map +1 -1
  24. package/dist/useSubagentTranscripts.js +1 -1
  25. package/package.json +9 -8
  26. package/src/LangChainThreadController.ts +24 -0
  27. package/src/convertMessages.test.ts +23 -0
  28. package/src/convertMessages.ts +43 -17
  29. package/src/converter.test.ts +322 -0
  30. package/src/converter.ts +138 -27
  31. package/src/getPendingToolCalls.test.ts +35 -0
  32. package/src/langChainThreadState.test.ts +203 -0
  33. package/src/langChainThreadState.ts +239 -0
  34. package/src/types.ts +9 -0
  35. package/src/useStreamRuntime.cloud-options.test.tsx +61 -0
  36. package/src/useStreamRuntime.test.tsx +296 -8
  37. package/src/useStreamRuntime.ts +221 -249
@@ -8,6 +8,7 @@ import {
8
8
  useMemo,
9
9
  useRef,
10
10
  useState,
11
+ useSyncExternalStore,
11
12
  } from "react";
12
13
  import type { AppendMessage, ToolExecutionStatus } from "@assistant-ui/core";
13
14
  import {
@@ -39,9 +40,11 @@ import type {
39
40
  import { getMessageModality, groupUIMessagesByParent } from "./converter";
40
41
  export { groupUIMessagesByParent } from "./converter";
41
42
  import {
43
+ createLangChainMetadataKey,
42
44
  convertLangChainBaseMessage,
43
45
  getMessageContent,
44
46
  getMessageType,
47
+ type LangChainMessageConverterMetadata,
45
48
  } from "./convertMessages";
46
49
  import {
47
50
  attachSubagentTranscripts,
@@ -60,6 +63,8 @@ import { langChainExtras } from "./runtimeExtras";
60
63
  import { resolveForkCheckpoint } from "./resolveForkCheckpoint";
61
64
  import { useLangChainStreamingTiming } from "./streamingTiming";
62
65
  import { LANGCHAIN_SDK } from "./sdkIdentity";
66
+ import { LangChainThreadController } from "./LangChainThreadController";
67
+ import type { LangChainThreadAction } from "./langChainThreadState";
63
68
 
64
69
  export const runConfigToSubmitOptions = (
65
70
  runConfig: AppendMessage["runConfig"],
@@ -72,14 +77,20 @@ type NormalizedRunConfigOptions = NonNullable<
72
77
  ReturnType<typeof runConfigToSubmitOptions>
73
78
  >;
74
79
 
75
- const getPendingToolCalls = (
80
+ export const getPendingToolCalls = (
76
81
  messages: readonly LangChainBaseMessage[],
77
82
  ): LangChainToolCall[] =>
78
83
  scanPendingToolCalls(
79
84
  messages,
80
85
  (message) => {
81
86
  const type = getMessageType(message);
82
- if (type === "ai") return { toolCalls: message.tool_calls ?? [] };
87
+ if (type === "ai") {
88
+ return {
89
+ toolCalls: (message.tool_calls ?? []).filter(
90
+ (toolCall) => typeof toolCall === "object" && toolCall !== null,
91
+ ),
92
+ };
93
+ }
83
94
  if (type === "tool" && message.tool_call_id) {
84
95
  return { toolCallId: message.tool_call_id };
85
96
  }
@@ -106,26 +117,6 @@ const toStagedMessageInput = (message: LangChainBaseMessage) => ({
106
117
  }),
107
118
  });
108
119
 
109
- const humanContentText = (content: LangChainBaseMessage["content"]) => {
110
- if (typeof content === "string") return content;
111
- if (!Array.isArray(content)) return "";
112
- return content
113
- .filter(
114
- (part): part is { type: "text"; text: string } =>
115
- typeof part === "object" &&
116
- part !== null &&
117
- part.type === "text" &&
118
- typeof part.text === "string",
119
- )
120
- .map((part) => part.text)
121
- .join("");
122
- };
123
-
124
- const hasSameMessageContent = (
125
- a: LangChainBaseMessage,
126
- b: LangChainBaseMessage,
127
- ) => humanContentText(a.content) === humanContentText(b.content);
128
-
129
120
  const truncateLangChainBaseMessages = (
130
121
  threadMessages: readonly ThreadMessage[],
131
122
  parentId: string | null,
@@ -149,7 +140,7 @@ type DistributiveOmit<T, K extends keyof any> = T extends unknown
149
140
  const useStreamThreadRuntime = (
150
141
  options: DistributiveOmit<
151
142
  UseStreamRuntimeOptions,
152
- "cloud" | "unstable_threadListAdapter" | "create" | "delete"
143
+ "cloud" | "scopeId" | "unstable_threadListAdapter" | "create" | "delete"
153
144
  >,
154
145
  ) => {
155
146
  const { adapters, autoCancelPendingToolCalls, unstable_allowCancellation } =
@@ -166,9 +157,19 @@ const useStreamThreadRuntime = (
166
157
  const stream = useStream(
167
158
  Object.assign({}, options, { threadId: externalId }),
168
159
  );
169
- const [stagedMessages, setStagedMessages] = useState<
170
- LangChainBaseMessage[] | null
171
- >(null);
160
+ const [threadController] = useState(() => new LangChainThreadController());
161
+ const getVisibleStagedMessages = () =>
162
+ threadController.getState().visibleStagedMessages;
163
+ const visibleStagedMessages = useSyncExternalStore(
164
+ threadController.subscribe,
165
+ getVisibleStagedMessages,
166
+ getVisibleStagedMessages,
167
+ );
168
+ const forkPending = useSyncExternalStore(
169
+ threadController.subscribe,
170
+ () => threadController.getState().forkPending,
171
+ () => threadController.getState().forkPending,
172
+ );
172
173
 
173
174
  const [toolStatuses, setToolStatuses] = useState<
174
175
  Record<string, ToolExecutionStatus>
@@ -202,7 +203,7 @@ const useStreamThreadRuntime = (
202
203
  );
203
204
 
204
205
  const visibleMessages =
205
- stagedMessages ?? (stream.messages as LangChainBaseMessage[]);
206
+ visibleStagedMessages ?? (stream.messages as LangChainBaseMessage[]);
206
207
 
207
208
  const messageTiming = useLangChainStreamingTiming(
208
209
  visibleMessages,
@@ -214,22 +215,21 @@ const useStreamThreadRuntime = (
214
215
  uiMessagesByParent,
215
216
  );
216
217
 
217
- const convertWithUI = useMemo<
218
- useExternalMessageConverter.Callback<LangChainBaseMessage>
219
- >(
220
- () => (message, metadata) =>
221
- convertLangChainBaseMessage(message, {
222
- ...metadata,
223
- uiMessagesByParent,
224
- messageTiming,
225
- }),
218
+ const [getConverterMetadataKey] = useState(createLangChainMetadataKey);
219
+ const converterMetadata = useMemo<LangChainMessageConverterMetadata>(
220
+ () => ({
221
+ uiMessagesByParent: uiMessagesByParent,
222
+ messageTiming,
223
+ }),
226
224
  [uiMessagesByParent, messageTiming],
227
225
  );
228
226
 
229
227
  const threadMessages = useExternalMessageConverter({
230
- callback: convertWithUI,
228
+ callback: convertLangChainBaseMessage,
231
229
  messages: visibleMessages,
232
230
  isRunning: effectiveIsRunning,
231
+ metadata: converterMetadata,
232
+ getMetadataKey: getConverterMetadataKey,
233
233
  });
234
234
  const [memo] = useState(createAttachMemo);
235
235
  const messagesWithTranscripts = useMemo(
@@ -308,85 +308,42 @@ const useStreamThreadRuntime = (
308
308
  visibleMessagesRef.current = visibleMessages;
309
309
  }, [visibleMessages]);
310
310
 
311
+ const dispatchStaging = useCallback(
312
+ (action: LangChainThreadAction, fallback = visibleMessagesRef.current) => {
313
+ threadController.dispatch(action);
314
+ visibleMessagesRef.current =
315
+ threadController.getState().visibleStagedMessages ?? fallback;
316
+ },
317
+ [threadController],
318
+ );
319
+
311
320
  const threadMessagesRef = useRef(messagesWithTranscripts);
312
321
  useInsertionEffect(() => {
313
322
  threadMessagesRef.current = messagesWithTranscripts;
314
323
  }, [messagesWithTranscripts]);
315
324
 
316
- const stagedMessagesRef = useRef(
317
- new Map<
318
- string,
319
- {
320
- message: LangChainBaseMessage & { id: string };
321
- runConfig: AppendMessage["runConfig"];
322
- reconcileOnEcho: boolean;
323
- baseMessageCount: number;
324
- transcriptStatus?: "unsent" | "sent";
325
- }
326
- >(),
327
- );
328
- const stagedBaseMessagesRef = useRef<LangChainBaseMessage[] | null>(null);
329
325
  useEffect(() => {
330
- if (stagedMessagesRef.current.size === 0) return;
331
-
332
- // Staged edits must keep their truncated base while stream updates arrive before promotion.
333
- const baseMessages =
334
- stagedBaseMessagesRef.current ??
335
- (stream.messages as LangChainBaseMessage[]);
336
- const remainingStagedMessages: LangChainBaseMessage[] = [];
337
- const matchedBaseMessageIndexes = new Set<number>();
338
- const visibleStagedIds = new Set(
339
- visibleMessagesRef.current.flatMap((m) => (m.id ? [m.id] : [])),
326
+ const state = threadController.getState();
327
+ if (state.stagedEntries.size === 0) return;
328
+ const messages = stream.messages as LangChainBaseMessage[];
329
+ dispatchStaging(
330
+ {
331
+ type: "reconcile",
332
+ messages,
333
+ visibleMessages: visibleMessagesRef.current,
334
+ },
335
+ state.stagedBaseMessages ?? messages,
340
336
  );
341
- for (const [id, staged] of stagedMessagesRef.current) {
342
- if (!visibleStagedIds.has(id)) continue;
343
- const echoed = baseMessages.some((message, index) => {
344
- if (matchedBaseMessageIndexes.has(index)) return false;
345
- if (message.id === id) {
346
- matchedBaseMessageIndexes.add(index);
347
- return true;
348
- }
349
- if (
350
- !staged.reconcileOnEcho ||
351
- index < staged.baseMessageCount ||
352
- getMessageType(message) !== "human" ||
353
- !hasSameMessageContent(message, staged.message)
354
- ) {
355
- return false;
356
- }
357
- matchedBaseMessageIndexes.add(index);
358
- return true;
359
- });
360
- if (echoed) stagedMessagesRef.current.delete(id);
361
- else remainingStagedMessages.push(staged.message);
362
- }
363
-
364
- if (remainingStagedMessages.length === 0) {
365
- stagedBaseMessagesRef.current = null;
366
- visibleMessagesRef.current = baseMessages;
367
- // Reconciling against the upstream stream mutates the staged refs above,
368
- // which cannot happen during render.
369
- // eslint-disable-next-line react-hooks/set-state-in-effect
370
- setStagedMessages(null);
371
- return;
372
- }
373
-
374
- const nextMessages = [...baseMessages, ...remainingStagedMessages];
375
- visibleMessagesRef.current = nextMessages;
376
- setStagedMessages(nextMessages);
377
- }, [stream.messages]);
337
+ }, [stream.messages, threadController, dispatchStaging]);
378
338
 
379
339
  const getStagedRun = (parentId: string | null) => {
380
- const parent = parentId
381
- ? stagedMessagesRef.current.get(parentId)
382
- : undefined;
340
+ const stagedEntries = threadController.getState().stagedEntries;
341
+ const parent = parentId ? stagedEntries.get(parentId) : undefined;
383
342
  if (!parent || parent.transcriptStatus === "sent") return null;
384
343
 
385
344
  const staged: LangChainBaseMessage[] = [];
386
345
  for (const message of visibleMessagesRef.current) {
387
- const entry = message.id
388
- ? stagedMessagesRef.current.get(message.id)
389
- : undefined;
346
+ const entry = message.id ? stagedEntries.get(message.id) : undefined;
390
347
  if (entry && entry.transcriptStatus !== "sent") {
391
348
  staged.push(entry.message);
392
349
  }
@@ -408,36 +365,33 @@ const useStreamThreadRuntime = (
408
365
  additional_kwargs: { modality: message.metadata.modality },
409
366
  }),
410
367
  };
411
- stagedMessagesRef.current.set(transcript.id, {
412
- message: transcript,
413
- runConfig: undefined,
414
- reconcileOnEcho: false,
415
- baseMessageCount: streamRef.current.messages.length,
416
- transcriptStatus: "unsent",
368
+ dispatchStaging({
369
+ type: "stage",
370
+ entry: {
371
+ message: transcript,
372
+ runConfig: undefined,
373
+ reconcileOnEcho: false,
374
+ baseMessageCount: streamRef.current.messages.length,
375
+ transcriptStatus: "unsent",
376
+ },
377
+ visibleMessages: visibleMessagesRef.current,
417
378
  });
418
- const nextMessages = [...visibleMessagesRef.current, transcript];
419
- visibleMessagesRef.current = nextMessages;
420
- setStagedMessages(nextMessages);
421
379
  };
422
380
 
423
- const getUnsentTranscripts = () =>
424
- visibleMessagesRef.current.filter(
381
+ const getUnsentTranscripts = () => {
382
+ const stagedEntries = threadController.getState().stagedEntries;
383
+ return visibleMessagesRef.current.filter(
425
384
  (message) =>
426
385
  message.id !== undefined &&
427
- stagedMessagesRef.current.get(message.id)?.transcriptStatus ===
428
- "unsent",
386
+ stagedEntries.get(message.id)?.transcriptStatus === "unsent",
429
387
  );
388
+ };
430
389
 
431
390
  const setTranscriptStatus = (
432
391
  messages: readonly LangChainBaseMessage[],
433
392
  status: "unsent" | "sent",
434
393
  ) => {
435
- for (const message of messages) {
436
- const staged = message.id
437
- ? stagedMessagesRef.current.get(message.id)
438
- : undefined;
439
- if (staged?.transcriptStatus) staged.transcriptStatus = status;
440
- }
394
+ dispatchStaging({ type: "markTranscript", messages, status });
441
395
  };
442
396
 
443
397
  // Reserving before the submit keeps an overlapping submit from carrying the
@@ -459,7 +413,8 @@ const useStreamThreadRuntime = (
459
413
  for (const message of messages) {
460
414
  if (
461
415
  message.id &&
462
- stagedMessagesRef.current.get(message.id)?.transcriptStatus
416
+ threadController.getState().stagedEntries.get(message.id)
417
+ ?.transcriptStatus
463
418
  )
464
419
  removeStagedMessage(message.id);
465
420
  }
@@ -467,7 +422,7 @@ const useStreamThreadRuntime = (
467
422
 
468
423
  const isTranscriptMessage = (message: LangChainBaseMessage) => {
469
424
  const staged = message.id
470
- ? stagedMessagesRef.current.get(message.id)
425
+ ? threadController.getState().stagedEntries.get(message.id)
471
426
  : undefined;
472
427
  if (staged?.transcriptStatus === "unsent") return true;
473
428
  const type = getMessageType(message);
@@ -497,30 +452,32 @@ const useStreamThreadRuntime = (
497
452
 
498
453
  const stageUserMessage = (msg: AppendMessage, reconcileOnEcho = false) => {
499
454
  const stagedMessage = toStagedHumanMessage(msg);
500
- stagedMessagesRef.current.set(stagedMessage.id, {
501
- message: stagedMessage,
502
- runConfig: msg.runConfig,
503
- reconcileOnEcho,
504
- baseMessageCount: streamRef.current.messages.length,
455
+ dispatchStaging({
456
+ type: "stage",
457
+ entry: {
458
+ message: stagedMessage,
459
+ runConfig: msg.runConfig,
460
+ reconcileOnEcho,
461
+ baseMessageCount: streamRef.current.messages.length,
462
+ },
463
+ visibleMessages: visibleMessagesRef.current,
505
464
  });
506
- const nextMessages = [...visibleMessagesRef.current, stagedMessage];
507
- visibleMessagesRef.current = nextMessages;
508
- setStagedMessages(nextMessages);
509
465
  return stagedMessage;
510
466
  };
511
467
 
512
468
  const removeStagedMessage = (id: string) => {
513
- if (!stagedMessagesRef.current.delete(id)) return;
514
- const nextMessages = visibleMessagesRef.current.filter(
515
- (message) => message.id !== id,
469
+ if (!threadController.getState().stagedEntries.has(id)) return;
470
+ const visible = visibleMessagesRef.current;
471
+ const nextMessages = visible.filter((message) => message.id !== id);
472
+ dispatchStaging(
473
+ { type: "remove", id, visibleMessages: visible },
474
+ nextMessages,
516
475
  );
517
- visibleMessagesRef.current = nextMessages;
518
- if (stagedMessagesRef.current.size === 0) {
519
- stagedBaseMessagesRef.current = null;
520
- setStagedMessages(null);
521
- } else {
522
- setStagedMessages(nextMessages);
523
- }
476
+ };
477
+
478
+ const supersedeFork = () => {
479
+ threadController.dispatch({ type: "supersedeFork" });
480
+ return threadController.getState().forkGeneration;
524
481
  };
525
482
 
526
483
  const extras = useMemo(
@@ -552,7 +509,7 @@ const useStreamThreadRuntime = (
552
509
 
553
510
  const runtime = useExternalStoreRuntime({
554
511
  ...pickExternalStoreSharedOptions(options),
555
- isRunning: stream.isLoading,
512
+ isRunning: stream.isLoading || forkPending,
556
513
  isLoading: stream.isThreadLoading,
557
514
  messages: messagesWithTranscripts,
558
515
  adapters,
@@ -638,129 +595,142 @@ const useStreamThreadRuntime = (
638
595
  );
639
596
  },
640
597
  onReload: async (parentId, config) => {
641
- const stagedRun = getStagedRun(parentId);
642
- if (stagedRun) {
643
- if (
644
- config.sourceId &&
645
- stagedMessagesRef.current.get(config.sourceId)?.transcriptStatus
646
- )
647
- removeStagedMessage(config.sourceId);
648
- const promotedIds = new Set<string>();
649
- for (const message of stagedRun.messages) {
598
+ const forkGeneration = supersedeFork();
599
+ try {
600
+ await streamRef.current.stop();
601
+ if (threadController.getState().forkGeneration !== forkGeneration)
602
+ return;
603
+ const stagedRun = getStagedRun(parentId);
604
+ if (stagedRun) {
650
605
  if (
651
- !message.id ||
652
- stagedMessagesRef.current.get(message.id)?.transcriptStatus
606
+ config.sourceId &&
607
+ threadController.getState().stagedEntries.get(config.sourceId)
608
+ ?.transcriptStatus
653
609
  )
654
- continue;
655
- promotedIds.add(message.id);
656
- stagedMessagesRef.current.delete(message.id);
657
- }
658
- stagedBaseMessagesRef.current = null;
659
- if (stagedMessagesRef.current.size > 0) {
660
- const nextMessages = visibleMessagesRef.current.filter(
661
- (message) => !message.id || !promotedIds.has(message.id),
610
+ removeStagedMessage(config.sourceId);
611
+ dispatchStaging({
612
+ type: "promote",
613
+ messages: stagedRun.messages,
614
+ visibleMessages: visibleMessagesRef.current,
615
+ });
616
+ const runConfig = config.runConfig ?? stagedRun.runConfig;
617
+ setActiveRunConfig(runConfig);
618
+ await submitCarryingTranscripts(stagedRun.messages, () =>
619
+ stream.submit(
620
+ {
621
+ [messagesKey]: stagedRun.messages.map(toStagedMessageInput),
622
+ },
623
+ runConfigToSubmitOptions(runConfig),
624
+ ),
662
625
  );
663
- visibleMessagesRef.current = nextMessages;
664
- setStagedMessages(nextMessages);
665
- } else {
666
- setStagedMessages(null);
626
+ return;
667
627
  }
668
- const runConfig = config.runConfig ?? stagedRun.runConfig;
669
- setActiveRunConfig(runConfig);
670
- await submitCarryingTranscripts(stagedRun.messages, () =>
671
- stream.submit(
628
+
629
+ const threadId = externalId;
630
+ if (!threadId || parentId == null) return;
631
+ const s = streamRef.current;
632
+ const fork = planForkTranscripts(parentId);
633
+ const checkpointId = await resolveForkCheckpoint(
634
+ s.client,
635
+ threadId,
636
+ s.messages as readonly LangChainBaseMessage[],
637
+ fork.forkParentId,
638
+ config.sourceId,
639
+ s[STREAM_CONTROLLER]?.messageMetadataStore?.getSnapshot?.(),
640
+ messagesKey,
641
+ );
642
+ if (threadController.getState().forkGeneration !== forkGeneration)
643
+ return;
644
+ if (!checkpointId) return;
645
+ dropTranscripts(fork.truncated);
646
+ setActiveRunConfig(config.runConfig);
647
+ await submitCarryingTranscripts(fork.transcripts, () =>
648
+ s.submit(
649
+ fork.transcripts.length > 0
650
+ ? { [messagesKey]: fork.transcripts.map(toStagedMessageInput) }
651
+ : null,
672
652
  {
673
- [messagesKey]: stagedRun.messages.map(toStagedMessageInput),
653
+ forkFrom: checkpointId,
654
+ ...runConfigToSubmitOptions(config.runConfig),
674
655
  },
675
- runConfigToSubmitOptions(runConfig),
676
656
  ),
677
657
  );
678
- return;
658
+ } finally {
659
+ threadController.dispatch({
660
+ type: "finishFork",
661
+ generation: forkGeneration,
662
+ });
679
663
  }
680
-
681
- const threadId = externalId;
682
- if (!threadId || parentId == null) return;
683
- const s = streamRef.current;
684
- const fork = planForkTranscripts(parentId);
685
- const checkpointId = await resolveForkCheckpoint(
686
- s.client,
687
- threadId,
688
- s.messages as readonly LangChainBaseMessage[],
689
- fork.forkParentId,
690
- config.sourceId,
691
- s[STREAM_CONTROLLER]?.messageMetadataStore?.getSnapshot?.(),
692
- messagesKey,
693
- );
694
- if (!checkpointId) return;
695
- dropTranscripts(fork.truncated);
696
- setActiveRunConfig(config.runConfig);
697
- await submitCarryingTranscripts(fork.transcripts, () =>
698
- s.submit(
699
- fork.transcripts.length > 0
700
- ? { [messagesKey]: fork.transcripts.map(toStagedMessageInput) }
701
- : null,
702
- {
703
- forkFrom: checkpointId,
704
- ...runConfigToSubmitOptions(config.runConfig),
705
- },
706
- ),
707
- );
708
664
  },
709
665
  onEdit: async (message) => {
710
- if (!(message.startRun ?? message.role === "user")) {
711
- const truncated = truncateLangChainBaseMessages(
712
- threadMessagesRef.current,
713
- message.parentId,
666
+ const forkGeneration = supersedeFork();
667
+ try {
668
+ await streamRef.current.stop();
669
+ if (threadController.getState().forkGeneration !== forkGeneration)
670
+ return;
671
+ if (!(message.startRun ?? message.role === "user")) {
672
+ const truncated = truncateLangChainBaseMessages(
673
+ threadMessagesRef.current,
674
+ message.parentId,
675
+ );
676
+ const stagedMessage = toStagedHumanMessage(message);
677
+ dispatchStaging({
678
+ type: "stageEdit",
679
+ entry: {
680
+ message: stagedMessage,
681
+ runConfig: message.runConfig,
682
+ reconcileOnEcho: false,
683
+ baseMessageCount: 0,
684
+ },
685
+ baseMessages: truncated,
686
+ });
687
+ return;
688
+ }
689
+
690
+ const threadId = externalId;
691
+ if (!threadId) return;
692
+ const s = streamRef.current;
693
+ const fork = planForkTranscripts(message.parentId);
694
+ const checkpointId = await resolveForkCheckpoint(
695
+ s.client,
696
+ threadId,
697
+ s.messages as readonly LangChainBaseMessage[],
698
+ fork.forkParentId,
699
+ message.sourceId,
700
+ s[STREAM_CONTROLLER]?.messageMetadataStore?.getSnapshot?.(),
701
+ messagesKey,
702
+ );
703
+ if (threadController.getState().forkGeneration !== forkGeneration)
704
+ return;
705
+ if (!checkpointId) return;
706
+ dropTranscripts(fork.truncated);
707
+ const content = getMessageContent(message);
708
+ setActiveRunConfig(message.runConfig);
709
+ await submitCarryingTranscripts(fork.transcripts, () =>
710
+ s.submit(
711
+ {
712
+ [messagesKey]: [
713
+ ...fork.transcripts.map(toStagedMessageInput),
714
+ { type: "human", content },
715
+ ],
716
+ },
717
+ {
718
+ forkFrom: checkpointId,
719
+ ...runConfigToSubmitOptions(message.runConfig),
720
+ },
721
+ ),
714
722
  );
715
- const stagedMessage = toStagedHumanMessage(message);
716
- stagedMessagesRef.current.set(stagedMessage.id, {
717
- message: stagedMessage,
718
- runConfig: message.runConfig,
719
- reconcileOnEcho: false,
720
- baseMessageCount: 0,
723
+ } finally {
724
+ threadController.dispatch({
725
+ type: "finishFork",
726
+ generation: forkGeneration,
721
727
  });
722
- stagedBaseMessagesRef.current = truncated;
723
- const nextMessages = [...truncated, stagedMessage];
724
- visibleMessagesRef.current = nextMessages;
725
- setStagedMessages(nextMessages);
726
- return;
727
728
  }
728
-
729
- const threadId = externalId;
730
- if (!threadId) return;
731
- const s = streamRef.current;
732
- const fork = planForkTranscripts(message.parentId);
733
- const checkpointId = await resolveForkCheckpoint(
734
- s.client,
735
- threadId,
736
- s.messages as readonly LangChainBaseMessage[],
737
- fork.forkParentId,
738
- message.sourceId,
739
- s[STREAM_CONTROLLER]?.messageMetadataStore?.getSnapshot?.(),
740
- messagesKey,
741
- );
742
- if (!checkpointId) return;
743
- dropTranscripts(fork.truncated);
744
- const content = getMessageContent(message);
745
- setActiveRunConfig(message.runConfig);
746
- await submitCarryingTranscripts(fork.transcripts, () =>
747
- s.submit(
748
- {
749
- [messagesKey]: [
750
- ...fork.transcripts.map(toStagedMessageInput),
751
- { type: "human", content },
752
- ],
753
- },
754
- {
755
- forkFrom: checkpointId,
756
- ...runConfigToSubmitOptions(message.runConfig),
757
- },
758
- ),
759
- );
760
729
  },
761
730
  onCancel:
762
731
  unstable_allowCancellation !== false
763
732
  ? async () => {
733
+ threadController.dispatch({ type: "cancelFork" });
764
734
  activeRunConfigRef.current = undefined;
765
735
  await stream.stop();
766
736
  }
@@ -773,7 +743,7 @@ const useStreamThreadRuntime = (
773
743
  /**
774
744
  * Creates an assistant-ui runtime backed by LangChain's `useStream` hook.
775
745
  * Accepts the same options as `useStream` from `@langchain/react`, plus
776
- * `cloud` and `adapters`.
746
+ * `cloud`, `scopeId`, and `adapters`.
777
747
  *
778
748
  * @example
779
749
  * ```tsx
@@ -797,6 +767,7 @@ const useStreamThreadRuntime = (
797
767
  export const useStreamRuntime = (rawOptions: UseStreamRuntimeOptions) => {
798
768
  const {
799
769
  cloud,
770
+ scopeId,
800
771
  unstable_threadListAdapter,
801
772
  create,
802
773
  delete: deleteFn,
@@ -808,6 +779,7 @@ export const useStreamRuntime = (rawOptions: UseStreamRuntimeOptions) => {
808
779
  const cloudAdapter = useCloudThreadListAdapter({
809
780
  sdk: LANGCHAIN_SDK,
810
781
  cloud,
782
+ scopeId,
811
783
  create: createCloudThreadListAdapterCreateFallback(
812
784
  create,
813
785
  aui.threadListItem,