@assistant-ui/react-langchain 0.0.30 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-langchain",
3
- "version": "0.0.30",
3
+ "version": "0.0.31",
4
4
  "description": "LangChain useStream adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "langchain",
@@ -40,17 +40,21 @@
40
40
  ],
41
41
  "sideEffects": false,
42
42
  "dependencies": {
43
- "@assistant-ui/core": "^0.3.18",
43
+ "@assistant-ui/core": "^0.3.19",
44
44
  "@assistant-ui/store": "^0.3.13",
45
45
  "assistant-cloud": "*",
46
- "assistant-stream": "^0.3.42"
46
+ "assistant-stream": "^0.3.43"
47
47
  },
48
48
  "peerDependencies": {
49
- "@langchain/react": "^1.0.2",
49
+ "@langchain/langgraph-sdk": "^1.9.20",
50
+ "@langchain/react": "^1.0.20",
50
51
  "@types/react": "*",
51
52
  "react": "^18 || ^19"
52
53
  },
53
54
  "peerDependenciesMeta": {
55
+ "@langchain/langgraph-sdk": {
56
+ "optional": true
57
+ },
54
58
  "@langchain/react": {
55
59
  "optional": true
56
60
  },
@@ -59,7 +63,7 @@
59
63
  }
60
64
  },
61
65
  "devDependencies": {
62
- "@assistant-ui/x-buildutils": "0.0.26",
66
+ "@assistant-ui/x-buildutils": "0.0.27",
63
67
  "@langchain/core": "^1.2.9",
64
68
  "@langchain/langgraph-sdk": "^1.10.2",
65
69
  "@langchain/react": "^1.0.35",
@@ -67,7 +71,7 @@
67
71
  "@types/react": "^19.2.18",
68
72
  "jsdom": "^30.0.1",
69
73
  "react": "^19.2.8",
70
- "vitest": "^4.1.11"
74
+ "vitest": "^5.0.0"
71
75
  },
