@assistant-ui/react-langchain 0.0.29 → 0.0.31

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/src/converter.ts CHANGED
@@ -61,7 +61,10 @@ export const convertLangChainContentBlock = (
61
61
  switch (type) {
62
62
  case "text":
63
63
  case "text_delta":
64
- return { type: "text" as const, text: part.text };
64
+ return {
65
+ type: "text" as const,
66
+ text: typeof part.text === "string" ? part.text : "",
67
+ };
65
68
  case "image_url": {
66
69
  const image =
67
70
  typeof part.image_url === "string"
@@ -98,15 +101,13 @@ export const convertLangChainContentBlock = (
98
101
  };
99
102
  }
100
103
  case "thinking":
101
- return { type: "reasoning" as const, text: part.thinking };
102
- case "reasoning":
103
- return {
104
- type: "reasoning" as const,
105
- text:
106
- part.summary && part.summary.length > 0
107
- ? part.summary.map((s) => s?.text ?? "").join("\n\n\n")
108
- : (part.reasoning ?? ""),
109
- };
104
+ return hasVisibleText(part.thinking)
105
+ ? { type: "reasoning" as const, text: part.thinking }
106
+ : null;
107
+ case "reasoning": {
108
+ const text = getReasoningText(part);
109
+ return text ? { type: "reasoning" as const, text } : null;
110
+ }
110
111
  case "tool_use":
111
112
  case "input_json_delta":
112
113
  return null;
@@ -262,13 +263,14 @@ export const getMessageContent = (msg: AppendMessage) => {
262
263
  return content;
263
264
  };
264
265
 
265
- const reasoningTextLength = (part: {
266
+ const getReasoningText = (part: {
266
267
  readonly summary?: ReadonlyArray<{ readonly text?: string }>;
267
268
  readonly reasoning?: string;
268
- }): number => {
269
- if (part.summary && part.summary.length > 0)
270
- return part.summary.map((s) => s?.text ?? "").join("\n\n\n").length;
271
- return part.reasoning?.length ?? 0;
269
+ }): string => {
270
+ const summary = part.summary?.map((s) => s?.text ?? "").join("\n\n\n") ?? "";
271
+ if (hasVisibleText(summary)) return summary;
272
+ const reasoning = part.reasoning ?? "";
273
+ return hasVisibleText(reasoning) ? reasoning : "";
272
274
  };
273
275
 
274
276
  export const createLangChainStreamingTimingAccessors = <
@@ -299,16 +301,17 @@ export const createLangChainStreamingTimingAccessors = <
299
301
  if (!Array.isArray(content)) return 0;
300
302
  let len = 0;
301
303
  for (const part of content as readonly LangChainContentBlock[]) {
304
+ if (typeof part !== "object" || part === null) continue;
302
305
  switch (part.type) {
303
306
  case "text":
304
307
  case "text_delta":
305
308
  if (typeof part.text === "string") len += part.text.length;
306
309
  break;
307
310
  case "thinking":
308
- if (typeof part.thinking === "string") len += part.thinking.length;
311
+ if (hasVisibleText(part.thinking)) len += part.thinking.length;
309
312
  break;
310
313
  case "reasoning":
311
- len += reasoningTextLength(part);
314
+ len += getReasoningText(part).length;
312
315
  break;
313
316
  }
314
317
  }
@@ -0,0 +1,9 @@
1
+ import type { SdkIdentity } from "assistant-cloud";
2
+
3
+ export const LANGCHAIN_SDK: SdkIdentity = {
4
+ name: "@assistant-ui/react-langchain",
5
+ version:
6
+ typeof __AUI_PACKAGE_VERSION__ === "string"
7
+ ? __AUI_PACKAGE_VERSION__
8
+ : "0.0.0",
9
+ };
@@ -34,4 +34,54 @@ describe("useLangChainStreamingTiming", () => {
34
34
  Math.ceil("deduced".length / 4),
35
35
  );
36
36
  });
37
+
38
+ it("counts a thinking block only when the converter renders it", () => {
39
+ const tokenCount = (thinking: string) => {
40
+ const messages: LangChainBaseMessage[] = [
41
+ {
42
+ id: "msg-1",
43
+ _getType: () => "ai",
44
+ content: [{ type: "thinking", thinking }],
45
+ },
46
+ ];
47
+ const { result, rerender } = renderHook(
48
+ ({ msgs, running }) => useLangChainStreamingTiming(msgs, running),
49
+ { initialProps: { msgs: messages, running: true } },
50
+ );
51
+ act(() => {
52
+ rerender({ msgs: messages, running: false });
53
+ });
54
+ return result.current["msg-1"]?.tokenCount;
55
+ };
56
+
57
+ expect(tokenCount("deduced")).toBe(Math.ceil("deduced".length / 4));
58
+ expect(tokenCount(" ")).toBeUndefined();
59
+ });
60
+
61
+ it("counts the summary text the converter renders, not the reasoning it shadows", () => {
62
+ const messages: LangChainBaseMessage[] = [
63
+ {
64
+ id: "msg-1",
65
+ _getType: () => "ai",
66
+ content: [
67
+ {
68
+ type: "reasoning",
69
+ reasoning: "partial thinking",
70
+ summary: [{ type: "summary_text", text: "first summary" }],
71
+ },
72
+ ],
73
+ },
74
+ ];
75
+ const { result, rerender } = renderHook(
76
+ ({ running }) => useLangChainStreamingTiming(messages, running),
77
+ { initialProps: { running: true } },
78
+ );
79
+ act(() => {
80
+ rerender({ running: false });
81
+ });
82
+
83
+ expect(result.current["msg-1"]?.tokenCount).toBe(
84
+ Math.ceil("first summary".length / 4),
85
+ );
86
+ });
37
87
  });
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { act, render, renderHook, waitFor } from "@testing-library/react";
4
- import { describe, expect, it, vi } from "vitest";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
6
6
  import type {
7
7
  AssistantRuntime,
@@ -20,7 +20,7 @@ import {
20
20
  } from "./hooks";
21
21
 
22
22
  const { mockUseChannel, mockUseStream, streamController } = vi.hoisted(() => ({
23
- mockUseChannel: vi.fn(() => []),
23
+ mockUseChannel: vi.fn((): unknown[] => []),
24
24
  mockUseStream: vi.fn(),
25
25
  streamController: Symbol("STREAM_CONTROLLER"),
26
26
  }));
@@ -40,7 +40,7 @@ type MockStream = {
40
40
  values: Record<string, unknown>;
41
41
  interrupts: unknown[];
42
42
  toolCalls: unknown[];
43
- subagents: unknown[];
43
+ subagents: ReadonlyMap<string, unknown>;
44
44
  subgraphs: unknown[];
45
45
  error: unknown;
46
46
  submit: ReturnType<typeof vi.fn>;
@@ -53,6 +53,10 @@ type MockStream = {
53
53
  messageMetadataStore: {
54
54
  getSnapshot: ReturnType<typeof vi.fn>;
55
55
  };
56
+ resolveSubagentNamespace: ReturnType<typeof vi.fn>;
57
+ registry: {
58
+ acquire: ReturnType<typeof vi.fn>;
59
+ };
56
60
  };
57
61
  };
58
62
 
@@ -75,7 +79,7 @@ const createMockStream = (
75
79
  values: {},
76
80
  interrupts: [],
77
81
  toolCalls: [],
78
- subagents: [],
82
+ subagents: new Map(),
79
83
  subgraphs: [],
80
84
  error: undefined,
81
85
  submit: vi.fn(async () => {}),
@@ -88,6 +92,10 @@ const createMockStream = (
88
92
  messageMetadataStore: {
89
93
  getSnapshot: vi.fn(),
90
94
  },
95
+ resolveSubagentNamespace: vi.fn(async () => {}),
96
+ registry: {
97
+ acquire: vi.fn(),
98
+ },
91
99
  },
92
100
  });
