@assistant-ui/react-langchain 0.0.32 → 0.0.33

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 (55) hide show
  1. package/dist/attachSubagentTranscripts.d.ts +13 -14
  2. package/dist/attachSubagentTranscripts.d.ts.map +1 -1
  3. package/dist/convertMessages.d.ts +6 -9
  4. package/dist/convertMessages.d.ts.map +1 -1
  5. package/dist/convertMessages.js +53 -15
  6. package/dist/convertMessages.js.map +1 -1
  7. package/dist/converter.d.ts +128 -128
  8. package/dist/converter.d.ts.map +1 -1
  9. package/dist/converter.js +2 -1
  10. package/dist/converter.js.map +1 -1
  11. package/dist/findForkCheckpointInHistory.d.ts +15 -16
  12. package/dist/findForkCheckpointInHistory.d.ts.map +1 -1
  13. package/dist/hooks.d.ts +5 -7
  14. package/dist/hooks.d.ts.map +1 -1
  15. package/dist/index.d.ts +7 -8
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/resolveForkCheckpoint.d.ts +4 -5
  18. package/dist/resolveForkCheckpoint.d.ts.map +1 -1
  19. package/dist/runtimeExtras.d.ts +1 -3
  20. package/dist/runtimeExtras.d.ts.map +1 -1
  21. package/dist/sdkIdentity.d.ts +1 -3
  22. package/dist/sdkIdentity.d.ts.map +1 -1
  23. package/dist/sdkIdentity.js +1 -1
  24. package/dist/streamingTiming.d.ts +2 -4
  25. package/dist/streamingTiming.d.ts.map +1 -1
  26. package/dist/subagentMessagesProjection.d.ts +17 -0
  27. package/dist/subagentMessagesProjection.d.ts.map +1 -0
  28. package/dist/subagentMessagesProjection.js +40 -0
  29. package/dist/subagentMessagesProjection.js.map +1 -0
  30. package/dist/types.d.ts +100 -103
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/uiMessages.d.ts +18 -6
  33. package/dist/uiMessages.d.ts.map +1 -1
  34. package/dist/uiMessages.js +33 -1
  35. package/dist/uiMessages.js.map +1 -1
  36. package/dist/useStreamRuntime.d.ts +7 -10
  37. package/dist/useStreamRuntime.d.ts.map +1 -1
  38. package/dist/useStreamRuntime.js +99 -25
  39. package/dist/useStreamRuntime.js.map +1 -1
  40. package/dist/useSubagentTranscripts.d.ts +3 -5
  41. package/dist/useSubagentTranscripts.d.ts.map +1 -1
  42. package/dist/useSubagentTranscripts.js +3 -2
  43. package/dist/useSubagentTranscripts.js.map +1 -1
  44. package/package.json +9 -5
  45. package/src/convertMessages.test.ts +69 -0
  46. package/src/convertMessages.ts +46 -8
  47. package/src/converter.ts +6 -0
  48. package/src/subagentMessagesProjection.test.ts +175 -0
  49. package/src/subagentMessagesProjection.ts +50 -0
  50. package/src/uiMessages.test.ts +106 -0
  51. package/src/uiMessages.ts +43 -0
  52. package/src/useStreamRuntime.test.tsx +100 -0
  53. package/src/useStreamRuntime.ts +193 -44
  54. package/src/useStreamRuntime.voice.test.tsx +712 -0
  55. package/src/useSubagentTranscripts.ts +3 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-langchain",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "description": "LangChain useStream adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "langchain",
@@ -40,18 +40,22 @@
40
40
  ],
41
41
  "sideEffects": false,
42
42
  "dependencies": {
43
- "@assistant-ui/core": "^0.3.20",
44
- "@assistant-ui/store": "^0.3.14",
43
+ "@assistant-ui/core": "^0.3.21",
44
+ "@assistant-ui/store": "^0.3.15",
45
45
  "assistant-cloud": "*",
46
- "assistant-stream": "^0.3.44"
46
+ "assistant-stream": "^0.3.45"
47
47
  },
