@assistant-ui/react-langchain 0.0.31 → 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 (60) hide show
  1. package/dist/attachSubagentTranscripts.d.ts +19 -12
  2. package/dist/attachSubagentTranscripts.d.ts.map +1 -1
  3. package/dist/attachSubagentTranscripts.js +8 -4
  4. package/dist/attachSubagentTranscripts.js.map +1 -1
  5. package/dist/convertMessages.d.ts +8 -11
  6. package/dist/convertMessages.d.ts.map +1 -1
  7. package/dist/convertMessages.js +53 -15
  8. package/dist/convertMessages.js.map +1 -1
  9. package/dist/converter.d.ts +137 -138
  10. package/dist/converter.d.ts.map +1 -1
  11. package/dist/converter.js +2 -1
  12. package/dist/converter.js.map +1 -1
  13. package/dist/findForkCheckpointInHistory.d.ts +17 -19
  14. package/dist/findForkCheckpointInHistory.d.ts.map +1 -1
  15. package/dist/hooks.d.ts +19 -22
  16. package/dist/hooks.d.ts.map +1 -1
  17. package/dist/index.d.ts +7 -8
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/resolveForkCheckpoint.d.ts +5 -7
  20. package/dist/resolveForkCheckpoint.d.ts.map +1 -1
  21. package/dist/runtimeExtras.d.ts +2 -5
  22. package/dist/runtimeExtras.d.ts.map +1 -1
  23. package/dist/sdkIdentity.d.ts +2 -5
  24. package/dist/sdkIdentity.d.ts.map +1 -1
  25. package/dist/sdkIdentity.js +1 -1
  26. package/dist/streamingTiming.d.ts +4 -7
  27. package/dist/streamingTiming.d.ts.map +1 -1
  28. package/dist/subagentMessagesProjection.d.ts +17 -0
  29. package/dist/subagentMessagesProjection.d.ts.map +1 -0
  30. package/dist/subagentMessagesProjection.js +40 -0
  31. package/dist/subagentMessagesProjection.js.map +1 -0
  32. package/dist/types.d.ts +106 -109
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/uiMessages.d.ts +37 -10
  35. package/dist/uiMessages.d.ts.map +1 -1
  36. package/dist/uiMessages.js +55 -5
  37. package/dist/uiMessages.js.map +1 -1
  38. package/dist/useStreamRuntime.d.ts +9 -12
  39. package/dist/useStreamRuntime.d.ts.map +1 -1
  40. package/dist/useStreamRuntime.js +102 -28
  41. package/dist/useStreamRuntime.js.map +1 -1
  42. package/dist/useSubagentTranscripts.d.ts +5 -8
  43. package/dist/useSubagentTranscripts.d.ts.map +1 -1
  44. package/dist/useSubagentTranscripts.js +129 -34
  45. package/dist/useSubagentTranscripts.js.map +1 -1
  46. package/package.json +16 -11
  47. package/src/attachSubagentTranscripts.test.ts +13 -5
  48. package/src/attachSubagentTranscripts.ts +26 -7
  49. package/src/convertMessages.test.ts +69 -0
  50. package/src/convertMessages.ts +46 -8
  51. package/src/converter.ts +6 -0
  52. package/src/subagentMessagesProjection.test.ts +175 -0
  53. package/src/subagentMessagesProjection.ts +50 -0
  54. package/src/uiMessages.test.ts +149 -0
  55. package/src/uiMessages.ts +79 -4
  56. package/src/useStreamRuntime.test.tsx +212 -0
  57. package/src/useStreamRuntime.ts +202 -50
  58. package/src/useStreamRuntime.voice.test.tsx +712 -0
  59. package/src/useSubagentTranscripts.test.tsx +795 -52
  60. package/src/useSubagentTranscripts.ts +269 -50
@@ -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
+ };
@@ -1,10 +1,13 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  applyUIUpdate,
4
+ createUIFoldMemo,
5
+ createUISnapshotMemo,
4
6
  extractUIUpdate,
5
7
  foldUIUpdates,
6
8
  isUIUpdate,
7
9
  mergeUIMessages,
10
+ reconcileUISnapshot,
8
11
  } from "./uiMessages";
9
12
  import type { UIMessage } from "./types";
10
13
 
@@ -137,6 +140,110 @@ describe("extractUIUpdate", () => {
137
140
  });
138
141
  });
