@assistant-ui/react-langchain 0.0.31 → 0.0.32

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 (44) hide show
  1. package/dist/attachSubagentTranscripts.d.ts +14 -6
  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 +3 -3
  6. package/dist/convertMessages.d.ts.map +1 -1
  7. package/dist/converter.d.ts +9 -10
  8. package/dist/converter.d.ts.map +1 -1
  9. package/dist/findForkCheckpointInHistory.d.ts +2 -3
  10. package/dist/findForkCheckpointInHistory.d.ts.map +1 -1
  11. package/dist/hooks.d.ts +14 -15
  12. package/dist/hooks.d.ts.map +1 -1
  13. package/dist/resolveForkCheckpoint.d.ts +1 -2
  14. package/dist/resolveForkCheckpoint.d.ts.map +1 -1
  15. package/dist/runtimeExtras.d.ts +1 -2
  16. package/dist/runtimeExtras.d.ts.map +1 -1
  17. package/dist/sdkIdentity.d.ts +1 -2
  18. package/dist/sdkIdentity.d.ts.map +1 -1
  19. package/dist/sdkIdentity.js +1 -1
  20. package/dist/streamingTiming.d.ts +2 -3
  21. package/dist/streamingTiming.d.ts.map +1 -1
  22. package/dist/types.d.ts +8 -8
  23. package/dist/types.d.ts.map +1 -1
  24. package/dist/uiMessages.d.ts +22 -7
  25. package/dist/uiMessages.d.ts.map +1 -1
  26. package/dist/uiMessages.js +23 -5
  27. package/dist/uiMessages.js.map +1 -1
  28. package/dist/useStreamRuntime.d.ts +3 -3
  29. package/dist/useStreamRuntime.d.ts.map +1 -1
  30. package/dist/useStreamRuntime.js +4 -4
  31. package/dist/useStreamRuntime.js.map +1 -1
  32. package/dist/useSubagentTranscripts.d.ts +3 -4
  33. package/dist/useSubagentTranscripts.d.ts.map +1 -1
  34. package/dist/useSubagentTranscripts.js +127 -33
  35. package/dist/useSubagentTranscripts.js.map +1 -1
  36. package/package.json +12 -11
  37. package/src/attachSubagentTranscripts.test.ts +13 -5
  38. package/src/attachSubagentTranscripts.ts +26 -7
  39. package/src/uiMessages.test.ts +43 -0
  40. package/src/uiMessages.ts +36 -4
  41. package/src/useStreamRuntime.test.tsx +112 -0
  42. package/src/useStreamRuntime.ts +9 -6
  43. package/src/useSubagentTranscripts.test.tsx +795 -52
  44. package/src/useSubagentTranscripts.ts +271 -49
@@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest";
7
7
  import {
8
8
  attachSubagentTranscripts,
9
9
  createAttachMemo,
10
+ type SubagentTranscript,
10
11
  } from "./attachSubagentTranscripts";
11
12
 
12
13
  const assistantMessage = (id: string, toolCallId?: string): ThreadMessage =>
@@ -35,7 +36,13 @@ const assistantMessage = (id: string, toolCallId?: string): ThreadMessage =>
35
36
  },
36
37
  }) as ThreadMessage;
37
38
 
38
- const transcript = (id: string) => [assistantMessage(id)];
39
+ const transcript = (
40
+ id: string,
41
+ timing: SubagentTranscript["timing"] = {
42
+ startedAt: 1_000,
43
+ completedAt: 3_500,
44
+ },
45
+ ): SubagentTranscript => ({ messages: [assistantMessage(id)], timing });
39
46
 