93
101
 
@@ -177,7 +185,11 @@ describe("useStreamRuntime thread options", () => {
177
185
  unstable_threadListAdapter: threadListAdapter,
178
186
  } as never);
179
187
  capture.runtime = runtime;
180
- return <AssistantRuntimeProvider runtime={runtime} />;
188
+ return (
189
+ <AssistantRuntimeProvider runtime={runtime}>
190
+ {null}
191
+ </AssistantRuntimeProvider>
192
+ );
181
193
  };
182
194
 
183
195
  const view = render(<TestRuntime />);
@@ -286,7 +298,11 @@ describe("useStreamRuntime thread options", () => {
286
298
  const TestRuntime = () => {
287
299
  const runtime = useStreamRuntime({ apiUrl: "/api" } as never);
288
300
  capture.runtime = runtime;
289
- return <AssistantRuntimeProvider runtime={runtime} />;
301
+ return (
302
+ <AssistantRuntimeProvider runtime={runtime}>
303
+ {null}
304
+ </AssistantRuntimeProvider>
305
+ );
290
306
  };
291
307
  const view = render(<TestRuntime />);
292
308
  await waitFor(() => expect(capture.runtime).not.toBeNull());
@@ -721,7 +737,9 @@ describe("useStreamRuntime run configuration", () => {
721
737
  });
722
738
 
723
739
  await act(async () => {
724
- await capture.send!([{ type: "human", content: "next" }]);
740
+ await capture.send!([
741
+ { type: "human", content: "next" } as unknown as LangChainBaseMessage,
742
+ ]);
725
743
  });
726
744
 
727
745
  expect(stream.submit).toHaveBeenLastCalledWith(
@@ -971,3 +989,84 @@ describe("useStreamRuntime committed refs", () => {
971
989
  host.unmount();
972
990
  });
973
991
  });
992
+
993
+ describe("useStreamRuntime subagent transcripts", () => {
994
+ afterEach(() => {
995
+ mockUseChannel.mockReset();
996
+ });
997
+
998
+ it("renders live UI messages inside the transcript of the subagent that pushed them", async () => {
999
+ const stream = createMockStream([
1000
+ message("human-1", "human", "delegate"),
1001
+ {
1002
+ id: "root-ai",
1003
+ _getType: () => "ai",
1004
+ content: "",
1005
+ tool_calls: [{ id: "task-one", name: "task", args: {} }],
1006
+ },
1007
+ ]);
1008
+ const transcript = [message("nested-ai", "ai", "nested answer")];
1009
+ stream.subagents = new Map([
1010
+ [
1011
+ "task-one",
1012
+ {
1013
+ id: "task-one",
1014
+ namespace: ["tools:task-one"],
1015
+ status: "running",
1016
+ parentId: null,
1017
+ depth: 1,
1018
+ },
1019
+ ],
1020
+ ]);
1021
+ stream[streamController]!.registry.acquire.mockReturnValue({
1022
+ store: { getSnapshot: () => transcript, subscribe: () => () => {} },
1023
+ release: vi.fn(),
1024
+ });
1025
+ const uiEvent = {
1026
+ method: "custom",
1027
+ params: {
1028
+ namespace: ["tools:task-one"],
1029
+ data: {
1030
+ type: "ui",
1031
+ id: "ui-1",
1032
+ name: "chart",
1033
+ props: { points: [1, 2] },
1034
+ metadata: { message_id: "nested-ai" },
1035
+ },
1036
+ },
1037
+ };
1038
+ mockUseChannel.mockReturnValue([uiEvent]);
1039
+ const { auiResult, rerender } = renderAui(stream);
1040
+ const nestedTranscript = () => {
1041
+ const { messages } = auiResult.current.thread.getState();
1042
+ for (const threadMessage of messages) {
1043
+ for (const part of threadMessage.content) {
1044
+ if (part.type === "tool-call" && part.toolCallId === "task-one")
1045
+ return part.messages;
1046
+ }
1047
+ }
1048
+ return undefined;
1049
+ };
1050
+
1051
+ await waitFor(() =>
1052
+ expect(nestedTranscript()?.[0]?.content).toMatchObject([
1053
+ { type: "text", text: "nested answer" },
1054
+ { type: "data", name: "chart", data: { points: [1, 2] } },
1055
+ ]),
1056
+ );
1057
+ const rendered = nestedTranscript();
1058
+
1059
+ mockUseChannel.mockReturnValue([
1060
+ uiEvent,
1061
+ {
1062
+ method: "custom",
1063
+ params: { namespace: [], data: { name: "progress", payload: 1 } },
1064
+ },
1065
+ ]);
1066
+ await act(async () => {
1067
+ rerender();
1068
+ });
1069
+
1070
+ expect(nestedTranscript()).toBe(rendered);
1071
+ });
1072
+ });
@@ -43,10 +43,16 @@ import {
43
43
  getMessageContent,
44
44
  getMessageType,
45
45
  } from "./convertMessages";
46
+ import {
47
+ attachSubagentTranscripts,
48
+ createAttachMemo,
49
+ } from "./attachSubagentTranscripts";
50
+ import { useSubagentTranscripts } from "./useSubagentTranscripts";
46
51
  import { foldUIUpdates, mergeUIMessages } from "./uiMessages";
47
52
  import { langChainExtras } from "./runtimeExtras";
48
53
  import { resolveForkCheckpoint } from "./resolveForkCheckpoint";
49
54
  import { useLangChainStreamingTiming } from "./streamingTiming";
55
+ import { LANGCHAIN_SDK } from "./sdkIdentity";
50
56
 
51
57
  const UI_CUSTOM_CHANNELS: readonly Channel[] = ["custom"];
52
58
 
@@ -171,6 +177,11 @@ const useStreamThreadRuntime = (
171
177
  [liveUiMessages, uiStateValue],
172
178
  );
173
179
 
180
+ const uiMessagesByParent = useMemo(
181
+ () => groupUIMessagesByParent<UIMessage>(mergedUiMessages),
182
+ [mergedUiMessages],
183
+ );
184
+
174
185
  const visibleMessages =
175
186
  stagedMessages ?? (stream.messages as LangChainBaseMessage[]);
176
187
 
@@ -179,24 +190,33 @@ const useStreamThreadRuntime = (
179
190
  effectiveIsRunning,
180
191
  );
181
192
 
193
+ const subagentTranscripts = useSubagentTranscripts(
194
+ stream,
195
+ uiMessagesByParent,
196
+ );
197
+
182
198
  const convertWithUI = useMemo<
183
199
  useExternalMessageConverter.Callback<LangChainBaseMessage>
184
- >(() => {
185
- const uiMessagesByParent =
186
- groupUIMessagesByParent<UIMessage>(mergedUiMessages);
187
- return (message, metadata) =>
200
+ >(
201
+ () => (message, metadata) =>
188
202
  convertLangChainBaseMessage(message, {
189
203
  ...metadata,
190
204
  uiMessagesByParent,
191
205
  messageTiming,
192
- });
193
- }, [mergedUiMessages, messageTiming]);
206
+ }),
207
+ [uiMessagesByParent, messageTiming],
208
+ );
194
209
 
