@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
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { act, render, renderHook, waitFor } from "@testing-library/react";
4
- import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
6
6
  import type {
7
7
  AssistantRuntime,
@@ -9,7 +9,11 @@ import type {
9
9
  RemoteThreadListAdapter,
10
10
  } from "@assistant-ui/core";
11
11
  import { useAui } from "@assistant-ui/store";
12
- import type { LangChainBaseMessage, LangChainToolCall } from "./types";
12
+ import type {
13
+ LangChainBaseMessage,
14
+ LangChainToolCall,
15
+ UIMessage,
16
+ } from "./types";
13
17
  import { startTransition, Suspense, type ReactNode } from "react";
14
18
  import {
15
19
  useLangChainRespond,
@@ -19,21 +23,41 @@ import {
19
23
  useLangChainSubmit,
20
24
  } from "./hooks";
21
25
 
22
- const { mockUseChannel, mockUseStream, streamController } = vi.hoisted(() => ({
23
- mockUseChannel: vi.fn((): unknown[] => []),
24
- mockUseStream: vi.fn(),
25
- streamController: Symbol("STREAM_CONTROLLER"),
26
- }));
26
+ const { conversionSpy, mockUseChannel, mockUseStream, streamController } =
27
+ vi.hoisted(() => ({
28
+ conversionSpy: vi.fn(),
29
+ mockUseChannel: vi.fn((): unknown[] => []),
30
+ mockUseStream: vi.fn(),
31
+ streamController: Symbol("STREAM_CONTROLLER"),
32
+ }));
27
33
 
28
- vi.mock("@langchain/react", () => ({
34
+ vi.mock("@langchain/react", async (importOriginal) => ({
35
+ ...(await importOriginal<typeof import("@langchain/react")>()),
29
36
  STREAM_CONTROLLER: streamController,
30
37
  useChannel: mockUseChannel,
31
38
  useStream: mockUseStream,
32
39
  }));
33
40
 
41
+ vi.mock("./convertMessages", async (importOriginal) => {
42
+ const original = await importOriginal<typeof import("./convertMessages")>();
43
+ return {
44
+ ...original,
45
+ convertLangChainBaseMessage: (
46
+ ...args: Parameters<typeof original.convertLangChainBaseMessage>
47
+ ) => {
48
+ conversionSpy(args[0].id);
49
+ return original.convertLangChainBaseMessage(...args);
50
+ },
51
+ };
52
+ });
53
+
34
54
  import { useStreamRuntime } from "./useStreamRuntime";
35
55
  import { settleOutsideAct } from "./tests/settleOutsideAct";
36
56
 
57
+ beforeEach(() => {
58
+ mockUseChannel.mockReturnValue([]);
59
+ });
60
+
37
61
  type MockStream = {
38
62
  messages: LangChainBaseMessage[];
39
63
  isLoading: boolean;
@@ -131,6 +155,45 @@ const getText = (aui: ReturnType<typeof useAui>) =>
131
155
  .join(""),
132
156
  );
133
157
 
158
+ describe("useStreamRuntime metadata cache", () => {
159
+ it("re-converts only the parent when a UI event changes", () => {
160
+ const messageCount = 1_000;
161
+ const messages = Array.from({ length: messageCount }, (_, index) =>
162
+ message(
163
+ `message-${index}`,
164
+ index % 2 === 0 ? "human" : "ai",
165
+ `Message ${index}`,
166
+ ),
167
+ );
168
+ const parentId = `message-${messageCount - 1}`;
169
+ const parentUI: UIMessage = {
170
+ type: "ui",
171
+ id: "ui-1",
172
+ name: "chart",
173
+ props: { value: 1 },
174
+ metadata: { message_id: parentId },
175
+ };
176
+ const stream = createMockStream(messages);
177
+ const { auiResult, rerender } = renderAui(stream);
178
+
179
+ expect(conversionSpy).toHaveBeenCalledTimes(messageCount);
180
+ conversionSpy.mockClear();
181
+
182
+ mockUseChannel.mockReturnValue([{ params: { data: parentUI } }] as never);
183
+ rerender();
184
+
185
+ expect(conversionSpy).toHaveBeenCalledOnce();
186
+ expect(conversionSpy).toHaveBeenCalledWith(parentId);
187
+ expect(
188
+ auiResult.current.thread.getState().messages.at(-1)?.content.at(-1),
189
+ ).toMatchObject({
190
+ type: "data",
191
+ name: "chart",
192
+ data: { value: 1 },
193
+ });
194
+ });
195
+ });
196
+
134
197
  const makeThreadListAdapter = (): RemoteThreadListAdapter => ({
135
198
  list: vi.fn(async () => ({
136
199
  threads: [
@@ -918,6 +981,231 @@ describe("useStreamRuntime staged messages", () => {
918
981
  });
919
982
  });
920
983
 
984
+ describe("useStreamRuntime fork ownership", () => {
985
+ type ForkHistory = {
986
+ values: { messages: LangChainBaseMessage[] };
987
+ checkpoint: { checkpoint_id: string };
988
+ }[];
989
+
990
+ const renderFork = async (stream: MockStream) => {
991
+ mockUseStream.mockReturnValue(stream);
992
+ const runtime = renderHook(() =>
993
+ useStreamRuntime({
994
+ apiUrl: "/api",
995
+ unstable_threadListAdapter: makeThreadListAdapter(),
996
+ } as never),
997
+ );
998
+ const view = render(
999
+ <AssistantRuntimeProvider runtime={runtime.result.current}>
1000
+ {null}
1001
+ </AssistantRuntimeProvider>,
1002
+ );
1003
+ await settleOutsideAct(() =>
1004
+ runtime.result.current.threads.switchToThread("thread-a"),
1005
+ );
1006
+ return { runtime, view };
1007
+ };
1008
+
1009
+ it("stops the streaming reply before an edit looks up its checkpoint and submits once", async () => {
1010
+ const events: string[] = [];
1011
+ const lookup = deferred<ForkHistory>();
1012
+ const stream = createMockStream([
1013
+ message("u1", "human", "original"),
1014
+ message("a1", "ai", "partial reply"),
1015
+ ]);
1016
+ stream.isLoading = true;
1017
+ stream.stop.mockImplementation(() => events.push("stop"));
1018
+ stream.client = {
1019
+ threads: {
1020
+ getHistory: vi.fn(() => {
1021
+ events.push("lookup");
1022
+ return lookup.promise;
1023
+ }),
1024
+ },
1025
+ };
1026
+ stream.submit.mockImplementation(async () => {
1027
+ events.push("submit");
1028
+ });
1029
+ const { runtime, view } = await renderFork(stream);
1030
+
1031
+ await act(async () => {
1032
+ runtime.result.current.thread.append({
1033
+ role: "user",
1034
+ parentId: null,
1035
+ sourceId: "u1",
1036
+ content: [{ type: "text", text: "edited" }],
1037
+ });
1038
+ await Promise.resolve();
1039
+ });
1040
+ expect(events).toEqual(["stop", "lookup"]);
1041
+ expect(stream.submit).not.toHaveBeenCalled();
1042
+
1043
+ await act(async () => {
1044
+ lookup.resolve([
1045
+ { values: { messages: [] }, checkpoint: { checkpoint_id: "cp-1" } },
1046
+ ]);
1047
+ await Promise.resolve();
1048
+ });
1049
+ expect(events).toEqual(["stop", "lookup", "submit"]);
1050
+ expect(stream.submit).toHaveBeenCalledExactlyOnceWith(
1051
+ { messages: [{ type: "human", content: "edited" }] },
1052
+ { forkFrom: "cp-1" },
1053
+ );
1054
+ view.unmount();
1055
+ runtime.unmount();
1056
+ });
1057
+
1058
+ it("drops the first edit when a second edit supersedes its checkpoint lookup", async () => {
1059
+ const firstLookup = deferred<ForkHistory>();
1060
+ const secondLookup = deferred<ForkHistory>();
1061
+ const stream = createMockStream([
1062
+ message("u1", "human", "original"),
1063
+ message("a1", "ai", "partial reply"),
1064
+ ]);
1065
+ stream.isLoading = true;
1066
+ const getHistory = vi
1067
+ .fn()
1068
+ .mockImplementationOnce(() => firstLookup.promise)
1069
+ .mockImplementationOnce(() => secondLookup.promise);
1070
+ stream.client = { threads: { getHistory } };
1071
+ const { runtime, view } = await renderFork(stream);
1072
+
1073
+ await act(async () => {
1074
+ runtime.result.current.thread.append({
1075
+ role: "user",
1076
+ parentId: null,
1077
+ sourceId: "u1",
1078
+ content: [{ type: "text", text: "first edit" }],
1079
+ });
1080
+ await Promise.resolve();
1081
+ });
1082
+ await waitFor(() => expect(getHistory).toHaveBeenCalledTimes(1));
1083
+ await act(async () => {
1084
+ runtime.result.current.thread.append({
1085
+ role: "user",
1086
+ parentId: null,
1087
+ sourceId: "u1",
1088
+ content: [{ type: "text", text: "second edit" }],
1089
+ });
1090
+ await Promise.resolve();
1091
+ });
1092
+ await waitFor(() => expect(getHistory).toHaveBeenCalledTimes(2));
1093
+
1094
+ await act(async () => {
1095
+ firstLookup.resolve([
1096
+ { values: { messages: [] }, checkpoint: { checkpoint_id: "cp-1" } },
1097
+ ]);
1098
+ await Promise.resolve();
1099
+ });
1100
+ expect(stream.submit).not.toHaveBeenCalled();
1101
+
1102
+ await act(async () => {
1103
+ secondLookup.resolve([
1104
+ { values: { messages: [] }, checkpoint: { checkpoint_id: "cp-2" } },
1105
+ ]);
1106
+ await Promise.resolve();
1107
+ });
1108
+ expect(stream.submit).toHaveBeenCalledExactlyOnceWith(
1109
+ { messages: [{ type: "human", content: "second edit" }] },
1110
+ { forkFrom: "cp-2" },
1111
+ );
1112
+ view.unmount();
1113
+ runtime.unmount();
1114
+ });
1115
+
1116
+ it("stops the streaming reply before reload looks up its checkpoint", async () => {
1117
+ const events: string[] = [];
1118
+ const lookup = deferred<ForkHistory>();
1119
+ const stream = createMockStream([
1120
+ message("u1", "human", "question"),
1121
+ message("a1", "ai", "partial reply"),
1122
+ ]);
1123
+ stream.isLoading = true;
1124
+ stream.stop.mockImplementation(() => events.push("stop"));
1125
+ stream.client = {
1126
+ threads: {
1127
+ getHistory: vi.fn(() => {
1128
+ events.push("lookup");
1129
+ return lookup.promise;
1130
+ }),
1131
+ },
1132
+ };
1133
+ stream.submit.mockImplementation(async () => {
1134
+ events.push("submit");
1135
+ });
1136
+ const { runtime, view } = await renderFork(stream);
1137
+
1138
+ await act(async () => {
1139
+ runtime.result.current.thread.getMessageById("a1").reload();
1140
+ await Promise.resolve();
1141
+ });
1142
+ expect(events).toEqual(["stop", "lookup"]);
1143
+ expect(stream.submit).not.toHaveBeenCalled();
1144
+
1145
+ await act(async () => {
1146
+ lookup.resolve([
1147
+ {
1148
+ values: { messages: [message("u1", "human", "question")] },
1149
+ checkpoint: { checkpoint_id: "cp-1" },
1150
+ },
1151
+ ]);
1152
+ await Promise.resolve();
1153
+ });
1154
+ expect(events).toEqual(["stop", "lookup", "submit"]);
1155
+ expect(stream.submit).toHaveBeenCalledExactlyOnceWith(null, {
1156
+ forkFrom: "cp-1",
1157
+ });
1158
+ view.unmount();
1159
+ runtime.unmount();
1160
+ });
1161
+
1162
+ it("drops an edit lookup after Stop", async () => {
1163
+ const lookup = deferred<ForkHistory>();
1164
+ const stream = createMockStream([
1165
+ message("u1", "human", "original"),
1166
+ message("a1", "ai", "partial reply"),
1167
+ ]);
1168
+ stream.stop.mockImplementation(() => {
1169
+ stream.isLoading = false;
1170
+ });
1171
+ const getHistory = vi.fn(() => lookup.promise);
1172
+ stream.client = { threads: { getHistory } };
1173
+ const { runtime, view } = await renderFork(stream);
1174
+
1175
+ await act(async () => {
1176
+ runtime.result.current.thread.append({
1177
+ role: "user",
1178
+ parentId: null,
1179
+ sourceId: "u1",
1180
+ content: [{ type: "text", text: "edited" }],
1181
+ });
1182
+ await Promise.resolve();
1183
+ });
1184
+ await waitFor(() => expect(getHistory).toHaveBeenCalledOnce());
1185
+ runtime.rerender();
1186
+ expect(runtime.result.current.thread.getState().isRunning).toBe(true);
1187
+ await act(async () => {
1188
+ await runtime.result.current.thread.cancelRun();
1189
+ });
1190
+
1191
+ await act(async () => {
1192
+ lookup.resolve([
1193
+ { values: { messages: [] }, checkpoint: { checkpoint_id: "cp-1" } },
1194
+ ]);
1195
+ await Promise.resolve();
1196
+ });
1197
+ expect(stream.submit).not.toHaveBeenCalled();
1198
+ expect(runtime.result.current.thread.getState().isRunning).toBe(false);
1199
+ expect(
1200
+ runtime.result.current.thread
1201
+ .getState()
1202
+ .messages.find((m) => m.id === "a1")?.metadata,
1203
+ ).not.toHaveProperty("timing");
1204
+ view.unmount();
1205
+ runtime.unmount();
1206
+ });
1207
+ });
1208
+
921
1209
  describe("useStreamRuntime pending tool call cancellation", () => {
922
1210
  it("cancels only the pending tool calls that carry an id", async () => {
923
1211
  const stream = createMockStream([