139
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
+
140
247
  describe("mergeUIMessages", () => {
141
248
  it("returns the snapshot when there are no live messages", () => {
142
249
  expect(mergeUIMessages([], [ui("a")])).toEqual([ui("a")]);
@@ -187,4 +294,46 @@ describe("foldUIUpdates", () => {
187
294
  it("returns an empty list for no events", () => {
188
295
  expect(foldUIUpdates([])).toEqual([]);
189
296
  });
297
+
298
+ const merge = (id: string, props: Record<string, unknown>) => ({
299
+ ...ui(id, props),
300
+ metadata: { merge: true },
301
+ });
302
+
303
+ it("keeps the previous list when appended events carry no UI update", () => {
304
+ const memo = createUIFoldMemo();
305
+ const events = [evt(ui("a", { x: 1 })), evt(merge("a", { y: 2 }))];
306
+ const folded = foldUIUpdates(events, memo);
307
+
308
+ expect(foldUIUpdates([...events, evt({ progress: 1 })], memo)).toBe(folded);
309
+ });
310
+
311
+ it("keeps entries that appended updates do not touch", () => {
312
+ const memo = createUIFoldMemo();
313
+ const events = [evt(ui("a", { x: 1 })), evt(merge("a", { y: 2 }))];
314
+ const [merged] = foldUIUpdates(events, memo);
315
+
316
+ const result = foldUIUpdates([...events, evt(ui("b"))], memo);
317
+
318
+ expect(result).toEqual([merge("a", { x: 1, y: 2 }), ui("b")]);
319
+ expect(result[0]).toBe(merged);
320
+ });
321
+
322
+ it("keeps folded entries when the buffer drops its oldest events", () => {
323
+ const memo = createUIFoldMemo();
324
+ const progress = evt({ progress: 1 });
325
+ foldUIUpdates([evt(ui("a", { x: 1 })), progress], memo);
326
+
327
+ expect(foldUIUpdates([progress, evt(merge("a", { y: 2 }))], memo)).toEqual([
328
+ merge("a", { x: 1, y: 2 }),
329
+ ]);
330
+ });
331
+
332
+ it("folds from scratch when the buffer is replaced", () => {
333
+ const memo = createUIFoldMemo();
334
+ foldUIUpdates([evt(ui("a"))], memo);
335
+
336
+ expect(foldUIUpdates([evt(ui("b"))], memo)).toEqual([ui("b")]);
337
+ expect(foldUIUpdates([], memo)).toEqual([]);
338
+ });
190
339
  });
package/src/uiMessages.ts CHANGED
@@ -1,5 +1,10 @@
1
+ import type { Channel } from "@langchain/react";
2
+ import { isJSONValueEqual } from "@assistant-ui/core/internal";
1
3
  import type { RemoveUIMessage, UIMessage } from "./types";
2
4
 
5
+ /** Channels the generative-UI fold reads, at the root and per subagent. */
6
+ export const UI_CUSTOM_CHANNELS: readonly Channel[] = ["custom"];
7
+
3
8
  export type UIUpdate = UIMessage | RemoveUIMessage;
4
9
 
5
10
  export const isUIUpdate = (
@@ -62,15 +67,85 @@ export const extractUIUpdate = (
62
67
  return undefined;
63
68
  };
64
69
 
65
- export const foldUIUpdates = (events: readonly unknown[]): UIMessage[] => {
66
- let acc: UIMessage[] = [];
67
- for (const event of events) {
68
- const update = extractUIUpdate(event);
70
+ export type UIFoldMemo = {
71
+ events: readonly unknown[];
72
+ messages: UIMessage[];
73
+ };
74
+
75
+ export const createUIFoldMemo = (): UIFoldMemo => ({
76
+ events: [],
77
+ messages: [],
78
+ });
79
+
80
+ /**
81
+ * Folds `custom`-channel events into UI messages, continuing the fold held in
82
+ * `memo`. The channel buffer holds each event object once, appends new events
83
+ * and drops its oldest ones once full, so only events after the previously
84
+ * folded last event are applied, and entries no later event touches keep
85
+ * their identity. A buffer that no longer contains that event was replaced
86
+ * and is folded from scratch.
87
+ */
88
+ export const foldUIUpdates = (
89
+ events: readonly unknown[],
90
+ memo: UIFoldMemo = createUIFoldMemo(),
91
+ ): UIMessage[] => {
92
+ const previous = memo.events;
93
+ const resumeAt =
94
+ previous.length === 0
95
+ ? 0
96
+ : events.lastIndexOf(previous.at(-1), previous.length - 1) + 1;
97
+ let acc = previous.length > 0 && resumeAt === 0 ? [] : memo.messages;
98
+ for (let i = resumeAt; i < events.length; i++) {
99
+ const update = extractUIUpdate(events[i]);
69
100
  if (update) acc = applyUIUpdate(acc, update);
70
101
  }
102
+ memo.events = events;
103
+ memo.messages = acc;
71
104
  return acc;
72
105
  };
73
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
+
74
149
  /**
75
150
  * Merges live-streamed UI with the state snapshot. The snapshot is
76
151
  * authoritative by id: once a UI lands in graph state it supersedes its live