@assistant-ui/react-langchain 0.0.30 → 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 (47) hide show
  1. package/README.md +1 -1
  2. package/dist/attachSubagentTranscripts.d.ts +24 -0
  3. package/dist/attachSubagentTranscripts.d.ts.map +1 -0
  4. package/dist/attachSubagentTranscripts.js +43 -0
  5. package/dist/attachSubagentTranscripts.js.map +1 -0
  6. package/dist/convertMessages.d.ts +4 -4
  7. package/dist/convertMessages.d.ts.map +1 -1
  8. package/dist/converter.d.ts +10 -11
  9. package/dist/converter.d.ts.map +1 -1
  10. package/dist/findForkCheckpointInHistory.d.ts +2 -3
  11. package/dist/findForkCheckpointInHistory.d.ts.map +1 -1
  12. package/dist/hooks.d.ts +14 -15
  13. package/dist/hooks.d.ts.map +1 -1
  14. package/dist/resolveForkCheckpoint.d.ts +1 -2
  15. package/dist/resolveForkCheckpoint.d.ts.map +1 -1
  16. package/dist/runtimeExtras.d.ts +1 -2
  17. package/dist/runtimeExtras.d.ts.map +1 -1
  18. package/dist/sdkIdentity.d.ts +5 -0
  19. package/dist/sdkIdentity.d.ts.map +1 -0
  20. package/dist/sdkIdentity.js +9 -0
  21. package/dist/sdkIdentity.js.map +1 -0
  22. package/dist/streamingTiming.d.ts +2 -3
  23. package/dist/streamingTiming.d.ts.map +1 -1
  24. package/dist/types.d.ts +8 -8
  25. package/dist/types.d.ts.map +1 -1
  26. package/dist/uiMessages.d.ts +22 -7
  27. package/dist/uiMessages.d.ts.map +1 -1
  28. package/dist/uiMessages.js +23 -5
  29. package/dist/uiMessages.js.map +1 -1
  30. package/dist/useStreamRuntime.d.ts +3 -3
  31. package/dist/useStreamRuntime.d.ts.map +1 -1
  32. package/dist/useStreamRuntime.js +26 -17
  33. package/dist/useStreamRuntime.js.map +1 -1
  34. package/dist/useSubagentTranscripts.d.ts +8 -0
  35. package/dist/useSubagentTranscripts.d.ts.map +1 -0
  36. package/dist/useSubagentTranscripts.js +265 -0
  37. package/dist/useSubagentTranscripts.js.map +1 -0
  38. package/package.json +17 -12
  39. package/src/attachSubagentTranscripts.test.ts +198 -0
  40. package/src/attachSubagentTranscripts.ts +91 -0
  41. package/src/sdkIdentity.ts +9 -0
  42. package/src/uiMessages.test.ts +43 -0
  43. package/src/uiMessages.ts +36 -4
  44. package/src/useStreamRuntime.test.tsx +218 -7
  45. package/src/useStreamRuntime.ts +40 -16
  46. package/src/useSubagentTranscripts.test.tsx +1258 -0
  47. package/src/useSubagentTranscripts.ts +526 -0