195
210
  const threadMessages = useExternalMessageConverter({
196
211
  callback: convertWithUI,
197
212
  messages: visibleMessages,
198
213
  isRunning: effectiveIsRunning,
199
214
  });
215
+ const [memo] = useState(createAttachMemo);
216
+ const messagesWithTranscripts = useMemo(
217
+ () => attachSubagentTranscripts(threadMessages, subagentTranscripts, memo),
218
+ [threadMessages, subagentTranscripts, memo],
219
+ );
200
220
 
201
221
  const streamRef = useRef(stream);
202
222
  useInsertionEffect(() => {
@@ -269,10 +289,10 @@ const useStreamThreadRuntime = (
269
289
  visibleMessagesRef.current = visibleMessages;
270
290
  }, [visibleMessages]);
271
291
 
272
- const threadMessagesRef = useRef(threadMessages);
292
+ const threadMessagesRef = useRef(messagesWithTranscripts);
273
293
  useInsertionEffect(() => {
274
- threadMessagesRef.current = threadMessages;
275
- }, [threadMessages]);
294
+ threadMessagesRef.current = messagesWithTranscripts;
295
+ }, [messagesWithTranscripts]);
276
296
 
277
297
  const stagedMessagesRef = useRef(
278
298
  new Map<
@@ -324,6 +344,9 @@ const useStreamThreadRuntime = (
324
344
  if (remainingStagedMessages.length === 0) {
325
345
  stagedBaseMessagesRef.current = null;
326
346
  visibleMessagesRef.current = baseMessages;
347
+ // Reconciling against the upstream stream mutates the staged refs above,
348
+ // which cannot happen during render.
349
+ // eslint-disable-next-line react-hooks/set-state-in-effect
327
350
  setStagedMessages(null);
328
351
  return;
329
352
  }
@@ -409,7 +432,7 @@ const useStreamThreadRuntime = (
409
432
  ...pickExternalStoreSharedOptions(options),
410
433
  isRunning: stream.isLoading,
411
434
  isLoading: stream.isThreadLoading,
412
- messages: threadMessages,
435
+ messages: messagesWithTranscripts,
413
436
  adapters,
414
437
  extras,
415
438
  unstable_enableToolInvocations: true,
@@ -628,6 +651,7 @@ export const useStreamRuntime = (rawOptions: UseStreamRuntimeOptions) => {
628
651
 
629
652
  const aui = useAui();
630
653
  const cloudAdapter = useCloudThreadListAdapter({
654
+ sdk: LANGCHAIN_SDK,
631
655
  cloud,
632
656
  create: createCloudThreadListAdapterCreateFallback(
633
657
  create,