@assistant-ui/react-langchain 0.0.32 → 0.0.34

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 (59) hide show
  1. package/LICENSE +1 -1
  2. package/dist/attachSubagentTranscripts.d.ts +13 -14
  3. package/dist/attachSubagentTranscripts.d.ts.map +1 -1
  4. package/dist/convertMessages.d.ts +6 -9
  5. package/dist/convertMessages.d.ts.map +1 -1
  6. package/dist/convertMessages.js +57 -15
  7. package/dist/convertMessages.js.map +1 -1
  8. package/dist/converter.d.ts +128 -128
  9. package/dist/converter.d.ts.map +1 -1
  10. package/dist/converter.js +2 -1
  11. package/dist/converter.js.map +1 -1
  12. package/dist/findForkCheckpointInHistory.d.ts +15 -16
  13. package/dist/findForkCheckpointInHistory.d.ts.map +1 -1
  14. package/dist/hooks.d.ts +5 -7
  15. package/dist/hooks.d.ts.map +1 -1
  16. package/dist/index.d.ts +7 -8
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/resolveForkCheckpoint.d.ts +4 -5
  19. package/dist/resolveForkCheckpoint.d.ts.map +1 -1
  20. package/dist/runtimeExtras.d.ts +1 -3
  21. package/dist/runtimeExtras.d.ts.map +1 -1
  22. package/dist/sdkIdentity.d.ts +1 -3
  23. package/dist/sdkIdentity.d.ts.map +1 -1
  24. package/dist/sdkIdentity.js +1 -1
  25. package/dist/streamingTiming.d.ts +2 -4
  26. package/dist/streamingTiming.d.ts.map +1 -1
  27. package/dist/subagentMessagesProjection.d.ts +17 -0
  28. package/dist/subagentMessagesProjection.d.ts.map +1 -0
  29. package/dist/subagentMessagesProjection.js +40 -0
  30. package/dist/subagentMessagesProjection.js.map +1 -0
  31. package/dist/types.d.ts +100 -103
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/uiMessages.d.ts +18 -6
  34. package/dist/uiMessages.d.ts.map +1 -1
  35. package/dist/uiMessages.js +33 -1
  36. package/dist/uiMessages.js.map +1 -1
  37. package/dist/useStreamRuntime.d.ts +7 -10
  38. package/dist/useStreamRuntime.d.ts.map +1 -1
  39. package/dist/useStreamRuntime.js +100 -26
  40. package/dist/useStreamRuntime.js.map +1 -1
  41. package/dist/useSubagentTranscripts.d.ts +3 -5
  42. package/dist/useSubagentTranscripts.d.ts.map +1 -1
  43. package/dist/useSubagentTranscripts.js +5 -3
  44. package/dist/useSubagentTranscripts.js.map +1 -1
  45. package/package.json +16 -9
  46. package/src/convertMessages.test.ts +153 -0
  47. package/src/convertMessages.ts +54 -8
  48. package/src/converter.ts +6 -0
  49. package/src/subagentMessagesProjection.test.ts +175 -0
  50. package/src/subagentMessagesProjection.ts +50 -0
  51. package/src/tests/settleOutsideAct.ts +15 -0
  52. package/src/uiMessages.test.ts +106 -0
  53. package/src/uiMessages.ts +43 -0
  54. package/src/useStreamRuntime.test.tsx +147 -7
  55. package/src/useStreamRuntime.ts +197 -45
  56. package/src/useStreamRuntime.voice.test.tsx +713 -0
  57. package/src/useSubagentTranscripts.fast-refresh.test.tsx +152 -0
  58. package/src/useSubagentTranscripts.test.tsx +2 -0
  59. package/src/useSubagentTranscripts.ts +5 -7