@@ -0,0 +1,526 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useSyncExternalStore } from "react";
4
+ import {
5
+ stepStreamingTiming,
6
+ type MessageTiming,
7
+ type StreamingTimingState,
8
+ type ThreadMessage,
9
+ type ToolCallTiming,
10
+ } from "@assistant-ui/core";
11
+ import {
12
+ convertExternalMessages,
13
+ createExternalMessageConversionCache,
14
+ type ExternalMessageConversionCache,
15
+ type useExternalMessageConverter,
16
+ } from "@assistant-ui/core/react";
17
+ import { STREAM_CONTROLLER, type AnyStream } from "@langchain/react";
18
+ import type { BaseMessage } from "@langchain/core/messages";
19
+ import {
20
+ channelProjection,
21
+ messagesProjection,
22
+ type Event,
23
+ } from "@langchain/langgraph-sdk/stream";
24
+ import type { SubagentDiscoverySnapshot } from "@langchain/react";
25
+ import {
26
+ attachSubagentTranscripts,
27
+ type AttachMemo,
28
+ createAttachMemo,
29
+ type SubagentTranscript,
30
+ } from "./attachSubagentTranscripts";
31
+ import { convertLangChainBaseMessage } from "./convertMessages";
32
+ import { groupUIMessagesByParent } from "./converter";
33
+ import { langChainStreamingTimingAccessors } from "./streamingTiming";
34
+ import type { LangChainBaseMessage, UIMessage } from "./types";
35
+ import {
36
+ createUIFoldMemo,
37
+ foldUIUpdates,
38
+ mergeUIMessages,
39
+ UI_CUSTOM_CHANNELS,
40
+ type UIFoldMemo,
41
+ } from "./uiMessages";
42
+
43
+ export const MAX_SUBAGENT_DEPTH = 16;
44
+
45
+ const ROOT_UI_CHANNEL_DEPTH = 1;
46
+
47
+ const TRANSCRIPT_METADATA = {};
48
+
49
+ const NO_UI_MESSAGES: readonly UIMessage[] = [];
50
+
51
+ const NO_MESSAGE_TIMING: Record<string, MessageTiming> = {};
52
+
53
+ type ProjectionStore<T> = {
54
+ getSnapshot(): T;
55
+ subscribe(listener: () => void): () => void;
56
+ };
57
+
58
+ type ProjectionResource = {
59
+ snapshot: SubagentDiscoverySnapshot;
60
+ namespace: readonly string[];
61
+ store: ProjectionStore<BaseMessage[]>;
62
+ uiStore: ProjectionStore<readonly Event[]> | undefined;
63
+ dispose: () => void;
64
+ storeSnapshot: BaseMessage[] | undefined;
65
+ status: SubagentDiscoverySnapshot["status"] | undefined;
66
+ uiFoldMemo: UIFoldMemo;
67
+ localUiMessages: readonly UIMessage[];
68
+ rootUiMessagesByParent: Map<string, UIMessage[]>;
69
+ uiMessagesByParent: Map<string, UIMessage[]>;
70
+ timingState: StreamingTimingState | null;
71
+ messageTiming: Record<string, MessageTiming>;
72
+ convertedMessageTiming: Record<string, MessageTiming> | undefined;
73
+ convert: useExternalMessageConverter.Callback<LangChainBaseMessage>;
74
+ uiMessages: readonly UIMessage[];
75
+ converted: readonly ThreadMessage[] | undefined;
76
+ childTranscripts: ReadonlyMap<string, SubagentTranscript> | undefined;
77
+ transcript: readonly ThreadMessage[] | undefined;
78
+ timing: ToolCallTiming | undefined;
79
+ entry: SubagentTranscript | undefined;
80
+ memo: AttachMemo;
81
+ cache: ExternalMessageConversionCache;
82
+ };
83
+
84
+ type MeasuredTiming = {
85
+ timing: ToolCallTiming | undefined;
86
+ timingState: StreamingTimingState | null;
87
+ messageTiming: Record<string, MessageTiming>;
88
+ };
89
+
90
+ type NamespaceRequest = {
91
+ id: string;
92
+ attempts: number;
93
+ pending: boolean;
94
+ retryQueued: boolean;
95
+ status: SubagentDiscoverySnapshot["status"];
96
+ };
97
+
98
+ type SubagentTranscriptSource = {
99
+ resources: Map<string, ProjectionResource>;
100
+ namespaceRequests: Map<string, NamespaceRequest>;
101
+ snapshot: ReadonlyMap<string, SubagentTranscript>;
102
+ listeners: Set<() => void>;
103
+ controller: AnyStream[typeof STREAM_CONTROLLER] | undefined;
104
+ uiMessagesByParent: Map<string, UIMessage[]>;
105
+ subscribe(listener: () => void): () => void;
106
+ getSnapshot(): ReadonlyMap<string, SubagentTranscript>;
107
+ reconcile(
108
+ controller: AnyStream[typeof STREAM_CONTROLLER],
109
+ subagents: AnyStream["subagents"],
110
+ uiMessagesByParent: Map<string, UIMessage[]>,
111
+ ): void;
112
+ dispose(): void;
113
+ };
114
+
115
+ const sameNamespace = (a: readonly string[], b: readonly string[]) =>
116
+ a.length === b.length && a.every((segment, index) => segment === b[index]);
117
+
118
+ const needsNamespaceResolution = (snapshot: SubagentDiscoverySnapshot) =>
119
+ snapshot.namespace.length === 1 &&
120
+ snapshot.namespace[0] === `tools:${snapshot.id}`;
121
+
122
+ const requestSubagentNamespace = (
123
+ source: SubagentTranscriptSource,
124
+ controller: AnyStream[typeof STREAM_CONTROLLER],
125
+ request: NamespaceRequest,
126
+ ) => {
127
+ request.attempts += 1;
128
+ request.pending = true;
129
+ request.retryQueued = false;
130
+ void controller
131
+ .resolveSubagentNamespace(request.id)
132
+ .catch(() => {})
133
+ .finally(() => {
134
+ if (
135
+ source.controller !== controller ||
136
+ source.namespaceRequests.get(request.id) !== request
137
+ )
138
+ return;
139
+
140
+ request.pending = false;
141
+ if (
142
+ !request.retryQueued &&
143
+ request.attempts === 1 &&
144
+ request.status !== "running"
145
+ ) {
146
+ request.retryQueued = true;
147
+ }
148
+ if (!request.retryQueued || request.attempts >= 2) {
149
+ request.retryQueued = false;
150
+ return;
151
+ }
152
+ requestSubagentNamespace(source, controller, request);
153
+ });
154
+ };
155
+
156
+ const sameTranscriptEntries = (
157
+ a: ReadonlyMap<string, SubagentTranscript> | undefined,
158
+ b: ReadonlyMap<string, SubagentTranscript>,
159
+ ) =>
160
+ a?.size === b.size &&
161
+ [...b].every(([id, transcript]) => a.get(id) === transcript);
162
+
163
+ const collectUIMessages = (
164
+ messages: readonly BaseMessage[],
165
+ uiMessagesByParent: Map<string, UIMessage[]>,
166
+ ) => {
167
+ const collected: UIMessage[] = [];
168
+ if (uiMessagesByParent.size === 0) return collected;
169
+ for (const message of messages) {
170
+ const uiMessages = message.id && uiMessagesByParent.get(message.id);
171
+ if (uiMessages) collected.push(...uiMessages);
172
+ }
173
+ return collected;
174
+ };
175
+
176
+ const sameUIMessages = (a: readonly UIMessage[], b: readonly UIMessage[]) =>
177
+ a.length === b.length && a.every((ui, index) => ui === b[index]);
178
+
179
+ const foldLocalUIMessages = (resource: ProjectionResource) => {
180
+ const events = resource.uiStore?.getSnapshot();
181
+ if (events === undefined) return NO_UI_MESSAGES;
182
+ return foldUIUpdates(events, resource.uiFoldMemo);
183
+ };
184
+
185
+ const mergeLocalUIMessages = (
186
+ uiMessagesByParent: Map<string, UIMessage[]>,
187
+ localUiMessages: readonly UIMessage[],
188
+ ) => {
189
+ const merged = new Map(uiMessagesByParent);
190
+ for (const [parentId, messages] of groupUIMessagesByParent<UIMessage>(
191
+ localUiMessages,
192
+ )) {
193
+ merged.set(parentId, mergeUIMessages(messages, merged.get(parentId)));
194
+ }
195
+ return merged;
196
+ };
197
+
198
+ const createConverter =
199
+ (
200
+ uiMessagesByParent: Map<string, UIMessage[]>,
201
+ messageTiming: Record<string, MessageTiming>,
202
+ ): useExternalMessageConverter.Callback<LangChainBaseMessage> =>
203
+ (message, metadata) =>
204
+ convertLangChainBaseMessage(message, {
205
+ ...metadata,
206
+ uiMessagesByParent,
207
+ messageTiming,
208
+ });
209
+
210
+ const updateTiming = (resource: ProjectionResource) => {
211
+ if (resource.timing === undefined) return;
212
+ const startedAt = resource.snapshot.startedAt.getTime();
213
+ const completedAt = resource.snapshot.completedAt?.getTime();
214
+ if (
215
+ resource.timing.startedAt === startedAt &&
216
+ resource.timing.completedAt === completedAt
217
+ )
218
+ return;
219
+ resource.timing = {
220
+ startedAt,
221
+ ...(completedAt !== undefined && { completedAt }),
222
+ };
223
+ };
224
+
225
+ const createSubagentTranscriptSource = (): SubagentTranscriptSource => {
226
+ const uiMessagesByParent = new Map<string, UIMessage[]>();
227
+ const source: SubagentTranscriptSource = {
228
+ resources: new Map(),
229
+ namespaceRequests: new Map(),
230
+ snapshot: new Map(),
231
+ listeners: new Set(),
232
+ controller: undefined,
233
+ uiMessagesByParent,
234
+ subscribe(listener) {
235
+ source.listeners.add(listener);
236
+ return () => source.listeners.delete(listener);
237
+ },
238
+ getSnapshot() {
239
+ return source.snapshot;
240
+ },
241
+ reconcile(controller, subagents, uiMessagesByParent) {
242
+ source.uiMessagesByParent = uiMessagesByParent;
243
+ // A resource is rebuilt from scratch when its namespace resolves, which
244
+ // routinely happens after the subagent finished. Its timing was measured
245
+ // while the client watched the task run and cannot be measured again.
246
+ const rebound = new Map<string, MeasuredTiming>();
247
+
248
+ if (source.controller !== controller) {
249
+ source.dispose();
250
+ source.controller = controller;
251
+ }
252
+
253
+ for (const id of source.namespaceRequests.keys()) {
254
+ if (!subagents.has(id)) source.namespaceRequests.delete(id);
255
+ }
256
+
257
+ for (const [id, resource] of source.resources) {
258
+ const snapshot = subagents.get(id);
259
+ if (
260
+ !snapshot ||
261
+ snapshot.depth > MAX_SUBAGENT_DEPTH ||
262
+ !sameNamespace(resource.namespace, snapshot.namespace)
263
+ ) {
264
+ if (snapshot && snapshot.depth <= MAX_SUBAGENT_DEPTH)
265
+ rebound.set(id, {
266
+ timing: resource.timing,
267
+ timingState: resource.timingState,
268
+ messageTiming: resource.messageTiming,
269
+ });
270
+ resource.dispose();
271
+ source.resources.delete(id);
272
+ continue;
273
+ }
274
+ resource.snapshot = snapshot;
275
+ }
276
+
277
+ for (const snapshot of subagents.values()) {
278
+ if (snapshot.depth > MAX_SUBAGENT_DEPTH) continue;
279
+ if (!needsNamespaceResolution(snapshot)) {
280
+ source.namespaceRequests.delete(snapshot.id);
281
+ } else {
282
+ const request = source.namespaceRequests.get(snapshot.id);
283
+ if (!request) {
284
+ const nextRequest: NamespaceRequest = {
285
+ id: snapshot.id,
286
+ attempts: 0,
287
+ pending: false,
288
+ retryQueued: false,
289
+ status: snapshot.status,
290
+ };
291
+ source.namespaceRequests.set(snapshot.id, nextRequest);
292
+ requestSubagentNamespace(source, controller, nextRequest);
293
+ } else if (request.status !== snapshot.status) {
294
+ request.status = snapshot.status;
295
+ if (request.pending) {
296
+ request.retryQueued = request.attempts < 2;
297
+ } else if (request.attempts < 2) {
298
+ requestSubagentNamespace(source, controller, request);
299
+ }
300
+ }
301
+ }
302
+ if (source.resources.has(snapshot.id)) continue;
303
+ const acquired = controller.registry.acquire(
304
+ messagesProjection(snapshot.namespace),
305
+ );
306
+ const acquiredUI =
307
+ snapshot.namespace.length > ROOT_UI_CHANNEL_DEPTH
308
+ ? controller.registry.acquire(
309
+ channelProjection(UI_CUSTOM_CHANNELS, snapshot.namespace),
310
+ )
311
+ : undefined;
312
+ const resource: ProjectionResource = {
313
+ snapshot,
314
+ namespace: snapshot.namespace,
315
+ store: acquired.store,
316
+ uiStore: acquiredUI?.store,
317
+ dispose: () => {},
318
+ storeSnapshot: undefined,
319
+ status: undefined,
320
+ uiFoldMemo: createUIFoldMemo(),
321
+ localUiMessages: NO_UI_MESSAGES,
322
+ rootUiMessagesByParent: source.uiMessagesByParent,
323
+ uiMessagesByParent: source.uiMessagesByParent,
324
+ timingState: rebound.get(snapshot.id)?.timingState ?? null,
325
+ messageTiming:
326
+ rebound.get(snapshot.id)?.messageTiming ?? NO_MESSAGE_TIMING,
327
+ convertedMessageTiming: undefined,
328
+ convert: createConverter(
329
+ source.uiMessagesByParent,
330
+ NO_MESSAGE_TIMING,
331
+ ),
332
+ uiMessages: [],
333
+ converted: undefined,
334
+ childTranscripts: undefined,
335
+ transcript: undefined,
336
+ timing: rebound.has(snapshot.id)
337
+ ? rebound.get(snapshot.id)!.timing
338
+ : snapshot.status === "running"
339
+ ? { startedAt: snapshot.startedAt.getTime() }
340
+ : undefined,
341
+ entry: undefined,
342
+ memo: createAttachMemo(),
343
+ cache: createExternalMessageConversionCache(),
344
+ };
345
+ const unsubscribe = resource.store.subscribe(() => rebuild());
346
+ const unsubscribeUI = resource.uiStore?.subscribe(() => rebuild());
347
+ resource.dispose = () => {
348
+ unsubscribe();
349
+ unsubscribeUI?.();
350
+ acquired.release();
351
+ acquiredUI?.release();
352
+ };
353
+ source.resources.set(snapshot.id, resource);
354
+ }
355
+
356
+ rebuild();
357
+ },
358
+ dispose() {
359
+ for (const resource of source.resources.values()) resource.dispose();
360
+ source.resources.clear();
361
+ source.namespaceRequests.clear();
362
+ source.snapshot = new Map();
363
+ for (const listener of source.listeners) listener();
364
+ },
365
+ };
366
+
367
+ const rebuild = () => {
368
+ const { uiMessagesByParent } = source;
369
+ const resources = [...source.resources.values()];
370
+ const childrenByParent = new Map<string, ProjectionResource[]>();
371
+
372
+ for (const resource of resources) {
373
+ const parentId = resource.snapshot.parentId;
374
+ if (parentId == null) continue;
375
+ const children = childrenByParent.get(parentId);
376
+ if (children) children.push(resource);
377
+ else childrenByParent.set(parentId, [resource]);
378
+ }
379
+
380
+ const transcripts = new Map<string, SubagentTranscript>();
381
+ let changed = source.snapshot.size !== resources.length;
382
+ const built = new Set<string>();
383
+
384
+ const build = (resource: ProjectionResource, depth: number) => {
385
+ if (built.has(resource.snapshot.id)) return;
386
+ built.add(resource.snapshot.id);
387
+ const children =
388
+ depth < MAX_SUBAGENT_DEPTH
389
+ ? (childrenByParent.get(resource.snapshot.id) ?? [])
390
+ : [];
391
+ for (const child of children) build(child, depth + 1);
392
+ const childTranscripts = new Map(
393
+ children.flatMap((child) =>
394
+ child.entry ? [[child.snapshot.id, child.entry] as const] : [],
395
+ ),
396
+ );
397
+ const storeSnapshot = resource.store.getSnapshot();
398
+ const status = resource.snapshot.status;
399
+ const stepped = stepStreamingTiming(
400
+ resource.timingState,
401
+ storeSnapshot as LangChainBaseMessage[],
402
+ status === "running",
403
+ langChainStreamingTimingAccessors,
404
+ undefined,
405
+ );
406
+ resource.timingState = stepped.state;
407
+ if (Object.keys(stepped.timings).length > 0) {
408
+ resource.messageTiming = {
409
+ ...resource.messageTiming,
410
+ ...stepped.timings,
411
+ };
412
+ }
413
+ const localUiMessages = foldLocalUIMessages(resource);
414
+ if (
415
+ resource.localUiMessages !== localUiMessages ||
416
+ resource.rootUiMessagesByParent !== uiMessagesByParent ||
417
+ resource.convertedMessageTiming !== resource.messageTiming
418
+ ) {
419
+ resource.localUiMessages = localUiMessages;
420
+ resource.rootUiMessagesByParent = uiMessagesByParent;
421
+ resource.uiMessagesByParent =
422
+ localUiMessages.length === 0
423
+ ? uiMessagesByParent
424
+ : mergeLocalUIMessages(uiMessagesByParent, localUiMessages);
425
+ resource.convert = createConverter(
426
+ resource.uiMessagesByParent,
427
+ resource.messageTiming,
428
+ );
429
+ }
430
+ const uiMessages = collectUIMessages(
431
+ storeSnapshot,
432
+ resource.uiMessagesByParent,
433
+ );
434
+
435
+ const conversionChanged =
436
+ resource.converted === undefined ||
437
+ resource.storeSnapshot !== storeSnapshot ||
438
+ resource.status !== status ||
439
+ resource.convertedMessageTiming !== resource.messageTiming ||
440
+ !sameUIMessages(resource.uiMessages, uiMessages);
441
+ if (conversionChanged) {
442
+ resource.converted = convertExternalMessages(
443
+ storeSnapshot as LangChainBaseMessage[],
444
+ resource.convert,
445
+ status === "running",
446
+ TRANSCRIPT_METADATA,
447
+ resource.cache,
448
+ );
449
+ resource.storeSnapshot = storeSnapshot;
450
+ resource.status = status;
451
+ resource.uiMessages = uiMessages;
452
+ resource.convertedMessageTiming = resource.messageTiming;
453
+ }
454
+
455
+ if (
456
+ resource.transcript === undefined ||
457
+ conversionChanged ||
458
+ !sameTranscriptEntries(resource.childTranscripts, childTranscripts)
459
+ ) {
460
+ const transcript = attachSubagentTranscripts(
461
+ resource.converted!,
462
+ childTranscripts,
463
+ resource.memo,
464
+ );
465
+ changed ||= resource.transcript !== transcript;
466
+ resource.transcript = transcript;
467
+ resource.childTranscripts = childTranscripts;
468
+ }
469
+
470
+ updateTiming(resource);
471
+ if (
472
+ resource.entry === undefined ||
473
+ resource.entry.messages !== resource.transcript ||
474
+ resource.entry.timing !== resource.timing
475
+ ) {
476
+ resource.entry = {
477
+ messages: resource.transcript!,
478
+ ...(resource.timing && { timing: resource.timing }),
479
+ };
480
+ changed = true;
481
+ }
482
+
483
+ if (!source.snapshot.has(resource.snapshot.id)) changed = true;
484
+ transcripts.set(resource.snapshot.id, resource.entry);
485
+ };
486
+
487
+ for (const resource of resources) {
488
+ if (
489
+ resource.snapshot.parentId == null ||
490
+ !source.resources.has(resource.snapshot.parentId)
491
+ )
492
+ build(resource, 1);
493
+ }
494
+ for (const resource of resources) build(resource, MAX_SUBAGENT_DEPTH);
495
+
496
+ if (!changed) return;
497
+ source.snapshot = transcripts;
498
+ for (const listener of source.listeners) listener();
499
+ };
500
+
501
+ return source;
502
+ };
503
+
504
+ export const useSubagentTranscripts = (
505
+ stream: AnyStream,
506
+ uiMessagesByParent: Map<string, UIMessage[]>,
507
+ ): ReadonlyMap<string, SubagentTranscript> => {
508
+ const sourceRef = useRef<SubagentTranscriptSource | undefined>(undefined);
509
+ if (!sourceRef.current) {
510
+ sourceRef.current = createSubagentTranscriptSource();
511
+ }
512
+ const source = sourceRef.current;
513
+ const controller = stream[STREAM_CONTROLLER];
514
+
515
+ useEffect(() => {
516
+ source.reconcile(controller, stream.subagents, uiMessagesByParent);
517
+ }, [controller, source, stream.subagents, uiMessagesByParent]);
518
+
519
+ useEffect(() => () => source.dispose(), [source]);
520
+
521
+ return useSyncExternalStore(
522
+ source.subscribe,
523
+ source.getSnapshot,
524
+ source.getSnapshot,
525
+ );
526
+ };