@assistant-ui/react-langchain 0.0.30 → 0.0.31

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.
@@ -0,0 +1,515 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { LangChainBaseMessage, UIMessage } from "./types";
6
+
7
+ const { streamController } = vi.hoisted(() => ({
8
+ streamController: Symbol("STREAM_CONTROLLER"),
9
+ }));
10
+
11
+ vi.mock("@langchain/react", () => ({
12
+ STREAM_CONTROLLER: streamController,
13
+ }));
14
+
15
+ import {
16
+ MAX_SUBAGENT_DEPTH,
17
+ useSubagentTranscripts,
18
+ } from "./useSubagentTranscripts";
19
+
20
+ type FakeStore = {
21
+ getSnapshot(): LangChainBaseMessage[];
22
+ subscribe(listener: () => void): () => void;
23
+ notify(): void;
24
+ setSnapshot(messages: LangChainBaseMessage[]): void;
25
+ };
26
+
27
+ const createStore = (messages: LangChainBaseMessage[] = []): FakeStore => {
28
+ let snapshot = messages;
29
+ const listeners = new Set<() => void>();
30
+ return {
31
+ getSnapshot: () => snapshot,
32
+ subscribe(listener) {
33
+ listeners.add(listener);
34
+ return () => listeners.delete(listener);
35
+ },
36
+ notify() {
37
+ for (const listener of listeners) listener();
38
+ },
39
+ setSnapshot(messages) {
40
+ snapshot = messages;
41
+ for (const listener of listeners) listener();
42
+ },
43
+ };
44
+ };
45
+
46
+ const message = (
47
+ id: string,
48
+ type: "human" | "ai",
49
+ content: string,
50
+ ): LangChainBaseMessage & { id: string } => ({
51
+ id,
52
+ _getType: () => type,
53
+ content,
54
+ });
55
+
56
+ const subagent = (
57
+ id: string,
58
+ namespace: readonly string[],
59
+ status: "running" | "complete" | "error" = "running",
60
+ parentId: string | null = null,
61
+ depth = 1,
62
+ ) => ({
63
+ id,
64
+ namespace,
65
+ status,
66
+ parentId,
67
+ depth,
68
+ });
69
+
70
+ const createStream = (
71
+ subagents: ReadonlyMap<string, ReturnType<typeof subagent>>,
72
+ stores: Map<string, FakeStore>,
73
+ ) => {
74
+ const releases = new Map<string, ReturnType<typeof vi.fn>>();
75
+ const acquire = vi.fn((spec: { namespace: readonly string[] }) => {
76
+ const key = spec.namespace.join("/");
77
+ const release = vi.fn();
78
+ releases.set(key, release);
79
+ return { store: stores.get(key)!, release };
80
+ });
81
+ const resolveSubagentNamespace = vi.fn(async () => {});
82
+ return {
83
+ subagents,
84
+ [streamController]: {
85
+ resolveSubagentNamespace,
86
+ registry: { acquire },
87
+ },
88
+ acquire,
89
+ releases,
90
+ resolveSubagentNamespace,
91
+ };
92
+ };
93
+
94
+ const noUIMessages = new Map<string, UIMessage[]>();
95
+
96
+ const chart = (id: string, messageId: string): UIMessage => ({
97
+ type: "ui",
98
+ id,
99
+ name: "chart",
100
+ props: { points: [1, 2, 3] },
101
+ metadata: { message_id: messageId },
102
+ });
103
+
104
+ describe("useSubagentTranscripts", () => {
105
+ it("acquires each namespace once and releases every projection on unmount", async () => {
106
+ const stores = new Map([
107
+ ["tools:one", createStore()],
108
+ ["tools:two", createStore()],
109
+ ]);
110
+ const stream = createStream(
111
+ new Map([
112
+ ["task-one", subagent("task-one", ["tools:one"])],
113
+ ["task-two", subagent("task-two", ["tools:two"])],
114
+ ]),
115
+ stores,
116
+ );
117
+ const hook = renderHook(() =>
118
+ useSubagentTranscripts(stream as never, noUIMessages),
119
+ );
120
+
121
+ await waitFor(() => expect(stream.acquire).toHaveBeenCalledTimes(2));
122
+ hook.rerender();
123
+
124
+ expect(stream.acquire).toHaveBeenCalledTimes(2);
125
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledTimes(2);
126
+ hook.unmount();
127
+ expect(stream.releases.get("tools:one")).toHaveBeenCalledOnce();
128
+ expect(stream.releases.get("tools:two")).toHaveBeenCalledOnce();
129
+ });
130
+
131
+ it("does not acquire depth-17 subagents and releases projections that move past the depth cap", async () => {
132
+ const stores = new Map([["tools:one", createStore()]]);
133
+ const stream = createStream(
134
+ new Map([
135
+ ["task-one", subagent("task-one", ["tools:one"], "running", null, 16)],
136
+ ]),
137
+ stores,
138
+ );
139
+ const hook = renderHook(() =>
140
+ useSubagentTranscripts(stream as never, noUIMessages),
141
+ );
142
+
143
+ await waitFor(() => expect(stream.acquire).toHaveBeenCalledOnce());
144
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
145
+
146
+ stream.subagents = new Map([
147
+ ["task-one", subagent("task-one", ["tools:one"], "running", null, 17)],
148
+ ["task-two", subagent("task-two", ["tools:two"], "running", null, 17)],
149
+ ]);
150
+ hook.rerender();
151
+
152
+ await waitFor(() =>
153
+ expect(stream.releases.get("tools:one")).toHaveBeenCalledOnce(),
154
+ );
155
+ expect(stream.acquire).toHaveBeenCalledOnce();
156
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
157
+ });
158
+
159
+ it("rebinds a transcript projection when a subagent namespace is promoted", async () => {
160
+ const placeholderStore = createStore([
161
+ message("placeholder-ai", "ai", "placeholder"),
162
+ ]);
163
+ const promotedStore = createStore([
164
+ message("promoted-ai", "ai", "promoted"),
165
+ ]);
166
+ const stream = createStream(
167
+ new Map([["task-one", subagent("task-one", ["tools:placeholder"])]]),
168
+ new Map([
169
+ ["tools:placeholder", placeholderStore],
170
+ ["tools:promoted", promotedStore],
171
+ ]),
172
+ );
173
+ const hook = renderHook(() =>
174
+ useSubagentTranscripts(stream as never, noUIMessages),
175
+ );
176
+
177
+ await waitFor(() =>
178
+ expect(hook.result.current.get("task-one")?.[0]?.content).toMatchObject([
179
+ { type: "text", text: "placeholder" },
180
+ ]),
181
+ );
182
+
183
+ stream.subagents = new Map([
184
+ ["task-one", subagent("task-one", ["tools:promoted"])],
185
+ ]);
186
+ hook.rerender();
187
+
188
+ await waitFor(() =>
189
+ expect(hook.result.current.get("task-one")?.[0]?.content).toMatchObject([
190
+ { type: "text", text: "promoted" },
191
+ ]),
192
+ );
193
+ expect(stream.releases.get("tools:placeholder")).toHaveBeenCalledOnce();
194
+ expect(stream.acquire).toHaveBeenCalledWith(
195
+ expect.objectContaining({ namespace: ["tools:promoted"] }),
196
+ );
197
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
198
+ });
199
+
200
+ it("rebuilds after a projection update and preserves identity otherwise", async () => {
201
+ const store = createStore([message("subagent-ai", "ai", "one")]);
202
+ const stream = createStream(
203
+ new Map([["task-one", subagent("task-one", ["tools:one"])]]),
204
+ new Map([["tools:one", store]]),
205
+ );
206
+ const hook = renderHook(() =>
207
+ useSubagentTranscripts(stream as never, noUIMessages),
208
+ );
209
+
210
+ await waitFor(() => expect(hook.result.current.has("task-one")).toBe(true));
211
+ const initial = hook.result.current;
212
+ hook.rerender();
213
+ expect(hook.result.current).toBe(initial);
214
+
215
+ await act(async () => {
216
+ store.notify();
217
+ });
218
+
219
+ expect(hook.result.current).toBe(initial);
220
+
221
+ await act(async () => {
222
+ store.setSnapshot([message("subagent-ai", "ai", "two")]);
223
+ });
224
+
225
+ expect(hook.result.current).not.toBe(initial);
226
+ expect(hook.result.current.get("task-one")?.[0]?.content).toMatchObject([
227
+ { type: "text", text: "two" },
228
+ ]);
229
+ });
230
+
231
+ it("keeps unchanged transcript messages when a projection update replaces one", async () => {
232
+ const question = message("subagent-human", "human", "question");
233
+ const store = createStore([question, message("subagent-ai", "ai", "par")]);
234
+ const stream = createStream(
235
+ new Map([["task-one", subagent("task-one", ["tools:one"])]]),
236
+ new Map([["tools:one", store]]),
237
+ );
238
+ const hook = renderHook(() =>
239
+ useSubagentTranscripts(stream as never, noUIMessages),
240
+ );
241
+
242
+ await waitFor(() =>
243
+ expect(hook.result.current.get("task-one")).toHaveLength(2),
244
+ );
245
+ const initial = hook.result.current.get("task-one");
246
+
247
+ await act(async () => {
248
+ store.setSnapshot([question, message("subagent-ai", "ai", "partial")]);
249
+ });
250
+
251
+ const updated = hook.result.current.get("task-one");
252
+ expect(updated).toHaveLength(2);
253
+ expect(updated).not.toBe(initial);
254
+ expect(updated?.[0]).toBe(initial?.[0]);
255
+ expect(updated?.[1]).not.toBe(initial?.[1]);
256
+ });
257
+
258
+ it("adds UI messages to the nested message they belong to", async () => {
259
+ const store = createStore([message("subagent-ai", "ai", "answer")]);
260
+ const stream = createStream(
261
+ new Map([["task-one", subagent("task-one", ["tools:one"])]]),
262
+ new Map([["tools:one", store]]),
263
+ );
264
+ let uiMessagesByParent = noUIMessages;
265
+ const hook = renderHook(() =>
266
+ useSubagentTranscripts(stream as never, uiMessagesByParent),
267
+ );
268
+
269
+ await waitFor(() => expect(hook.result.current.has("task-one")).toBe(true));
270
+ uiMessagesByParent = new Map([
271
+ ["subagent-ai", [chart("ui-1", "subagent-ai")]],
272
+ ]);
273
+ hook.rerender();
274
+
275
+ await waitFor(() =>
276
+ expect(hook.result.current.get("task-one")?.[0]?.content).toMatchObject([
277
+ { type: "text", text: "answer" },
278
+ { type: "data", name: "chart", data: { points: [1, 2, 3] } },
279
+ ]),
280
+ );
281
+ });
282
+
283
+ it("rebuilds only the transcripts whose UI messages changed", async () => {
284
+ const oneStore = createStore([message("one-ai", "ai", "one")]);
285
+ const twoStore = createStore([message("two-ai", "ai", "two")]);
286
+ const stream = createStream(
287
+ new Map([
288
+ ["task-one", subagent("task-one", ["tools:one"])],
289
+ ["task-two", subagent("task-two", ["tools:two"])],
290
+ ]),
291
+ new Map([
292
+ ["tools:one", oneStore],
293
+ ["tools:two", twoStore],
294
+ ]),
295
+ );
296
+ let uiMessagesByParent = noUIMessages;
297
+ const hook = renderHook(() =>
298
+ useSubagentTranscripts(stream as never, uiMessagesByParent),
299
+ );
300
+
301
+ await waitFor(() => expect(hook.result.current.size).toBe(2));
302
+ const initial = hook.result.current;
303
+
304
+ uiMessagesByParent = new Map([["root-ai", [chart("ui-root", "root-ai")]]]);
305
+ hook.rerender();
306
+ expect(hook.result.current).toBe(initial);
307
+
308
+ const ui = chart("ui-1", "one-ai");
309
+ uiMessagesByParent = new Map([["one-ai", [ui]]]);
310
+ hook.rerender();
311
+ await waitFor(() =>
312
+ expect(hook.result.current.get("task-one")).not.toBe(
313
+ initial.get("task-one"),
314
+ ),
315
+ );
316
+ const withUI = hook.result.current;
317
+ expect(withUI.get("task-two")).toBe(initial.get("task-two"));
318
+
319
+ uiMessagesByParent = new Map([["one-ai", [ui]]]);
320
+ hook.rerender();
321
+ expect(hook.result.current).toBe(withUI);
322
+ });
323
+
324
+ it("sets the trailing transcript message status from the subagent status", async () => {
325
+ const store = createStore([message("subagent-ai", "ai", "answer")]);
326
+ const stream = createStream(
327
+ new Map([["task-one", subagent("task-one", ["tools:one"])]]),
328
+ new Map([["tools:one", store]]),
329
+ );
330
+ const hook = renderHook(() =>
331
+ useSubagentTranscripts(stream as never, noUIMessages),
332
+ );
333
+
334
+ await waitFor(() =>
335
+ expect(hook.result.current.get("task-one")?.[0]?.status).toMatchObject({
336
+ type: "running",
337
+ }),
338
+ );
339
+
340
+ stream.subagents = new Map([
341
+ ["task-one", subagent("task-one", ["tools:one"], "complete")],
342
+ ]);
343
+ hook.rerender();
344
+
345
+ await waitFor(() =>
346
+ expect(hook.result.current.get("task-one")?.[0]?.status).toMatchObject({
347
+ type: "complete",
348
+ }),
349
+ );
350
+ });
351
+
352
+ it("keeps unchanged sibling transcript identities after a projection update", async () => {
353
+ const oneStore = createStore([message("one-ai", "ai", "one")]);
354
+ const twoStore = createStore([message("two-ai", "ai", "two")]);
355
+ const stream = createStream(
356
+ new Map([
357
+ ["task-one", subagent("task-one", ["tools:one"])],
358
+ ["task-two", subagent("task-two", ["tools:two"])],
359
+ ]),
360
+ new Map([
361
+ ["tools:one", oneStore],
362
+ ["tools:two", twoStore],
363
+ ]),
364
+ );
365
+ const hook = renderHook(() =>
366
+ useSubagentTranscripts(stream as never, noUIMessages),
367
+ );
368
+
369
+ await waitFor(() => expect(hook.result.current.size).toBe(2));
370
+ const initial = hook.result.current;
371
+ const initialOne = initial.get("task-one");
372
+ const initialTwo = initial.get("task-two");
373
+
374
+ await act(async () => {
375
+ oneStore.setSnapshot([message("one-ai", "ai", "updated")]);
376
+ });
377
+
378
+ expect(hook.result.current).not.toBe(initial);
379
+ expect(hook.result.current.get("task-one")).not.toBe(initialOne);
380
+ expect(hook.result.current.get("task-two")).toBe(initialTwo);
381
+ });
382
+
383
+ it("nests a child discovered at the same depth as its parent", async () => {
384
+ const parentMessage: LangChainBaseMessage = {
385
+ id: "parent-ai",
386
+ _getType: () => "ai",
387
+ content: "delegating",
388
+ tool_calls: [{ id: "task-child", name: "task", args: {} }],
389
+ };
390
+ const childStore = createStore([message("child-ai", "ai", "child answer")]);
391
+ const parentStore = createStore([parentMessage]);
392
+ const stream = createStream(
393
+ new Map([
394
+ ["task-parent", subagent("task-parent", ["tools:parent"])],
395
+ [
396
+ "task-child",
397
+ subagent("task-child", ["tools:child"], "complete", "task-parent", 1),
398
+ ],
399
+ ]),
400
+ new Map([
401
+ ["tools:parent", parentStore],
402
+ ["tools:child", childStore],
403
+ ]),
404
+ );
405
+ const hook = renderHook(() =>
406
+ useSubagentTranscripts(stream as never, noUIMessages),
407
+ );
408
+
409
+ await waitFor(() => expect(hook.result.current.size).toBe(2));
410
+ const taskCall = hook.result.current
411
+ .get("task-parent")?.[0]
412
+ ?.content.find((part) => part.type === "tool-call");
413
+
414
+ expect(taskCall).toMatchObject({
415
+ messages: hook.result.current.get("task-child"),
416
+ });
417
+ });
418
+
419
+ it("stops attaching descendants past sixteen levels of actual nesting", async () => {
420
+ const subagents = new Map<string, ReturnType<typeof subagent>>();
421
+ const stores = new Map<string, FakeStore>();
422
+ const levels = MAX_SUBAGENT_DEPTH + 2;
423
+ for (let level = 1; level <= levels; level += 1) {
424
+ const id = `task-${level}`;
425
+ const childId = `task-${level + 1}`;
426
+ const namespace = [`tools:${level}`];
427
+ subagents.set(
428
+ id,
429
+ subagent(
430
+ id,
431
+ namespace,
432
+ "complete",
433
+ level === 1 ? null : `task-${level - 1}`,
434
+ 1,
435
+ ),
436
+ );
437
+ stores.set(
438
+ namespace.join("/"),
439
+ createStore([
440
+ {
441
+ id: `ai-${level}`,
442
+ _getType: () => "ai",
443
+ content: `level ${level}`,
444
+ tool_calls:
445
+ level < levels ? [{ id: childId, name: "task", args: {} }] : [],
446
+ },
447
+ ]),
448
+ );
449
+ }
450
+ const stream = createStream(subagents, stores);
451
+ const hook = renderHook(() =>
452
+ useSubagentTranscripts(stream as never, noUIMessages),
453
+ );
454
+
455
+ await waitFor(() => expect(hook.result.current.size).toBe(levels));
456
+ let transcript = hook.result.current.get("task-1");
457
+ let nested = 1;
458
+ while (transcript) {
459
+ const taskCall = transcript[0]?.content.find(
460
+ (part) => part.type === "tool-call",
461
+ );
462
+ transcript =
463
+ taskCall && "messages" in taskCall ? taskCall.messages : undefined;
464
+ if (transcript) nested += 1;
465
+ }
466
+
467
+ expect(nested).toBe(MAX_SUBAGENT_DEPTH);
468
+ const deepest = hook.result.current
469
+ .get(`task-${MAX_SUBAGENT_DEPTH + 1}`)?.[0]
470
+ ?.content.find((part) => part.type === "tool-call");
471
+ expect(deepest).not.toHaveProperty("messages");
472
+ });
473
+
474
+ it("nests child transcripts under the task call in their parent transcript", async () => {
475
+ const parentMessage: LangChainBaseMessage = {
476
+ id: "parent-ai",
477
+ _getType: () => "ai",
478
+ content: "delegating",
479
+ tool_calls: [{ id: "task-child", name: "task", args: {} }],
480
+ };
481
+ const childStore = createStore([message("child-ai", "ai", "child answer")]);
482
+ const parentStore = createStore([parentMessage]);
483
+ const stream = createStream(
484
+ new Map([
485
+ ["task-parent", subagent("task-parent", ["tools:parent"])],
486
+ [
487
+ "task-child",
488
+ subagent(
489
+ "task-child",
490
+ ["tools:parent", "tools:child"],
491
+ "complete",
492
+ "task-parent",
493
+ 2,
494
+ ),
495
+ ],
496
+ ]),
497
+ new Map([
498
+ ["tools:parent", parentStore],
499
+ ["tools:parent/tools:child", childStore],
500
+ ]),
501
+ );
502
+ const hook = renderHook(() =>
503
+ useSubagentTranscripts(stream as never, noUIMessages),
504
+ );
505
+
506
+ await waitFor(() => expect(hook.result.current.size).toBe(2));
507
+ const parentTranscript = hook.result.current.get("task-parent");
508
+ const childTranscript = hook.result.current.get("task-child");
509
+ const taskCall = parentTranscript?.[0]?.content.find(
510
+ (part) => part.type === "tool-call",
511
+ );
512
+
513
+ expect(taskCall).toMatchObject({ messages: childTranscript });
514
+ });
515
+ });