@@ -0,0 +1,50 @@
1
+ import type { BaseMessage } from "@langchain/core/messages";
2
+ import {
3
+ messagesProjection,
4
+ type ProjectionSpec,
5
+ } from "@langchain/langgraph-sdk/stream";
6
+
7
+ type MessagesProjection = ProjectionSpec<BaseMessage[]>;
8
+
9
+ type ProjectionThread = Parameters<MessagesProjection["open"]>[0]["thread"];
10
+
11
+ const exactDepthThread = (thread: ProjectionThread): ProjectionThread =>
12
+ new Proxy(thread, {
13
+ get(target, property) {
14
+ if (property === "subscribe") {
15
+ return (...[params]: Parameters<ProjectionThread["subscribe"]>) =>
16
+ target.subscribe({ ...params, depth: 0 });
17
+ }
18
+ const value = Reflect.get(target, property, target);
19
+ return typeof value === "function" ? value.bind(target) : value;
20
+ },
21
+ });
22
+
23
+ /**
24
+ * The SDK's messages projection subscribes at the protocol's default depth
25
+ * of 1 and applies every delivered event, so a nested subagent's `values`
26
+ * snapshots rebuild its parent's store from the child's state while the child
27
+ * runs. The projection exposes no depth option, so this spec opens it against
28
+ * a thread whose subscription is pinned to depth 0: the namespace the SDK
29
+ * sets stays and only events at exactly that namespace are delivered, the
30
+ * rule the SDK's root projection already applies. The client unions every
31
+ * subscription's depth into the server filter, so a depth 0 subscription
32
+ * narrows nothing for other consumers.
33
+ */
34
+ export const subagentMessagesProjection = (
35
+ namespace: readonly string[],
36
+ ): MessagesProjection => {
37
+ const projection = messagesProjection(namespace);
38
+ return {
39
+ key: `exact|${projection.key}`,
40
+ namespace: projection.namespace,
41
+ initial: projection.initial,
42
+ open({ thread, store, rootBus }) {
43
+ return projection.open({
44
+ thread: exactDepthThread(thread),
45
+ store,
46
+ rootBus,
47
+ });
48
+ },
49
+ };
50
+ };
@@ -0,0 +1,15 @@
1
+ import { act, waitFor } from "@testing-library/react";
2
+
3
+ // React 18's act holds renders until its callback settles, so a task that waits on a render starts inside act and settles outside it, where React can commit what the task waits on.
4
+ export async function settleOutsideAct<T>(task: () => Promise<T>): Promise<T> {
5
+ let pending!: Promise<T>;
6
+ await act(async () => {
7
+ pending = task();
8
+ pending.catch(() => {});
9
+ });
10
+ await waitFor(() => pending.then(settled, settled));
11
+ await act(async () => {});
12
+ return pending;
13
+ }
14
+
15
+ const settled = () => {};
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  applyUIUpdate,
4
4
  createUIFoldMemo,
5
+ createUISnapshotMemo,
5
6
  extractUIUpdate,
6
7
  foldUIUpdates,
7
8
  isUIUpdate,
8
9
  mergeUIMessages,
10
+ reconcileUISnapshot,
9
11
  } from "./uiMessages";
10
12
  import type { UIMessage } from "./types";
11
13
 
@@ -138,6 +140,110 @@ describe("extractUIUpdate", () => {
138
140
  });
139
141
  });
140
142
 