48
48
  "peerDependencies": {
49
+ "@langchain/core": "^1.0.0",
49
50
  "@langchain/langgraph-sdk": "^1.9.20",
50
51
  "@langchain/react": "^1.0.20",
51
52
  "@types/react": "*",
52
53
  "react": "^18 || ^19"
53
54
  },
54
55
  "peerDependenciesMeta": {
56
+ "@langchain/core": {
57
+ "optional": true
58
+ },
55
59
  "@langchain/langgraph-sdk": {
56
60
  "optional": true
57
61
  },
@@ -63,7 +67,7 @@
63
67
  }
64
68
  },
65
69
  "devDependencies": {
66
- "@assistant-ui/x-buildutils": "0.0.28",
70
+ "@assistant-ui/x-buildutils": "0.0.29",
67
71
  "@langchain/core": "^1.2.11",
68
72
  "@langchain/langgraph-sdk": "^1.11.0",
69
73
  "@langchain/react": "^1.1.0",
@@ -27,6 +27,27 @@ const contentOf = (result: ReturnType<typeof convertLangChainBaseMessage>) => {
27
27
  return result.content;
28
28
  };
29
29
 
30
+ describe("convertLangChainBaseMessage modality", () => {
31
+ it("lifts voice modality onto human and ai messages and ignores unknown values", () => {
32
+ for (const message of [humanMessage("Question"), aiMessage("Answer")]) {
33
+ const spoken = convertLangChainBaseMessage({
34
+ ...message,
35
+ additional_kwargs: { modality: "voice" },
36
+ });
37
+ const unknown = convertLangChainBaseMessage({
38
+ ...message,
39
+ additional_kwargs: { modality: "video" },
40
+ });
41
+
42
+ expect(spoken).toHaveProperty("metadata", {
43
+ custom: {},
44
+ modality: "voice",
45
+ });
46
+ expect(unknown).toHaveProperty("metadata", { custom: {} });
47
+ }
48
+ });
49
+ });
50
+
30
51
  describe("convertLangChainBaseMessage file content parts", () => {
31
52
  it("converts a base64 file block", () => {
32
53
  const result = convertLangChainBaseMessage(
@@ -1113,6 +1134,54 @@ describe("convertLangChainBaseMessage malformed messages", () => {
1113
1134
  ]);
1114
1135
  });
1115
1136
 
1137
+ it("normalizes missing, string, and array tool-call args to an object", () => {
1138
+ for (const args of [undefined, "not-json", ["x"], null]) {
1139
+ const result = convertLangChainBaseMessage(
1140
+ {
1141
+ ...aiMessage([]),
1142
+ tool_calls: [{ id: "call-1", name: "lookup", args }],
1143
+ } as unknown as LangChainBaseMessage,
1144
+ {},
1145
+ );
1146
+ expect(contentOf(result)).toEqual([
1147
+ {
1148
+ type: "tool-call",
1149
+ toolCallId: "call-1",
1150
+ toolName: "lookup",
1151
+ args: {},
1152
+ argsText: "{}",
1153
+ },
1154
+ ]);
1155
+ }
1156
+ });
1157
+
1158
+ it("normalizes unsafe object tool-call args to an empty object", () => {
1159
+ const cyclic: Record<string, unknown> = {};
1160
+ cyclic.self = cyclic;
1161
+ class CustomArgs {
1162
+ query = "x";
1163
+ }
1164
+
1165
+ for (const args of [new Date(0), new Map(), new CustomArgs(), cyclic]) {
1166
+ const result = convertLangChainBaseMessage(
1167
+ {
1168
+ ...aiMessage([]),
1169
+ tool_calls: [{ id: "call-1", name: "lookup", args }],
1170
+ } as unknown as LangChainBaseMessage,
1171
+ {},
1172
+ );
1173
+ expect(contentOf(result)).toEqual([
1174
+ {
1175
+ type: "tool-call",
1176
+ toolCallId: "call-1",
1177
+ toolName: "lookup",
1178
+ args: {},
1179
+ argsText: "{}",
1180
+ },
1181
+ ]);
1182
+ }
1183
+ });
1184
+
1116
1185
  it("converts a system message with null content to empty text", () => {
1117
1186
  const result = convertLangChainBaseMessage(
1118
1187
  { _getType: () => "system", id: "msg-4", content: null },
@@ -6,6 +6,7 @@ import type { ReadonlyJSONObject } from "assistant-stream/utils";
6
6
  import {
7
7
  convertLangChainContentBlock,
8
8
  getCustomMetadata,
9
+ getMessageModality,
9
10
  uiMessageToDataPart,
10
11
  withAudioTranscript,
11
12
  } from "./converter";
@@ -55,6 +56,35 @@ const contentBlocks = (content: unknown): readonly LangChainContentBlock[] => {
55
56
  return [];
56
57
  };
57
58
 
59
+ const normalizeToolCallArgs = (args: unknown): ReadonlyJSONObject => {
60
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
61
+ return {};
62
+ }
63
+
64
+ try {
65
+ const prototype = Object.getPrototypeOf(args);
66
+ return prototype === Object.prototype || prototype === null
67
+ ? (args as ReadonlyJSONObject)
68
+ : {};
69
+ } catch {
70
+ return {};
71
+ }
72
+ };
73
+
74
+ const toolCallArgs = (
75
+ value: unknown,
76
+ ): { args: ReadonlyJSONObject; argsText: string } => {
77
+ const args = normalizeToolCallArgs(value);
78
+ try {
79
+ const argsText = JSON.stringify(args);
80
+ return typeof argsText === "string"
81
+ ? { args, argsText }
82
+ : { args: {}, argsText: "{}" };
83
+ } catch {
84
+ return { args: {}, argsText: "{}" };
85
+ }
86
+ };
87
+
58
88
  const contentToParts = (content: unknown) => {
59
89
  if (typeof content === "string")
60
90
  return [{ type: "text" as const, text: content }];
@@ -89,25 +119,31 @@ export const convertLangChainBaseMessage = (
89
119
  },
90
120
  };
91
121
 
92
- case "human":
122
+ case "human": {
123
+ const modality = getMessageModality(message.additional_kwargs);
93
124
  return {
94
125
  role: "user",
95
126
  id: message.id,
96
127
  content: contentToParts(message.content),
97
128
  metadata: {
98
129
  custom: getCustomMetadata(message.additional_kwargs),
130
+ ...(modality && { modality }),
99
131
  },
100
132
  };
133
+ }
101
134
 
102
135
  case "ai": {
103
136
  const toolCallParts =
104
- message.tool_calls?.map((tc) => ({
105
- type: "tool-call" as const,
106
- toolCallId: tc.id,
107
- toolName: tc.name,
108
- args: tc.args as ReadonlyJSONObject,
109
- argsText: JSON.stringify(tc.args),
110
- })) ?? [];
137
+ message.tool_calls?.map((tc) => {
138
+ const { args, argsText } = toolCallArgs(tc.args);
139
+ return {
140
+ type: "tool-call" as const,
141
+ toolCallId: tc.id,
142
+ toolName: tc.name,
143
+ args,
144
+ argsText,
145
+ };
146
+ }) ?? [];
111
147
 
112
148
  const assistantStatus =
113
149
  typeof message.status === "object" ? message.status : undefined;
@@ -120,6 +156,7 @@ export const convertLangChainBaseMessage = (
120
156
  : undefined) ?? [];
121
157
 
122
158
  const timing = metadata.messageTiming?.[message.id ?? ""];
159
+ const modality = getMessageModality(message.additional_kwargs);
123
160
 
124
161
  return {
125
162
  role: "assistant",
@@ -135,6 +172,7 @@ export const convertLangChainBaseMessage = (
135
172
  metadata: {
136
173
  custom: getCustomMetadata(message.additional_kwargs),
137
174
  ...(timing && { timing }),
175
+ ...(modality && { modality }),
138
176
  },
139
177
  ...(assistantStatus && { status: assistantStatus }),
140
178
  };
package/src/converter.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  AppendMessage,
3
3
  DataMessagePart,
4
+ MessageModality,
4
5
  ThreadAssistantMessage,
5
6
  ThreadUserMessage,
6
7
  } from "@assistant-ui/core";
@@ -146,6 +147,11 @@ export const getCustomMetadata = (
146
147
  ): Record<string, unknown> =>
147
148
  (additionalKwargs?.metadata as Record<string, unknown>) ?? {};
148
149
 
150
+ export const getMessageModality = (
151
+ additionalKwargs: Record<string, unknown> | undefined,
152
+ ): MessageModality | undefined =>
153
+ additionalKwargs?.modality === "voice" ? "voice" : undefined;
154
+
149
155
  export const uiMessageToDataPart = <
150
156
  TUIMessage extends { name: string; props: Record<string, unknown> },
151
157
  >(
@@ -0,0 +1,175 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { BaseMessage } from "@langchain/core/messages";
3
+ import {
4
+ matchesSubscription,
5
+ SubscriptionHandle,
6
+ } from "@langchain/langgraph-sdk/client";
7
+ import {
8
+ type Event,
9
+ messagesProjection,
10
+ type ProjectionSpec,
11
+ StreamStore,
12
+ } from "@langchain/langgraph-sdk/stream";
13
+ import { subagentMessagesProjection } from "./subagentMessagesProjection";
14
+
15
+ const PARENT = ["tools:parent"];
16
+ const CHILD = ["tools:parent", "tools:child"];
17
+
18
+ const human = (id: string, content: string) => ({
19
+ id,
20
+ type: "human",
21
+ content,
22
+ });
23
+ const ai = (id: string, content: string, tool_calls: unknown[] = []) => ({
24
+ id,
25
+ type: "ai",
26
+ content,
27
+ tool_calls,
28
+ });
29
+ const tool = (id: string, content: string, tool_call_id: string) => ({
30
+ id,
31
+ type: "tool",
32
+ content,
33
+ tool_call_id,
34
+ });
35
+
36
+ const values = (namespace: string[], messages: unknown[]) =>
37
+ ({
38
+ method: "values",
39
+ params: { namespace, data: { messages } },
40
+ }) as unknown as Event;
41
+
42
+ const messagesEvent = (namespace: string[], data: Record<string, unknown>) =>
43
+ ({
44
+ method: "messages",
45
+ params: { namespace, node: "model", data },
46
+ }) as unknown as Event;
47
+
48
+ /**
49
+ * Feeds the projection through the per-subscription matcher the SDK client
50
+ * applies before it hands an event to a subscription.
51
+ */
52
+ const openProjection = (spec: ProjectionSpec<BaseMessage[]>) => {
53
+ let handle: SubscriptionHandle<Event> | undefined;
54
+ const unsubscribe = vi.fn(async () => {});
55
+ const subscribe = vi.fn(
56
+ async (params: SubscriptionHandle<Event>["params"]) => {
57
+ handle = new SubscriptionHandle<Event>(
58
+ "subscription",
59
+ params,
60
+ unsubscribe,
61
+ );
62
+ return handle;
63
+ },
64
+ );
65
+ const store = new StreamStore<BaseMessage[]>(spec.initial);
66
+ const runtime = spec.open({
67
+ thread: { subscribe } as never,
68
+ store,
69
+ rootBus: {
70
+ channels: [
71
+ "values",
72
+ "checkpoints",
73
+ "lifecycle",
74
+ "input",
75
+ "messages",
76
+ "tools",
77
+ ],
78
+ subscribe: () => () => {},
79
+ },
80
+ });
81
+ const push = (event: Event) => {
82
+ if (matchesSubscription(event, handle!.params)) handle!.push(event);
83
+ };
84
+ const ids = () => store.getSnapshot().map((message) => message.id);
85
+ return { subscribe, unsubscribe, runtime, push, ids };
86
+ };
87
+
88
+ const parentTurn = [
89
+ human("parent-human", "research"),
90
+ ai("parent-ai", "", [
91
+ { id: "call-child", name: "task", args: { subagent_type: "worker" } },
92
+ ]),
93
+ ];
94
+
95
+ const childRun = [
96
+ values(CHILD, [human("child-human", "sub task")]),
97
+ messagesEvent(CHILD, { event: "message-start", id: "child-ai", role: "ai" }),
98
+ messagesEvent(CHILD, {
99
+ event: "content-block-start",
100
+ index: 0,
101
+ content: { type: "text", text: "hello from the child" },
102
+ }),
103
+ messagesEvent(CHILD, { event: "message-finish" }),
104
+ values(CHILD, [
105
+ human("child-human", "sub task"),
106
+ ai("child-ai", "hello from the child"),
107
+ ]),
108
+ ];
109
+
110
+ describe("subagentMessagesProjection", () => {
111
+ it("keeps the SDK projection's identity and pins its subscription to depth 0", async () => {
112
+ const spec = subagentMessagesProjection(PARENT);
113
+ expect(spec.namespace).toEqual(PARENT);
114
+ expect(spec.initial).toEqual([]);
115
+ expect(spec.key).not.toBe(messagesProjection(PARENT).key);
116
+
117
+ const { subscribe, runtime } = openProjection(spec);
118
+ await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce());
119
+ expect(subscribe).toHaveBeenCalledWith({
120
+ channels: ["messages", "values"],
121
+ namespaces: [PARENT],
122
+ depth: 0,
123
+ });
124
+ await runtime.dispose();
125
+ });
126
+
127
+ it("ignores a nested subagent's events while the parent's tool call runs", async () => {
128
+ const { subscribe, runtime, push, ids } = openProjection(
129
+ subagentMessagesProjection(PARENT),
130
+ );
131
+ await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce());
132
+
133
+ push(values(PARENT, parentTurn));
134
+ await vi.waitFor(() =>
135
+ expect(ids()).toEqual(["parent-human", "parent-ai"]),
136
+ );
137
+
138
+ for (const event of childRun) push(event);
139
+ push(
140
+ messagesEvent(PARENT, {
141
+ event: "message-start",
142
+ id: "parent-sentinel",
143
+ role: "ai",
144
+ }),
145
+ );
146
+ push(messagesEvent(PARENT, { event: "message-finish" }));
147
+ await vi.waitFor(() => expect(ids()).toContain("parent-sentinel"));
148
+ expect(ids()).toEqual(["parent-human", "parent-ai", "parent-sentinel"]);
149
+
150
+ push(
151
+ values(PARENT, [
152
+ ...parentTurn,
153
+ tool("parent-tool", "done", "call-child"),
154
+ ]),
155
+ );
156
+ await vi.waitFor(() =>
157
+ expect(ids()).toEqual([
158
+ "parent-human",
159
+ "parent-ai",
160
+ "parent-tool",
161
+ "parent-sentinel",
162
+ ]),
163
+ );
164
+ await runtime.dispose();
165
+ });
166
+
167
+ it("unsubscribes the underlying subscription on dispose", async () => {
168
+ const { subscribe, unsubscribe, runtime } = openProjection(
169
+ subagentMessagesProjection(PARENT),
170
+ );
171
+ await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce());
172
+ await runtime.dispose();
173
+ await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce());
174
+ });
175
+ });
@@ -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
+ };
@@ -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