40
47
  describe("attachSubagentTranscripts", () => {
41
48
  it("returns the input array when no tool call has a transcript", () => {
@@ -68,7 +75,8 @@ describe("attachSubagentTranscripts", () => {
68
75
  const part = attached[0]!.content[0]!;
69
76
  expect(part.type).toBe("tool-call");
70
77
  if (part.type !== "tool-call") throw new Error("expected a tool call");
71
- expect(part.messages).toBe(childTranscript);
78
+ expect(part.messages).toBe(childTranscript.messages);
79
+ expect(part.timing).toBe(childTranscript.timing);
72
80
  });
73
81
 
74
82
  it("reuses the attached array when transcript identities are unchanged", () => {
@@ -141,10 +149,10 @@ describe("attachSubagentTranscripts", () => {
141
149
  memo,
142
150
  );
143
151
 
144
- expect(before?.content[0]).toMatchObject({ messages: nested });
152
+ expect(before?.content[0]).toMatchObject({ messages: nested.messages });
145
153
  expect(after).not.toBe(before);
146
154
  expect(after?.content[0]).toBe(first);
147
- expect(after?.content[1]).toMatchObject({ messages: nested });
155
+ expect(after?.content[1]).toMatchObject({ messages: nested.messages });
148
156
  });
149
157
 
150
158
  it("keeps untouched parts of a partially matched message by reference", () => {
@@ -164,7 +172,7 @@ describe("attachSubagentTranscripts", () => {
164
172
  );
165
173
 
166
174
  expect(attached?.content[0]).toBe(text);
167
- expect(attached?.content[1]).toMatchObject({ messages: nested });
175
+ expect(attached?.content[1]).toMatchObject({ messages: nested.messages });
168
176
  expect(attached?.content[2]).toBe(unmatched);
169
177
  });
170
178
 
@@ -2,9 +2,20 @@ import {
2
2
  bindExternalStoreMessage,
3
3
  getExternalStoreMessages,
4
4
  type ThreadMessage,
5
+ type ToolCallTiming,
5
6
  } from "@assistant-ui/core";
6
7
 
7
- type AttachedTranscript = readonly [string, readonly ThreadMessage[]];
8
+ export type SubagentTranscript = {
9
+ readonly messages: readonly ThreadMessage[];
10
+ /**
11
+ * Only present when the client watched the task run: the discovery
12
+ * timestamps are stamped when the task call is first seen, so a thread
13
+ * seeded from a checkpoint would report a duration of zero.
14
+ */
15
+ readonly timing?: ToolCallTiming;
16
+ };
17
+
18
+ type AttachedTranscript = readonly [string, SubagentTranscript];
8
19
 
9
20
  type AttachedMessage = {
10
21
  transcripts: readonly AttachedTranscript[];
@@ -33,7 +44,7 @@ const sameTranscripts = (
33
44
 
34
45
  export const attachSubagentTranscripts = (
35
46
  messages: readonly ThreadMessage[],
36
- transcripts: ReadonlyMap<string, readonly ThreadMessage[]>,
47
+ transcripts: ReadonlyMap<string, SubagentTranscript>,
37
48
  memo: AttachMemo,
38
49
  ): readonly ThreadMessage[] => {
39
50
  let attachedAny = false;
@@ -52,11 +63,19 @@ export const attachSubagentTranscripts = (
52
63
 
53
64
  const attached = {
54
65
  ...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
- ),
66
+ content: message.content.map((part) => {
67
+ const transcript =
68
+ part.type === "tool-call"
69
+ ? transcripts.get(part.toolCallId)
70
+ : undefined;
71
+ return transcript
72
+ ? {
73
+ ...part,
74
+ messages: transcript.messages,
75
+ ...(transcript.timing && { timing: transcript.timing }),
76
+ }
77
+ : part;
78
+ }),
60
79
  } as ThreadMessage;
61
80
  memo.messages.set(message, { transcripts: attachedTranscripts, attached });
62
81
  return attached;
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  applyUIUpdate,
4
+ createUIFoldMemo,
4
5
  extractUIUpdate,
5
6
  foldUIUpdates,
6
7
  isUIUpdate,
@@ -187,4 +188,46 @@ describe("foldUIUpdates", () => {
187
188
  it("returns an empty list for no events", () => {
188
189
  expect(foldUIUpdates([])).toEqual([]);
189
190
  });
191
+
192
+ const merge = (id: string, props: Record<string, unknown>) => ({
193
+ ...ui(id, props),
194
+ metadata: { merge: true },
195
+ });
196
+
197
+ it("keeps the previous list when appended events carry no UI update", () => {
198
+ const memo = createUIFoldMemo();
199
+ const events = [evt(ui("a", { x: 1 })), evt(merge("a", { y: 2 }))];
200
+ const folded = foldUIUpdates(events, memo);
201
+
202
+ expect(foldUIUpdates([...events, evt({ progress: 1 })], memo)).toBe(folded);
203
+ });
204
+
205
+ it("keeps entries that appended updates do not touch", () => {
206
+ const memo = createUIFoldMemo();
207
+ const events = [evt(ui("a", { x: 1 })), evt(merge("a", { y: 2 }))];
208
+ const [merged] = foldUIUpdates(events, memo);
209
+
210
+ const result = foldUIUpdates([...events, evt(ui("b"))], memo);
211
+
212
+ expect(result).toEqual([merge("a", { x: 1, y: 2 }), ui("b")]);
213
+ expect(result[0]).toBe(merged);
214
+ });
215
+
216
+ it("keeps folded entries when the buffer drops its oldest events", () => {
217
+ const memo = createUIFoldMemo();
218
+ const progress = evt({ progress: 1 });
219
+ foldUIUpdates([evt(ui("a", { x: 1 })), progress], memo);
220
+
221
+ expect(foldUIUpdates([progress, evt(merge("a", { y: 2 }))], memo)).toEqual([
222
+ merge("a", { x: 1, y: 2 }),
223
+ ]);
224
+ });
225
+
226
+ it("folds from scratch when the buffer is replaced", () => {
227
+ const memo = createUIFoldMemo();
228
+ foldUIUpdates([evt(ui("a"))], memo);
229
+
230
+ expect(foldUIUpdates([evt(ui("b"))], memo)).toEqual([ui("b")]);
231
+ expect(foldUIUpdates([], memo)).toEqual([]);
232
+ });
190
233
  });
package/src/uiMessages.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import type { Channel } from "@langchain/react";
1
2
  import type { RemoveUIMessage, UIMessage } from "./types";
2
3
 
4
+ /** Channels the generative-UI fold reads, at the root and per subagent. */
5
+ export const UI_CUSTOM_CHANNELS: readonly Channel[] = ["custom"];
6
+
3
7
  export type UIUpdate = UIMessage | RemoveUIMessage;
4
8
 
5
9
  export const isUIUpdate = (
@@ -62,12 +66,40 @@ export const extractUIUpdate = (
62
66
  return undefined;
63
67
  };
64
68
 
65
- export const foldUIUpdates = (events: readonly unknown[]): UIMessage[] => {
66
- let acc: UIMessage[] = [];
67
- for (const event of events) {
68
- const update = extractUIUpdate(event);
69
+ export type UIFoldMemo = {
70
+ events: readonly unknown[];
71
+ messages: UIMessage[];
72
+ };
73
+
74
+ export const createUIFoldMemo = (): UIFoldMemo => ({
75
+ events: [],
76
+ messages: [],
77
+ });
78
+
79
+ /**
80
+ * Folds `custom`-channel events into UI messages, continuing the fold held in
81
+ * `memo`. The channel buffer holds each event object once, appends new events
82
+ * and drops its oldest ones once full, so only events after the previously
83
+ * folded last event are applied, and entries no later event touches keep
84
+ * their identity. A buffer that no longer contains that event was replaced
85
+ * and is folded from scratch.
86
+ */
87
+ export const foldUIUpdates = (
88
+ events: readonly unknown[],
89
+ memo: UIFoldMemo = createUIFoldMemo(),
90
+ ): UIMessage[] => {
91
+ const previous = memo.events;
92
+ const resumeAt =
93
+ previous.length === 0
94
+ ? 0
95
+ : events.lastIndexOf(previous.at(-1), previous.length - 1) + 1;
96
+ let acc = previous.length > 0 && resumeAt === 0 ? [] : memo.messages;
97
+ for (let i = resumeAt; i < events.length; i++) {
98
+ const update = extractUIUpdate(events[i]);
69
99
  if (update) acc = applyUIUpdate(acc, update);
70
100
  }
101
+ memo.events = events;
102
+ memo.messages = acc;
71
103
  return acc;
72
104
  };
73
105
 
@@ -1015,6 +1015,8 @@ describe("useStreamRuntime subagent transcripts", () => {
1015
1015
  status: "running",
1016
1016
  parentId: null,
1017
1017
  depth: 1,
1018
+ startedAt: new Date(1_000),
1019
+ completedAt: null,
1018
1020
  },
1019
1021
  ],
1020
1022
  ]);
@@ -1069,4 +1071,114 @@ describe("useStreamRuntime subagent transcripts", () => {
1069
1071
 
1070
1072
  expect(nestedTranscript()).toBe(rendered);
1071
1073
  });
1074
+
1075
+ it("keeps messages and transcripts when custom events carry no UI update", async () => {
1076
+ const stream = createMockStream([
1077
+ message("human-1", "human", "delegate"),
1078
+ {
1079
+ id: "root-ai",
1080
+ _getType: () => "ai",
1081
+ content: "",
1082
+ tool_calls: [{ id: "task-one", name: "task", args: {} }],
1083
+ },
1084
+ ]);
1085
+ const transcript = [message("nested-ai", "ai", "nested answer")];
1086
+ stream.subagents = new Map([
1087
+ [
1088
+ "task-one",
1089
+ {
1090
+ id: "task-one",
1091
+ namespace: ["tools:task-one"],
1092
+ status: "running",
1093
+ parentId: null,
1094
+ depth: 1,
1095
+ startedAt: new Date(1_000),
1096
+ completedAt: null,
1097
+ },
1098
+ ],
1099
+ ]);
1100
+ stream[streamController]!.registry.acquire.mockReturnValue({
1101
+ store: { getSnapshot: () => transcript, subscribe: () => () => {} },
1102
+ release: vi.fn(),
1103
+ });
1104
+ const chartEvent = (
1105
+ props: Record<string, unknown>,
1106
+ metadata: Record<string, unknown> = {},
1107
+ ) => ({
1108
+ method: "custom",
1109
+ params: {
1110
+ namespace: ["tools:task-one"],
1111
+ data: {
1112
+ type: "ui",
1113
+ id: "ui-1",
1114
+ name: "chart",
1115
+ props,
1116
+ metadata: { message_id: "nested-ai", ...metadata },
1117
+ },
1118
+ },
1119
+ });
1120
+ const events = [
1121
+ chartEvent({ points: [1, 2] }),
1122
+ chartEvent({ label: "first" }, { merge: true }),
1123
+ ];
1124
+ mockUseChannel.mockReturnValue(events);
1125
+ const { auiResult, rerender } = renderAui(stream);
1126
+ const nestedTranscript = () => {
1127
+ const { messages } = auiResult.current.thread.getState();
1128
+ for (const threadMessage of messages) {
1129
+ for (const part of threadMessage.content) {
1130
+ if (part.type === "tool-call" && part.toolCallId === "task-one")
1131
+ return part.messages;
1132
+ }
1133
+ }
1134
+ return undefined;
1135
+ };
1136
+
1137
+ await waitFor(() =>
1138
+ expect(nestedTranscript()?.[0]?.content).toMatchObject([
1139
+ { type: "text", text: "nested answer" },
1140
+ {
1141
+ type: "data",
1142
+ name: "chart",
1143
+ data: { points: [1, 2], label: "first" },
1144
+ },
1145
+ ]),
1146
+ );
1147
+ const [human, ai] = auiResult.current.thread.getState().messages;
1148
+ const rendered = nestedTranscript();
1149
+
1150
+ const withProgress = [
1151
+ ...events,
1152
+ {
1153
+ method: "custom",
1154
+ params: { namespace: [], data: { name: "progress", payload: 1 } },
1155
+ },
1156
+ ];
1157
+ mockUseChannel.mockReturnValue(withProgress);
1158
+ await act(async () => {
1159
+ rerender();
1160
+ });
1161
+
1162
+ const messages = auiResult.current.thread.getState().messages;
1163
+ expect(messages[0]).toBe(human);
1164
+ expect(messages[1]).toBe(ai);
1165
+ expect(nestedTranscript()).toBe(rendered);
1166
+
1167
+ mockUseChannel.mockReturnValue([
1168
+ ...withProgress,
1169
+ chartEvent({ label: "second" }, { merge: true }),
1170
+ ]);
1171
+ await act(async () => {
1172
+ rerender();
1173
+ });
1174
+
1175
+ expect(nestedTranscript()?.[0]?.content).toMatchObject([
1176
+ { type: "text", text: "nested answer" },
1177
+ {
1178
+ type: "data",
1179
+ name: "chart",
1180
+ data: { points: [1, 2], label: "second" },
1181
+ },
1182
+ ]);
1183
+ });
1072
1184
  });
@@ -29,7 +29,6 @@ import {
29
29
  } from "@assistant-ui/core/react";
30
30
  import { useAui, useAuiState } from "@assistant-ui/store";
31
31
  import { STREAM_CONTROLLER, useChannel, useStream } from "@langchain/react";
32
- import type { Channel } from "@langchain/react";
33
32
  import type {
34
33
  LangChainBaseMessage,
35
34
  LangChainToolCall,
@@ -48,14 +47,17 @@ import {
48
47
  createAttachMemo,
49
48
  } from "./attachSubagentTranscripts";
50
49
  import { useSubagentTranscripts } from "./useSubagentTranscripts";
51
- import { foldUIUpdates, mergeUIMessages } from "./uiMessages";
50
+ import {
51
+ createUIFoldMemo,
52
+ foldUIUpdates,
53
+ mergeUIMessages,
54
+ UI_CUSTOM_CHANNELS,
55
+ } from "./uiMessages";
52
56
  import { langChainExtras } from "./runtimeExtras";
53
57
  import { resolveForkCheckpoint } from "./resolveForkCheckpoint";
54
58
  import { useLangChainStreamingTiming } from "./streamingTiming";
55
59
  import { LANGCHAIN_SDK } from "./sdkIdentity";
56
60
 
57
- const UI_CUSTOM_CHANNELS: readonly Channel[] = ["custom"];
58
-
59
61
  export const runConfigToSubmitOptions = (
60
62
  runConfig: AppendMessage["runConfig"],
61
63
  ) =>
@@ -167,9 +169,10 @@ const useStreamThreadRuntime = (
167
169
  const uiStateValue = stream.values[uiStateKey];
168
170
 
169
171
  const customEvents = useChannel(stream, UI_CUSTOM_CHANNELS);
172
+ const [uiFoldMemo] = useState(createUIFoldMemo);
170
173
  const liveUiMessages = useMemo(
171
- () => foldUIUpdates(customEvents),
172
- [customEvents],
174
+ () => foldUIUpdates(customEvents, uiFoldMemo),
175
+ [customEvents, uiFoldMemo],
173
176
  );
174
177
 
175
178
  const mergedUiMessages = useMemo(