143
+ describe("reconcileUISnapshot", () => {
144
+ const snapshot = (...entries: UIMessage[]) =>
145
+ entries.map((entry) => structuredClone(entry));
146
+
147
+ it("returns the previous list for an equal copy of the snapshot", () => {
148
+ const memo = createUISnapshotMemo();
149
+ const first = reconcileUISnapshot(
150
+ snapshot(ui("a", { x: 1 }), ui("b", { y: [1, 2] })),
151
+ memo,
152
+ );
153
+
154
+ const second = reconcileUISnapshot(
155
+ snapshot(ui("a", { x: 1 }), ui("b", { y: [1, 2] })),
156
+ memo,
157
+ );
158
+
159
+ expect(second).toBe(first);
160
+ expect(second).toEqual([ui("a", { x: 1 }), ui("b", { y: [1, 2] })]);
161
+ });
162
+
163
+ it("returns the same list for the same snapshot reference", () => {
164
+ const memo = createUISnapshotMemo();
165
+ const value = snapshot(ui("a"));
166
+ const first = reconcileUISnapshot(value, memo);
167
+
168
+ expect(reconcileUISnapshot(value, memo)).toBe(first);
169
+ });
170
+
171
+ it("replaces only the entry that changed", () => {
172
+ const memo = createUISnapshotMemo();
173
+ const [a, b] = reconcileUISnapshot(
174
+ snapshot(ui("a", { x: 1 }), ui("b", { y: 1 })),
175
+ memo,
176
+ );
177
+
178
+ const result = reconcileUISnapshot(
179
+ snapshot(ui("a", { x: 1 }), ui("b", { y: 2 })),
180
+ memo,
181
+ );
182
+
183
+ expect(result[0]).toBe(a);
184
+ expect(result[1]).not.toBe(b);
185
+ expect(result[1]).toEqual(ui("b", { y: 2 }));
186
+ });
187
+
188
+ it("keeps surviving entries across an addition and a removal", () => {
189
+ const memo = createUISnapshotMemo();
190
+ const [a, b] = reconcileUISnapshot(snapshot(ui("a"), ui("b")), memo);
191
+
192
+ const added = reconcileUISnapshot(
193
+ snapshot(ui("a"), ui("b"), ui("c")),
194
+ memo,
195
+ );
196
+ expect(added[0]).toBe(a);
197
+ expect(added[1]).toBe(b);
198
+ expect(added).toHaveLength(3);
199
+
200
+ const removed = reconcileUISnapshot(snapshot(ui("b"), ui("c")), memo);
201
+ expect(removed[0]).toBe(b);
202
+ expect(removed[1]).toBe(added[2]);
203
+ expect(removed).toHaveLength(2);
204
+ });
205
+
206
+ it("returns a new list when equal entries change position", () => {
207
+ const memo = createUISnapshotMemo();
208
+ const [a, b] = reconcileUISnapshot(snapshot(ui("a"), ui("b")), memo);
209
+
210
+ const result = reconcileUISnapshot(snapshot(ui("b"), ui("a")), memo);
211
+
212
+ expect(result).toEqual([ui("b"), ui("a")]);
213
+ expect(result[0]).toBe(b);
214
+ expect(result[1]).toBe(a);
215
+ });
216
+
217
+ it("treats an entry with a changed name or metadata as new", () => {
218
+ const memo = createUISnapshotMemo();
219
+ const [a] = reconcileUISnapshot(
220
+ snapshot({ ...ui("a"), metadata: { message_id: "m1" } }),
221
+ memo,
222
+ );
223
+
224
+ const renamed = reconcileUISnapshot(
225
+ snapshot({ ...ui("a"), name: "table", metadata: { message_id: "m1" } }),
226
+ memo,
227
+ );
228
+ expect(renamed[0]).not.toBe(a);
229
+
230
+ const moved = reconcileUISnapshot(
231
+ snapshot({ ...ui("a"), name: "table", metadata: { message_id: "m2" } }),
232
+ memo,
233
+ );
234
+ expect(moved[0]).not.toBe(renamed[0]);
235
+ });
236
+
237
+ it("returns an empty list for a non-array snapshot", () => {
238
+ const memo = createUISnapshotMemo();
239
+ expect(reconcileUISnapshot(undefined, memo)).toEqual([]);
240
+ reconcileUISnapshot(snapshot(ui("a")), memo);
241
+
242
+ expect(reconcileUISnapshot(null, memo)).toEqual([]);
243
+ expect(reconcileUISnapshot({ id: "a" }, memo)).toEqual([]);
244
+ });
245
+ });
246
+
141
247
  describe("mergeUIMessages", () => {
142
248
  it("returns the snapshot when there are no live messages", () => {
143
249
  expect(mergeUIMessages([], [ui("a")])).toEqual([ui("a")]);
package/src/uiMessages.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Channel } from "@langchain/react";
2
+ import { isJSONValueEqual } from "@assistant-ui/core/internal";
2
3
  import type { RemoveUIMessage, UIMessage } from "./types";
3
4
 
4
5
  /** Channels the generative-UI fold reads, at the root and per subagent. */
@@ -103,6 +104,48 @@ export const foldUIUpdates = (
103
104
  return acc;
104
105
  };
105
106
 
107
+ export type UISnapshotMemo = {
108
+ snapshot: unknown;
109
+ entries: readonly UIMessage[];
110
+ };
111
+
112
+ export const createUISnapshotMemo = (): UISnapshotMemo => ({
113
+ snapshot: undefined,
114
+ entries: [],
115
+ });
116
+
117
+ /**
118
+ * Recovers entry identity across `values` events. The SDK rebuilds the
119
+ * `values` object from every snapshot and reconciles only the messages slot by
120
+ * id, so an unchanged UI list arrives as a new array of new objects on every
121
+ * superstep. An entry structurally equal to the previous entry with its id is
122
+ * replaced by that previous object, and a list whose entries all survive in
123
+ * place is replaced by the previous list.
124
+ */
125
+ export const reconcileUISnapshot = (
126
+ snapshot: unknown,
127
+ memo: UISnapshotMemo,
128
+ ): readonly UIMessage[] => {
129
+ if (snapshot === memo.snapshot) return memo.entries;
130
+ memo.snapshot = snapshot;
131
+ if (!Array.isArray(snapshot)) {
132
+ if (memo.entries.length > 0) memo.entries = [];
133
+ return memo.entries;
134
+ }
135
+ const previous = memo.entries;
136
+ const previousById = new Map(previous.map((ui) => [ui.id, ui]));
137
+ let same = snapshot.length === previous.length;
138
+ const entries = (snapshot as UIMessage[]).map((ui, index) => {
139
+ const before = previousById.get(ui.id);
140
+ const entry =
141
+ before !== undefined && isJSONValueEqual(before, ui) ? before : ui;
142
+ if (entry !== previous[index]) same = false;
143
+ return entry;
144
+ });
145
+ if (!same) memo.entries = entries;
146
+ return memo.entries;
147
+ };
148
+
106
149
  /**
107
150
  * Merges live-streamed UI with the state snapshot. The snapshot is
108
151
  * authoritative by id: once a UI lands in graph state it supersedes its live
@@ -9,7 +9,7 @@ import type {
9
9
  RemoteThreadListAdapter,
10
10
  } from "@assistant-ui/core";
11
11
  import { useAui } from "@assistant-ui/store";
12
- import type { LangChainBaseMessage } from "./types";
12
+ import type { LangChainBaseMessage, LangChainToolCall } from "./types";
13
13
  import { startTransition, Suspense, type ReactNode } from "react";
14
14
  import {
15
15
  useLangChainRespond,
@@ -32,6 +32,7 @@ vi.mock("@langchain/react", () => ({
32
32
  }));
33
33
 
34
34
  import { useStreamRuntime } from "./useStreamRuntime";
35
+ import { settleOutsideAct } from "./tests/settleOutsideAct";
35
36
 
36
37
  type MockStream = {
37
38
  messages: LangChainBaseMessage[];
@@ -194,18 +195,18 @@ describe("useStreamRuntime thread options", () => {
194
195
 
195
196
  const view = render(<TestRuntime />);
196
197
 
197
- await act(async () => {
198
- await capture.runtime!.threads.switchToThread("thread-a");
199
- });
198
+ await settleOutsideAct(() =>
199
+ capture.runtime!.threads.switchToThread("thread-a"),
200
+ );
200
201
 
201
202
  const threadAOptions = mockUseStream.mock.calls
202
203
  .map(([options]) => options as { threadId?: string | null })
203
204
  .findLast((options) => options.threadId === "thread-a");
204
205
  expect(threadAOptions).toBeDefined();
205
206
 
206
- await act(async () => {
207
- await capture.runtime!.threads.switchToThread("thread-b");
208
- });
207
+ await settleOutsideAct(() =>
208
+ capture.runtime!.threads.switchToThread("thread-b"),
209
+ );
209
210
 
210
211
  const threadBOptions = mockUseStream.mock.calls
211
212
  .map(([options]) => options as { threadId?: string | null })
@@ -917,6 +918,45 @@ describe("useStreamRuntime staged messages", () => {
917
918
  });
918
919
  });
919
920
 
921
+ describe("useStreamRuntime pending tool call cancellation", () => {
922
+ it("cancels only the pending tool calls that carry an id", async () => {
923
+ const stream = createMockStream([
924
+ message("u1", "human", "look it up"),
925
+ {
926
+ id: "a1",
927
+ _getType: () => "ai",
928
+ content: "",
929
+ tool_calls: [
930
+ { name: "lookup", args: {} } as LangChainToolCall,
931
+ { id: "call-1", name: "search", args: {} },
932
+ ],
933
+ },
934
+ ]);
935
+ const { auiResult } = renderAui(stream);
936
+
937
+ await act(async () => {
938
+ await auiResult.current.thread.append({
939
+ role: "user",
940
+ content: [{ type: "text", text: "never mind" }],
941
+ });
942
+ });
943
+
944
+ expect(stream.submit).toHaveBeenCalledTimes(1);
945
+ expect(stream.submit.mock.calls[0]![0]).toEqual({
946
+ messages: [
947
+ {
948
+ type: "tool",
949
+ name: "search",
950
+ tool_call_id: "call-1",
951
+ content: JSON.stringify({ cancelled: true }),
952
+ status: "error",
953
+ },
954
+ { id: expect.any(String), type: "human", content: "never mind" },
955
+ ],
956
+ });
957
+ });
958
+ });
959
+
920
960
  describe("useStreamRuntime committed refs", () => {
921
961
  it("submits through the committed stream after an abandoned render", async () => {
922
962
  const streamA = createMockStream();
@@ -1072,6 +1112,106 @@ describe("useStreamRuntime subagent transcripts", () => {
1072
1112
  expect(nestedTranscript()).toBe(rendered);
1073
1113
  });
1074
1114
 
1115
+ it("keeps messages and transcripts across equal copies of the UI state", async () => {
1116
+ const stream = createMockStream([
1117
+ message("human-1", "human", "delegate"),
1118
+ {
1119
+ id: "root-ai",
1120
+ _getType: () => "ai",
1121
+ content: "",
1122
+ tool_calls: [{ id: "task-one", name: "task", args: {} }],
1123
+ },
1124
+ ]);
1125
+ const transcript = [message("nested-ai", "ai", "nested answer")];
1126
+ stream.subagents = new Map([
1127
+ [
1128
+ "task-one",
1129
+ {
1130
+ id: "task-one",
1131
+ namespace: ["tools:task-one"],
1132
+ status: "running",
1133
+ parentId: null,
1134
+ depth: 1,
1135
+ startedAt: new Date(1_000),
1136
+ completedAt: null,
1137
+ },
1138
+ ],
1139
+ ]);
1140
+ stream[streamController]!.registry.acquire.mockReturnValue({
1141
+ store: { getSnapshot: () => transcript, subscribe: () => () => {} },
1142
+ release: vi.fn(),
1143
+ });
1144
+ const uiState = (points: number[]) => [
1145
+ {
1146
+ type: "ui",
1147
+ id: "ui-root",
1148
+ name: "chart",
1149
+ props: { points },
1150
+ metadata: { message_id: "root-ai" },
1151
+ },
1152
+ {
1153
+ type: "ui",
1154
+ id: "ui-nested",
1155
+ name: "chart",
1156
+ props: { points },
1157
+ metadata: { message_id: "nested-ai" },
1158
+ },
1159
+ ];
1160
+ stream.values = { ui: uiState([1, 2]) };
1161
+ const { auiResult, rerender } = renderAui(stream);
1162
+ const nestedTranscript = () => {
1163
+ const { messages } = auiResult.current.thread.getState();
1164
+ for (const threadMessage of messages) {
1165
+ for (const part of threadMessage.content) {
1166
+ if (part.type === "tool-call" && part.toolCallId === "task-one")
1167
+ return part.messages;
1168
+ }
1169
+ }
1170
+ return undefined;
1171
+ };
1172
+
1173
+ await waitFor(() =>
1174
+ expect(nestedTranscript()?.[0]?.content).toMatchObject([
1175
+ { type: "text", text: "nested answer" },
1176
+ { type: "data", name: "chart", data: { points: [1, 2] } },
1177
+ ]),
1178
+ );
1179
+ const [human, ai] = auiResult.current.thread.getState().messages;
1180
+ expect(ai?.content).toMatchObject([
1181
+ { type: "tool-call", toolCallId: "task-one" },
1182
+ { type: "data", name: "chart", data: { points: [1, 2] } },
1183
+ ]);
1184
+ const rendered = nestedTranscript();
1185
+
1186
+ for (let i = 0; i < 3; i++) {
1187
+ stream.values = { ui: uiState([1, 2]) };
1188
+ await act(async () => {
1189
+ rerender();
1190
+ });
1191
+ }
1192
+
1193
+ const messages = auiResult.current.thread.getState().messages;
1194
+ expect(messages[0]).toBe(human);
1195
+ expect(messages[1]).toBe(ai);
1196
+ expect(nestedTranscript()).toBe(rendered);
1197
+
1198
+ stream.values = { ui: uiState([1, 2, 3]) };
1199
+ await act(async () => {
1200
+ rerender();
1201
+ });
1202
+
1203
+ expect(
1204
+ auiResult.current.thread.getState().messages[1]?.content,
1205
+ ).toMatchObject([
1206
+ { type: "tool-call", toolCallId: "task-one" },
1207
+ { type: "data", name: "chart", data: { points: [1, 2, 3] } },
1208
+ ]);
1209
+ expect(nestedTranscript()?.[0]?.content).toMatchObject([
1210
+ { type: "text", text: "nested answer" },
1211
+ { type: "data", name: "chart", data: { points: [1, 2, 3] } },
1212
+ ]);
1213
+ });
1214
+
1075
1215
  it("keeps messages and transcripts when custom events carry no UI update", async () => {
1076
1216
  const stream = createMockStream([
1077
1217
  message("human-1", "human", "delegate"),