72
76
  "publishConfig": {
73
77
  "access": "public",
@@ -0,0 +1,190 @@
1
+ import {
2
+ bindExternalStoreMessage,
3
+ getExternalStoreMessages,
4
+ type ThreadMessage,
5
+ } from "@assistant-ui/core";
6
+ import { describe, expect, it } from "vitest";
7
+ import {
8
+ attachSubagentTranscripts,
9
+ createAttachMemo,
10
+ } from "./attachSubagentTranscripts";
11
+
12
+ const assistantMessage = (id: string, toolCallId?: string): ThreadMessage =>
13
+ ({
14
+ id,
15
+ createdAt: new Date(),
16
+ role: "assistant",
17
+ status: { type: "complete", reason: "unknown" },
18
+ content: toolCallId
19
+ ? [
20
+ {
21
+ type: "tool-call",
22
+ toolCallId,
23
+ toolName: "task",
24
+ args: {},
25
+ argsText: "{}",
26
+ },
27
+ ]
28
+ : [{ type: "text", text: id }],
29
+ metadata: {
30
+ unstable_state: null,
31
+ unstable_annotations: [],
32
+ unstable_data: [],
33
+ steps: [],
34
+ custom: {},
35
+ },
36
+ }) as ThreadMessage;
37
+
38
+ const transcript = (id: string) => [assistantMessage(id)];
39
+
40
+ describe("attachSubagentTranscripts", () => {
41
+ it("returns the input array when no tool call has a transcript", () => {
42
+ const messages = [assistantMessage("message", "unknown")];
43
+
44
+ expect(
45
+ attachSubagentTranscripts(
46
+ messages,
47
+ new Map([["task", transcript("transcript")]]),
48
+ createAttachMemo(),
49
+ ),
50
+ ).toBe(messages);
51
+ });
52
+
53
+ it("attaches a transcript and preserves untouched message identities", () => {
54
+ const matching = assistantMessage("matching", "task");
55
+ const untouched = assistantMessage("untouched");
56
+ const messages = [matching, untouched];
57
+ const childTranscript = transcript("child");
58
+
59
+ const attached = attachSubagentTranscripts(
60
+ messages,
61
+ new Map([["task", childTranscript]]),
62
+ createAttachMemo(),
63
+ );
64
+
65
+ expect(attached).not.toBe(messages);
66
+ expect(attached[0]).not.toBe(matching);
67
+ expect(attached[1]).toBe(untouched);
68
+ const part = attached[0]!.content[0]!;
69
+ expect(part.type).toBe("tool-call");
70
+ if (part.type !== "tool-call") throw new Error("expected a tool call");
71
+ expect(part.messages).toBe(childTranscript);
72
+ });
73
+
74
+ it("reuses the attached array when transcript identities are unchanged", () => {
75
+ const messages = [assistantMessage("matching", "task")];
76
+ const childTranscript = transcript("child");
77
+ const memo = createAttachMemo();
78
+
79
+ const first = attachSubagentTranscripts(
80
+ messages,
81
+ new Map([["task", childTranscript]]),
82
+ memo,
83
+ );
84
+ const second = attachSubagentTranscripts(
85
+ messages,
86
+ new Map([["task", childTranscript]]),
87
+ memo,
88
+ );
89
+
90
+ expect(second).toBe(first);
91
+ });
92
+
93
+ it("rebuilds only messages whose transcript changed", () => {
94
+ const messages = [
95
+ assistantMessage("first", "task-one"),
96
+ assistantMessage("second", "task-two"),
97
+ ];
98
+ const firstTranscript = transcript("first-transcript");
99
+ const secondTranscript = transcript("second-transcript");
100
+ const memo = createAttachMemo();
101
+ const first = attachSubagentTranscripts(
102
+ messages,
103
+ new Map([
104
+ ["task-one", firstTranscript],
105
+ ["task-two", secondTranscript],
106
+ ]),
107
+ memo,
108
+ );
109
+ const next = attachSubagentTranscripts(
110
+ messages,
111
+ new Map([
112
+ ["task-one", transcript("updated-transcript")],
113
+ ["task-two", secondTranscript],
114
+ ]),
115
+ memo,
116
+ );
117
+
118
+ expect(next).not.toBe(first);
119
+ expect(next[0]).not.toBe(first[0]);
120
+ expect(next[1]).toBe(first[1]);
121
+ });
122
+
123
+ it("rebuilds when the same transcript moves to another tool call", () => {
124
+ const first = assistantMessage("f", "call-a").content[0]!;
125
+ const second = assistantMessage("s", "call-b").content[0]!;
126
+ const message = {
127
+ ...assistantMessage("pair"),
128
+ content: [first, second],
129
+ } as ThreadMessage;
130
+ const nested = transcript("nested");
131
+ const memo = createAttachMemo();
132
+
133
+ const [before] = attachSubagentTranscripts(
134
+ [message],
135
+ new Map([["call-a", nested]]),
136
+ memo,
137
+ );
138
+ const [after] = attachSubagentTranscripts(
139
+ [message],
140
+ new Map([["call-b", nested]]),
141
+ memo,
142
+ );
143
+
144
+ expect(before?.content[0]).toMatchObject({ messages: nested });
145
+ expect(after).not.toBe(before);
146
+ expect(after?.content[0]).toBe(first);
147
+ expect(after?.content[1]).toMatchObject({ messages: nested });
148
+ });
149
+
150
+ it("keeps untouched parts of a partially matched message by reference", () => {
151
+ const text = { type: "text" as const, text: "hello" };
152
+ const matched = assistantMessage("m", "call-matched").content[0]!;
153
+ const unmatched = assistantMessage("u", "call-unmatched").content[0]!;
154
+ const message = {
155
+ ...assistantMessage("mixed"),
156
+ content: [text, matched, unmatched],
157
+ } as ThreadMessage;
158
+ const nested = transcript("nested");
159
+
160
+ const [attached] = attachSubagentTranscripts(
161
+ [message],
162
+ new Map([["call-matched", nested]]),
163
+ createAttachMemo(),
164
+ );
165
+
166
+ expect(attached?.content[0]).toBe(text);
167
+ expect(attached?.content[1]).toMatchObject({ messages: nested });
168
+ expect(attached?.content[2]).toBe(unmatched);
169
+ });
170
+
171
+ it("preserves message and array external-store bindings", () => {
172
+ const message = assistantMessage("matching", "task");
173
+ const messages = [message];
174
+ const originalMessage = { id: "original-message" };
175
+ const originalMessages = [{ id: "original-messages" }];
176
+ bindExternalStoreMessage(message, originalMessage);
177
+ bindExternalStoreMessage(messages, originalMessages);
178
+
179
+ const attached = attachSubagentTranscripts(
180
+ messages,
181
+ new Map([["task", transcript("child")]]),
182
+ createAttachMemo(),
183
+ );
184
+
185
+ expect(getExternalStoreMessages(attached[0]!)).toEqual([originalMessage]);
186
+ expect(getExternalStoreMessages({ messages: attached })).toBe(
187
+ originalMessages,
188
+ );
189
+ });
190
+ });
@@ -0,0 +1,72 @@
1
+ import {
2
+ bindExternalStoreMessage,
3
+ getExternalStoreMessages,
4
+ type ThreadMessage,
5
+ } from "@assistant-ui/core";
6
+
7
+ type AttachedTranscript = readonly [string, readonly ThreadMessage[]];
8
+
9
+ type AttachedMessage = {
10
+ transcripts: readonly AttachedTranscript[];
11
+ attached: ThreadMessage;
12
+ };
13
+
14
+ export type AttachMemo = {
15
+ messages: WeakMap<ThreadMessage, AttachedMessage>;
16
+ results: WeakMap<readonly ThreadMessage[], readonly ThreadMessage[]>;
17
+ };
18
+
19
+ export const createAttachMemo = (): AttachMemo => ({
20
+ messages: new WeakMap(),
21
+ results: new WeakMap(),
22
+ });
23
+
24
+ const sameTranscripts = (
25
+ a: readonly AttachedTranscript[],
26
+ b: readonly AttachedTranscript[],
27
+ ) =>
28
+ a.length === b.length &&
29
+ a.every(
30
+ ([toolCallId, transcript], index) =>
31
+ toolCallId === b[index]![0] && transcript === b[index]![1],
32
+ );
33
+
34
+ export const attachSubagentTranscripts = (
35
+ messages: readonly ThreadMessage[],
36
+ transcripts: ReadonlyMap<string, readonly ThreadMessage[]>,
37
+ memo: AttachMemo,
38
+ ): readonly ThreadMessage[] => {
39
+ let attachedAny = false;
40
+ const next = messages.map((message) => {
41
+ const attachedTranscripts = message.content.flatMap((part) =>
42
+ part.type === "tool-call" && transcripts.has(part.toolCallId)
43
+ ? [[part.toolCallId, transcripts.get(part.toolCallId)!] as const]
44
+ : [],
45
+ );
46
+ if (attachedTranscripts.length === 0) return message;
47
+ attachedAny = true;
48
+
49
+ const cached = memo.messages.get(message);
50
+ if (cached && sameTranscripts(cached.transcripts, attachedTranscripts))
51
+ return cached.attached;
52
+
53
+ const attached = {
54
+ ...message,
55
+ content: message.content.map((part) =>
56
+ part.type === "tool-call" && transcripts.has(part.toolCallId)
57
+ ? { ...part, messages: transcripts.get(part.toolCallId)! }
58
+ : part,
59
+ ),
60
+ } as ThreadMessage;
61
+ memo.messages.set(message, { transcripts: attachedTranscripts, attached });
62
+ return attached;
63
+ });
64
+
65
+ if (!attachedAny) return messages;
66
+ const previous = memo.results.get(messages);
67
+ if (previous && previous.every((message, index) => message === next[index]))
68
+ return previous;
69
+ bindExternalStoreMessage(next, getExternalStoreMessages({ messages }));
70
+ memo.results.set(messages, next);
71
+ return next;
72
+ };
@@ -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
+ };
@@ -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<
@@ -412,7 +432,7 @@ const useStreamThreadRuntime = (
412
432
  ...pickExternalStoreSharedOptions(options),
413
433
  isRunning: stream.isLoading,
414
434
  isLoading: stream.isThreadLoading,
415
- messages: threadMessages,
435
+ messages: messagesWithTranscripts,
416
436
  adapters,
417
437
  extras,
418
438
  unstable_enableToolInvocations: true,
@@ -631,6 +651,7 @@ export const useStreamRuntime = (rawOptions: UseStreamRuntimeOptions) => {
631
651
 
632
652
  const aui = useAui();
633
653
  const cloudAdapter = useCloudThreadListAdapter({
654
+ sdk: LANGCHAIN_SDK,
634
655
  cloud,
635
656
  create: createCloudThreadListAdapterCreateFallback(
636
657
  create,