@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,1258 @@
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 { ThreadMessage } from "@assistant-ui/core";
6
+ import type { LangChainBaseMessage, UIMessage } from "./types";
7
+
8
+ const { streamController } = vi.hoisted(() => ({
9
+ streamController: Symbol("STREAM_CONTROLLER"),
10
+ }));
11
+
12
+ vi.mock("@langchain/react", () => ({
13
+ STREAM_CONTROLLER: streamController,
14
+ }));
15
+
16
+ import {
17
+ MAX_SUBAGENT_DEPTH,
18
+ useSubagentTranscripts,
19
+ } from "./useSubagentTranscripts";
20
+
21
+ type FakeStore<T> = {
22
+ getSnapshot(): T;
23
+ subscribe(listener: () => void): () => void;
24
+ notify(): void;
25
+ setSnapshot(snapshot: T): void;
26
+ };
27
+
28
+ const createFakeStore = <T,>(initial: T): FakeStore<T> => {
29
+ let snapshot = initial;
30
+ const listeners = new Set<() => void>();
31
+ return {
32
+ getSnapshot: () => snapshot,
33
+ subscribe(listener) {
34
+ listeners.add(listener);
35
+ return () => listeners.delete(listener);
36
+ },
37
+ notify() {
38
+ for (const listener of listeners) listener();
39
+ },
40
+ setSnapshot(next) {
41
+ snapshot = next;
42
+ for (const listener of listeners) listener();
43
+ },
44
+ };
45
+ };
46
+
47
+ const createStore = (messages: LangChainBaseMessage[] = []) =>
48
+ createFakeStore(messages);
49
+
50
+ const createUIStore = (events: UIChannelEvent[] = []) =>
51
+ createFakeStore(events);
52
+
53
+ const createDeferred = () => {
54
+ let resolve!: () => void;
55
+ const promise = new Promise<void>((res) => {
56
+ resolve = res;
57
+ });
58
+ return { promise, resolve };
59
+ };
60
+
61
+ const message = (
62
+ id: string,
63
+ type: "human" | "ai",
64
+ content: string,
65
+ ): LangChainBaseMessage & { id: string } => ({
66
+ id,
67
+ _getType: () => type,
68
+ content,
69
+ });
70
+
71
+ const subagent = (
72
+ id: string,
73
+ namespace: readonly string[],
74
+ status: "running" | "complete" | "error" = "running",
75
+ parentId: string | null = null,
76
+ depth = 1,
77
+ startedAt = new Date(1_000),
78
+ completedAt: Date | null = status === "running" ? null : new Date(4_000),
79
+ ) => ({
80
+ id,
81
+ namespace,
82
+ status,
83
+ parentId,
84
+ depth,
85
+ startedAt,
86
+ completedAt,
87
+ });
88
+
89
+ const messagesOf = (
90
+ transcripts: ReadonlyMap<string, { messages: readonly ThreadMessage[] }>,
91
+ id: string,
92
+ ) => transcripts.get(id)?.messages;
93
+
94
+ const createStream = (
95
+ subagents: ReadonlyMap<string, ReturnType<typeof subagent>>,
96
+ stores: Map<string, FakeStore<LangChainBaseMessage[]>>,
97
+ uiStores = new Map<string, FakeStore<UIChannelEvent[]>>(),
98
+ ) => {
99
+ const releases = new Map<string, ReturnType<typeof vi.fn>>();
100
+ const uiReleases = new Map<string, ReturnType<typeof vi.fn>>();
101
+ const acquire = vi.fn(
102
+ (spec: { key: string; namespace: readonly string[] }) => {
103
+ const key = spec.namespace.join("/");
104
+ const release = vi.fn();
105
+ if (spec.key.startsWith("channel|")) {
106
+ uiReleases.set(key, release);
107
+ return { store: uiStores.get(key) ?? createUIStore(), release };
108
+ }
109
+ releases.set(key, release);
110
+ return { store: stores.get(key)!, release };
111
+ },
112
+ );
113
+ const resolveSubagentNamespace = vi.fn(async () => {});
114
+ return {
115
+ subagents,
116
+ [streamController]: {
117
+ resolveSubagentNamespace,
118
+ registry: { acquire },
119
+ },
120
+ acquire,
121
+ releases,
122
+ uiReleases,
123
+ resolveSubagentNamespace,
124
+ };
125
+ };
126
+
127
+ const noUIMessages = new Map<string, UIMessage[]>();
128
+
129
+ const chart = (id: string, messageId: string): UIMessage => ({
130
+ type: "ui",
131
+ id,
132
+ name: "chart",
133
+ props: { points: [1, 2, 3] },
134
+ metadata: { message_id: messageId },
135
+ });
136
+
137
+ type UIChannelEvent = {
138
+ method: "custom";
139
+ params: { namespace: readonly string[]; data: unknown };
140
+ };
141
+
142
+ const uiEvent = (
143
+ namespace: readonly string[],
144
+ data: unknown,
145
+ ): UIChannelEvent => ({
146
+ method: "custom",
147
+ params: { namespace, data },
148
+ });
149
+
150
+ const nestedSubagents = () =>
151
+ new Map([
152
+ ["task-parent", subagent("task-parent", ["tools:parent"])],
153
+ [
154
+ "task-child",
155
+ subagent(
156
+ "task-child",
157
+ ["tools:parent", "tools:child"],
158
+ "running",
159
+ "task-parent",
160
+ 2,
161
+ ),
162
+ ],
163
+ ]);
164
+
165
+ describe("useSubagentTranscripts", () => {
166
+ it("acquires each namespace once and releases every projection on unmount", async () => {
167
+ const stores = new Map([
168
+ ["tools:one", createStore()],
169
+ ["tools:two", createStore()],
170
+ ]);
171
+ const stream = createStream(
172
+ new Map([
173
+ ["task-one", subagent("task-one", ["tools:one"])],
174
+ ["task-two", subagent("task-two", ["tools:two"])],
175
+ ]),
176
+ stores,
177
+ );
178
+ const hook = renderHook(() =>
179
+ useSubagentTranscripts(stream as never, noUIMessages),
180
+ );
181
+
182
+ await waitFor(() => expect(stream.acquire).toHaveBeenCalledTimes(2));
183
+ hook.rerender();
184
+
185
+ expect(stream.acquire).toHaveBeenCalledTimes(2);
186
+ expect(stream.resolveSubagentNamespace).not.toHaveBeenCalled();
187
+ hook.unmount();
188
+ expect(stream.releases.get("tools:one")).toHaveBeenCalledOnce();
189
+ expect(stream.releases.get("tools:two")).toHaveBeenCalledOnce();
190
+ });
191
+
192
+ it("serializes a bounded retry for a later unresolved status", async () => {
193
+ const stores = new Map([["tools:task-one", createStore()]]);
194
+ const stream = createStream(
195
+ new Map([["task-one", subagent("task-one", ["tools:task-one"])]]),
196
+ stores,
197
+ );
198
+ const firstRequest = createDeferred();
199
+ const secondRequest = createDeferred();
200
+ stream.resolveSubagentNamespace
201
+ .mockImplementationOnce(() => firstRequest.promise)
202
+ .mockImplementationOnce(() => secondRequest.promise);
203
+ const hook = renderHook(() =>
204
+ useSubagentTranscripts(stream as never, noUIMessages),
205
+ );
206
+
207
+ await waitFor(() =>
208
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce(),
209
+ );
210
+ act(() => {
211
+ stream.subagents = new Map([
212
+ ["task-one", subagent("task-one", ["tools:task-one"])],
213
+ ]);
214
+ hook.rerender();
215
+ });
216
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
217
+
218
+ act(() => {
219
+ stream.subagents = new Map([
220
+ ["task-one", subagent("task-one", ["tools:task-one"], "complete")],
221
+ ]);
222
+ hook.rerender();
223
+ });
224
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
225
+
226
+ await act(async () => {
227
+ firstRequest.resolve();
228
+ });
229
+
230
+ await waitFor(() =>
231
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledTimes(2),
232
+ );
233
+ act(() => {
234
+ stream.subagents = new Map([
235
+ ["task-one", subagent("task-one", ["tools:task-one"], "complete")],
236
+ ]);
237
+ hook.rerender();
238
+ });
239
+ secondRequest.resolve();
240
+ await act(() => Promise.resolve());
241
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledTimes(2);
242
+ });
243
+
244
+ it("retries only after an unresolved namespace changes status", async () => {
245
+ const stores = new Map([["tools:task-one", createStore()]]);
246
+ const stream = createStream(
247
+ new Map([["task-one", subagent("task-one", ["tools:task-one"])]]),
248
+ stores,
249
+ );
250
+ const hook = renderHook(() =>
251
+ useSubagentTranscripts(stream as never, noUIMessages),
252
+ );
253
+
254
+ await waitFor(() =>
255
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce(),
256
+ );
257
+ await act(async () => {
258
+ await Promise.resolve();
259
+ await Promise.resolve();
260
+ stream.subagents = new Map([
261
+ ["task-one", subagent("task-one", ["tools:task-one"])],
262
+ ]);
263
+ hook.rerender();
264
+ });
265
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
266
+
267
+ await act(async () => {
268
+ stream.subagents = new Map([
269
+ ["task-one", subagent("task-one", ["tools:task-one"], "complete")],
270
+ ]);
271
+ hook.rerender();
272
+ });
273
+ await waitFor(() =>
274
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledTimes(2),
275
+ );
276
+
277
+ act(() => {
278
+ stream.subagents = new Map([
279
+ ["task-one", subagent("task-one", ["tools:task-one"], "complete")],
280
+ ]);
281
+ hook.rerender();
282
+ });
283
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledTimes(2);
284
+ });
285
+
286
+ it("retries an initially terminal unresolved namespace once", async () => {
287
+ const stores = new Map([["tools:task-one", createStore()]]);
288
+ const stream = createStream(
289
+ new Map([
290
+ ["task-one", subagent("task-one", ["tools:task-one"], "complete")],
291
+ ]),
292
+ stores,
293
+ );
294
+ const hook = renderHook(() =>
295
+ useSubagentTranscripts(stream as never, noUIMessages),
296
+ );
297
+
298
+ await waitFor(() =>
299
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledTimes(2),
300
+ );
301
+ await act(async () => {
302
+ await Promise.resolve();
303
+ stream.subagents = new Map([
304
+ ["task-one", subagent("task-one", ["tools:task-one"], "complete")],
305
+ ]);
306
+ hook.rerender();
307
+ });
308
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledTimes(2);
309
+ });
310
+
311
+ it("does not acquire depth-17 subagents and releases projections that move past the depth cap", async () => {
312
+ const stores = new Map([["tools:task-one", createStore()]]);
313
+ const stream = createStream(
314
+ new Map([
315
+ [
316
+ "task-one",
317
+ subagent("task-one", ["tools:task-one"], "running", null, 16),
318
+ ],
319
+ ]),
320
+ stores,
321
+ );
322
+ const hook = renderHook(() =>
323
+ useSubagentTranscripts(stream as never, noUIMessages),
324
+ );
325
+
326
+ await waitFor(() => expect(stream.acquire).toHaveBeenCalledOnce());
327
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
328
+
329
+ stream.subagents = new Map([
330
+ ["task-one", subagent("task-one", ["tools:one"], "running", null, 17)],
331
+ ["task-two", subagent("task-two", ["tools:two"], "running", null, 17)],
332
+ ]);
333
+ hook.rerender();
334
+
335
+ await waitFor(() =>
336
+ expect(stream.releases.get("tools:task-one")).toHaveBeenCalledOnce(),
337
+ );
338
+ expect(stream.acquire).toHaveBeenCalledOnce();
339
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
340
+ });
341
+
342
+ it("rebinds a transcript projection when a subagent namespace is promoted", async () => {
343
+ const placeholderStore = createStore([
344
+ message("placeholder-ai", "ai", "placeholder"),
345
+ ]);
346
+ const promotedStore = createStore([
347
+ message("promoted-ai", "ai", "promoted"),
348
+ ]);
349
+ const stream = createStream(
350
+ new Map([["task-one", subagent("task-one", ["tools:task-one"])]]),
351
+ new Map([
352
+ ["tools:task-one", placeholderStore],
353
+ ["tools:promoted", promotedStore],
354
+ ]),
355
+ );
356
+ const hook = renderHook(() =>
357
+ useSubagentTranscripts(stream as never, noUIMessages),
358
+ );
359
+
360
+ await waitFor(() =>
361
+ expect(
362
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
363
+ ).toMatchObject([{ type: "text", text: "placeholder" }]),
364
+ );
365
+
366
+ stream.subagents = new Map([
367
+ ["task-one", subagent("task-one", ["tools:promoted"])],
368
+ ]);
369
+ hook.rerender();
370
+
371
+ await waitFor(() =>
372
+ expect(
373
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
374
+ ).toMatchObject([{ type: "text", text: "promoted" }]),
375
+ );
376
+ expect(stream.releases.get("tools:task-one")).toHaveBeenCalledOnce();
377
+ expect(stream.acquire).toHaveBeenCalledWith(
378
+ expect.objectContaining({ namespace: ["tools:promoted"] }),
379
+ );
380
+ expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
381
+ });
382
+
383
+ it("rebuilds after a projection update and preserves identity otherwise", async () => {
384
+ const store = createStore([message("subagent-ai", "ai", "one")]);
385
+ const stream = createStream(
386
+ new Map([["task-one", subagent("task-one", ["tools:one"])]]),
387
+ new Map([["tools:one", store]]),
388
+ );
389
+ const hook = renderHook(() =>
390
+ useSubagentTranscripts(stream as never, noUIMessages),
391
+ );
392
+
393
+ await waitFor(() => expect(hook.result.current.has("task-one")).toBe(true));
394
+ const initial = hook.result.current;
395
+ hook.rerender();
396
+ expect(hook.result.current).toBe(initial);
397
+
398
+ await act(async () => {
399
+ store.notify();
400
+ });
401
+
402
+ expect(hook.result.current).toBe(initial);
403
+
404
+ await act(async () => {
405
+ store.setSnapshot([message("subagent-ai", "ai", "two")]);
406
+ });
407
+
408
+ expect(hook.result.current).not.toBe(initial);
409
+ expect(
410
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
411
+ ).toMatchObject([{ type: "text", text: "two" }]);
412
+ });
413
+
414
+ it("keeps unchanged transcript messages when a projection update replaces one", async () => {
415
+ const question = message("subagent-human", "human", "question");
416
+ const store = createStore([question, message("subagent-ai", "ai", "par")]);
417
+ const stream = createStream(
418
+ new Map([["task-one", subagent("task-one", ["tools:one"])]]),
419
+ new Map([["tools:one", store]]),
420
+ );
421
+ const hook = renderHook(() =>
422
+ useSubagentTranscripts(stream as never, noUIMessages),
423
+ );
424
+
425
+ await waitFor(() =>
426
+ expect(messagesOf(hook.result.current, "task-one")).toHaveLength(2),
427
+ );
428
+ const initial = messagesOf(hook.result.current, "task-one");
429
+
430
+ await act(async () => {
431
+ store.setSnapshot([question, message("subagent-ai", "ai", "partial")]);
432
+ });
433
+
434
+ const updated = messagesOf(hook.result.current, "task-one");
435
+ expect(updated).toHaveLength(2);
436
+ expect(updated).not.toBe(initial);
437
+ expect(updated?.[0]).toBe(initial?.[0]);
438
+ expect(updated?.[1]).not.toBe(initial?.[1]);
439
+ });
440
+
441
+ it("adds UI messages to the nested message they belong to", async () => {
442
+ const store = createStore([message("subagent-ai", "ai", "answer")]);
443
+ const stream = createStream(
444
+ new Map([["task-one", subagent("task-one", ["tools:one"])]]),
445
+ new Map([["tools:one", store]]),
446
+ );
447
+ let uiMessagesByParent = noUIMessages;
448
+ const hook = renderHook(() =>
449
+ useSubagentTranscripts(stream as never, uiMessagesByParent),
450
+ );
451
+
452
+ await waitFor(() => expect(hook.result.current.has("task-one")).toBe(true));
453
+ uiMessagesByParent = new Map([
454
+ ["subagent-ai", [chart("ui-1", "subagent-ai")]],
455
+ ]);
456
+ hook.rerender();
457
+
458
+ await waitFor(() =>
459
+ expect(
460
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
461
+ ).toMatchObject([
462
+ { type: "text", text: "answer" },
463
+ { type: "data", name: "chart", data: { points: [1, 2, 3] } },
464
+ ]),
465
+ );
466
+ });
467
+
468
+ it("rebuilds only the transcripts whose UI messages changed", async () => {
469
+ const oneStore = createStore([message("one-ai", "ai", "one")]);
470
+ const twoStore = createStore([message("two-ai", "ai", "two")]);
471
+ const stream = createStream(
472
+ new Map([
473
+ ["task-one", subagent("task-one", ["tools:one"])],
474
+ ["task-two", subagent("task-two", ["tools:two"])],
475
+ ]),
476
+ new Map([
477
+ ["tools:one", oneStore],
478
+ ["tools:two", twoStore],
479
+ ]),
480
+ );
481
+ let uiMessagesByParent = noUIMessages;
482
+ const hook = renderHook(() =>
483
+ useSubagentTranscripts(stream as never, uiMessagesByParent),
484
+ );
485
+
486
+ await waitFor(() => expect(hook.result.current.size).toBe(2));
487
+ const initial = hook.result.current;
488
+
489
+ uiMessagesByParent = new Map([["root-ai", [chart("ui-root", "root-ai")]]]);
490
+ hook.rerender();
491
+ expect(hook.result.current).toBe(initial);
492
+
493
+ const ui = chart("ui-1", "one-ai");
494
+ uiMessagesByParent = new Map([["one-ai", [ui]]]);
495
+ hook.rerender();
496
+ await waitFor(() =>
497
+ expect(messagesOf(hook.result.current, "task-one")).not.toBe(
498
+ initial.get("task-one"),
499
+ ),
500
+ );
501
+ const withUI = hook.result.current;
502
+ expect(withUI.get("task-two")).toBe(initial.get("task-two"));
503
+
504
+ uiMessagesByParent = new Map([["one-ai", [ui]]]);
505
+ hook.rerender();
506
+ expect(hook.result.current).toBe(withUI);
507
+ });
508
+
509
+ it("sets the trailing transcript message status from the subagent status", async () => {
510
+ const store = createStore([message("subagent-ai", "ai", "answer")]);
511
+ const stream = createStream(
512
+ new Map([["task-one", subagent("task-one", ["tools:one"])]]),
513
+ new Map([["tools:one", store]]),
514
+ );
515
+ const hook = renderHook(() =>
516
+ useSubagentTranscripts(stream as never, noUIMessages),
517
+ );
518
+
519
+ await waitFor(() =>
520
+ expect(
521
+ messagesOf(hook.result.current, "task-one")?.[0]?.status,
522
+ ).toMatchObject({
523
+ type: "running",
524
+ }),
525
+ );
526
+
527
+ stream.subagents = new Map([
528
+ ["task-one", subagent("task-one", ["tools:one"], "complete")],
529
+ ]);
530
+ hook.rerender();
531
+
532
+ await waitFor(() =>
533
+ expect(
534
+ messagesOf(hook.result.current, "task-one")?.[0]?.status,
535
+ ).toMatchObject({
536
+ type: "complete",
537
+ }),
538
+ );
539
+ });
540
+
541
+ it("keeps unchanged sibling transcript identities after a projection update", async () => {
542
+ const oneStore = createStore([message("one-ai", "ai", "one")]);
543
+ const twoStore = createStore([message("two-ai", "ai", "two")]);
544
+ const stream = createStream(
545
+ new Map([
546
+ ["task-one", subagent("task-one", ["tools:one"])],
547
+ ["task-two", subagent("task-two", ["tools:two"])],
548
+ ]),
549
+ new Map([
550
+ ["tools:one", oneStore],
551
+ ["tools:two", twoStore],
552
+ ]),
553
+ );
554
+ const hook = renderHook(() =>
555
+ useSubagentTranscripts(stream as never, noUIMessages),
556
+ );
557
+
558
+ await waitFor(() => expect(hook.result.current.size).toBe(2));
559
+ const initial = hook.result.current;
560
+ const initialOne = messagesOf(initial, "task-one");
561
+ const initialTwo = messagesOf(initial, "task-two");
562
+
563
+ await act(async () => {
564
+ oneStore.setSnapshot([message("one-ai", "ai", "updated")]);
565
+ });
566
+
567
+ expect(hook.result.current).not.toBe(initial);
568
+ expect(messagesOf(hook.result.current, "task-one")).not.toBe(initialOne);
569
+ expect(messagesOf(hook.result.current, "task-two")).toBe(initialTwo);
570
+ });
571
+
572
+ it("nests a child discovered at the same depth as its parent", async () => {
573
+ const parentMessage: LangChainBaseMessage = {
574
+ id: "parent-ai",
575
+ _getType: () => "ai",
576
+ content: "delegating",
577
+ tool_calls: [{ id: "task-child", name: "task", args: {} }],
578
+ };
579
+ const childStore = createStore([message("child-ai", "ai", "child answer")]);
580
+ const parentStore = createStore([parentMessage]);
581
+ const stream = createStream(
582
+ new Map([
583
+ ["task-parent", subagent("task-parent", ["tools:parent"])],
584
+ [
585
+ "task-child",
586
+ subagent("task-child", ["tools:child"], "complete", "task-parent", 1),
587
+ ],
588
+ ]),
589
+ new Map([
590
+ ["tools:parent", parentStore],
591
+ ["tools:child", childStore],
592
+ ]),
593
+ );
594
+ const hook = renderHook(() =>
595
+ useSubagentTranscripts(stream as never, noUIMessages),
596
+ );
597
+
598
+ await waitFor(() => expect(hook.result.current.size).toBe(2));
599
+ const taskCall = messagesOf(
600
+ hook.result.current,
601
+ "task-parent",
602
+ )?.[0]?.content.find((part) => part.type === "tool-call");
603
+
604
+ expect(taskCall).toMatchObject({
605
+ messages: messagesOf(hook.result.current, "task-child"),
606
+ });
607
+ });
608
+
609
+ it("stops attaching descendants past sixteen levels of actual nesting", async () => {
610
+ const subagents = new Map<string, ReturnType<typeof subagent>>();
611
+ const stores = new Map<string, FakeStore<LangChainBaseMessage[]>>();
612
+ const levels = MAX_SUBAGENT_DEPTH + 2;
613
+ for (let level = 1; level <= levels; level += 1) {
614
+ const id = `task-${level}`;
615
+ const childId = `task-${level + 1}`;
616
+ const namespace = [`tools:${level}`];
617
+ subagents.set(
618
+ id,
619
+ subagent(
620
+ id,
621
+ namespace,
622
+ "complete",
623
+ level === 1 ? null : `task-${level - 1}`,
624
+ 1,
625
+ ),
626
+ );
627
+ stores.set(
628
+ namespace.join("/"),
629
+ createStore([
630
+ {
631
+ id: `ai-${level}`,
632
+ _getType: () => "ai",
633
+ content: `level ${level}`,
634
+ tool_calls:
635
+ level < levels ? [{ id: childId, name: "task", args: {} }] : [],
636
+ },
637
+ ]),
638
+ );
639
+ }
640
+ const stream = createStream(subagents, stores);
641
+ const hook = renderHook(() =>
642
+ useSubagentTranscripts(stream as never, noUIMessages),
643
+ );
644
+
645
+ await waitFor(() => expect(hook.result.current.size).toBe(levels));
646
+ let transcript = messagesOf(hook.result.current, "task-1");
647
+ let nested = 1;
648
+ while (transcript) {
649
+ const taskCall = transcript[0]?.content.find(
650
+ (part) => part.type === "tool-call",
651
+ );
652
+ transcript =
653
+ taskCall && "messages" in taskCall ? taskCall.messages : undefined;
654
+ if (transcript) nested += 1;
655
+ }
656
+
657
+ expect(nested).toBe(MAX_SUBAGENT_DEPTH);
658
+ const deepest = messagesOf(
659
+ hook.result.current,
660
+ `task-${MAX_SUBAGENT_DEPTH + 1}`,
661
+ )?.[0]?.content.find((part) => part.type === "tool-call");
662
+ expect(deepest).not.toHaveProperty("messages");
663
+ });
664
+
665
+ it("nests child transcripts under the task call in their parent transcript", async () => {
666
+ const parentMessage: LangChainBaseMessage = {
667
+ id: "parent-ai",
668
+ _getType: () => "ai",
669
+ content: "delegating",
670
+ tool_calls: [{ id: "task-child", name: "task", args: {} }],
671
+ };
672
+ const childStore = createStore([message("child-ai", "ai", "child answer")]);
673
+ const parentStore = createStore([parentMessage]);
674
+ const stream = createStream(
675
+ new Map([
676
+ ["task-parent", subagent("task-parent", ["tools:parent"])],
677
+ [
678
+ "task-child",
679
+ subagent(
680
+ "task-child",
681
+ ["tools:parent", "tools:child"],
682
+ "complete",
683
+ "task-parent",
684
+ 2,
685
+ ),
686
+ ],
687
+ ]),
688
+ new Map([
689
+ ["tools:parent", parentStore],
690
+ ["tools:parent/tools:child", childStore],
691
+ ]),
692
+ );
693
+ const hook = renderHook(() =>
694
+ useSubagentTranscripts(stream as never, noUIMessages),
695
+ );
696
+
697
+ await waitFor(() => expect(hook.result.current.size).toBe(2));
698
+ const parentTranscript = messagesOf(hook.result.current, "task-parent");
699
+ const childTranscript = messagesOf(hook.result.current, "task-child");
700
+ const taskCall = parentTranscript?.[0]?.content.find(
701
+ (part) => part.type === "tool-call",
702
+ );
703
+
704
+ expect(taskCall).toMatchObject({ messages: childTranscript });
705
+ });
706
+ it("renders live UI a subagent pushed from its own nested namespace", async () => {
707
+ const childStore = createStore([message("child-ai", "ai", "child answer")]);
708
+ const childUIStore = createUIStore([
709
+ uiEvent(["tools:parent", "tools:child"], chart("ui-child", "child-ai")),
710
+ uiEvent(
711
+ ["tools:parent", "tools:child", "tools:grandchild"],
712
+ chart("ui-grandchild", "grandchild-ai"),
713
+ ),
714
+ ]);
715
+ const stream = createStream(
716
+ nestedSubagents(),
717
+ new Map([
718
+ [
719
+ "tools:parent",
720
+ createStore([message("parent-ai", "ai", "delegating")]),
721
+ ],
722
+ ["tools:parent/tools:child", childStore],
723
+ ]),
724
+ new Map([["tools:parent/tools:child", childUIStore]]),
725
+ );
726
+ const hook = renderHook(() =>
727
+ useSubagentTranscripts(stream as never, noUIMessages),
728
+ );
729
+
730
+ await waitFor(() =>
731
+ expect(
732
+ messagesOf(hook.result.current, "task-child")?.[0]?.content,
733
+ ).toMatchObject([
734
+ { type: "text", text: "child answer" },
735
+ { type: "data", name: "chart", data: { points: [1, 2, 3] } },
736
+ ]),
737
+ );
738
+
739
+ await act(async () => {
740
+ childUIStore.setSnapshot([
741
+ uiEvent(["tools:parent", "tools:child"], chart("ui-child", "child-ai")),
742
+ uiEvent(["tools:parent", "tools:child"], {
743
+ type: "remove-ui",
744
+ id: "ui-child",
745
+ }),
746
+ ]);
747
+ });
748
+
749
+ expect(
750
+ messagesOf(hook.result.current, "task-child")?.[0]?.content.some(
751
+ (part) => part.type === "data",
752
+ ),
753
+ ).toBe(false);
754
+ });
755
+
756
+ it("opens a custom projection only for subagents the root channel cannot reach", async () => {
757
+ const stream = createStream(
758
+ nestedSubagents(),
759
+ new Map([
760
+ ["tools:parent", createStore()],
761
+ ["tools:parent/tools:child", createStore()],
762
+ ]),
763
+ );
764
+ const hook = renderHook(() =>
765
+ useSubagentTranscripts(stream as never, noUIMessages),
766
+ );
767
+
768
+ await waitFor(() => expect(stream.acquire).toHaveBeenCalledTimes(3));
769
+ const channelSpecs = stream.acquire.mock.calls
770
+ .map(([spec]) => spec)
771
+ .filter((spec) => spec.key.startsWith("channel|"));
772
+ expect(channelSpecs).toHaveLength(1);
773
+ expect(channelSpecs[0]?.namespace).toEqual(["tools:parent", "tools:child"]);
774
+
775
+ hook.unmount();
776
+ expect(
777
+ stream.uiReleases.get("tools:parent/tools:child"),
778
+ ).toHaveBeenCalledOnce();
779
+ });
780
+
781
+ it("ignores live UI whose message is absent from this transcript", async () => {
782
+ const childUI = uiEvent(
783
+ ["tools:parent", "tools:child"],
784
+ chart("ui-child", "child-ai"),
785
+ );
786
+ const childUIStore = createUIStore([childUI]);
787
+ const stream = createStream(
788
+ nestedSubagents(),
789
+ new Map([
790
+ ["tools:parent", createStore()],
791
+ [
792
+ "tools:parent/tools:child",
793
+ createStore([message("child-ai", "ai", "child answer")]),
794
+ ],
795
+ ]),
796
+ new Map([["tools:parent/tools:child", childUIStore]]),
797
+ );
798
+ const hook = renderHook(() =>
799
+ useSubagentTranscripts(stream as never, noUIMessages),
800
+ );
801
+
802
+ await waitFor(() =>
803
+ expect(
804
+ messagesOf(hook.result.current, "task-child")?.[0]?.content.some(
805
+ (part) => part.type === "data",
806
+ ),
807
+ ).toBe(true),
808
+ );
809
+ const transcript = messagesOf(hook.result.current, "task-child");
810
+
811
+ await act(async () => {
812
+ childUIStore.setSnapshot([
813
+ childUI,
814
+ uiEvent(
815
+ ["tools:parent", "tools:child", "tools:grandchild"],
816
+ chart("ui-grandchild", "grandchild-ai"),
817
+ ),
818
+ ]);
819
+ });
820
+
821
+ expect(messagesOf(hook.result.current, "task-child")).toBe(transcript);
822
+ });
823
+
824
+ it("keeps a nested transcript stable while a streamed UI update repeats", async () => {
825
+ const streamedChart = (props: Record<string, unknown>): UIMessage => ({
826
+ type: "ui",
827
+ id: "ui-child",
828
+ name: "chart",
829
+ props,
830
+ metadata: { message_id: "child-ai", merge: true },
831
+ });
832
+ const parentStore = createStore([message("parent-ai", "ai", "delegating")]);
833
+ const childUIStore = createUIStore([
834
+ uiEvent(["tools:parent", "tools:child"], streamedChart({ points: [1] })),
835
+ uiEvent(["tools:parent", "tools:child"], streamedChart({ label: "a" })),
836
+ ]);
837
+ const stream = createStream(
838
+ nestedSubagents(),
839
+ new Map([
840
+ ["tools:parent", parentStore],
841
+ [
842
+ "tools:parent/tools:child",
843
+ createStore([message("child-ai", "ai", "child answer")]),
844
+ ],
845
+ ]),
846
+ new Map([["tools:parent/tools:child", childUIStore]]),
847
+ );
848
+ const hook = renderHook(() =>
849
+ useSubagentTranscripts(stream as never, noUIMessages),
850
+ );
851
+
852
+ await waitFor(() =>
853
+ expect(
854
+ messagesOf(hook.result.current, "task-child")?.[0]?.content.some(
855
+ (part) => part.type === "data",
856
+ ),
857
+ ).toBe(true),
858
+ );
859
+ const transcript = messagesOf(hook.result.current, "task-child");
860
+
861
+ await act(async () => {
862
+ parentStore.notify();
863
+ });
864
+
865
+ expect(messagesOf(hook.result.current, "task-child")).toBe(transcript);
866
+ });
867
+
868
+ it("keeps graph state authoritative over a live nested update", async () => {
869
+ const childUIStore = createUIStore([
870
+ uiEvent(["tools:parent", "tools:child"], chart("ui-child", "child-ai")),
871
+ ]);
872
+ const stream = createStream(
873
+ nestedSubagents(),
874
+ new Map([
875
+ ["tools:parent", createStore()],
876
+ [
877
+ "tools:parent/tools:child",
878
+ createStore([message("child-ai", "ai", "child answer")]),
879
+ ],
880
+ ]),
881
+ new Map([["tools:parent/tools:child", childUIStore]]),
882
+ );
883
+ const stateUiMessages = new Map<string, UIMessage[]>([
884
+ [
885
+ "child-ai",
886
+ [
887
+ {
888
+ type: "ui",
889
+ id: "ui-child",
890
+ name: "chart",
891
+ props: { points: [9] },
892
+ metadata: { message_id: "child-ai" },
893
+ },
894
+ ],
895
+ ],
896
+ ]);
897
+ const hook = renderHook(() =>
898
+ useSubagentTranscripts(stream as never, stateUiMessages),
899
+ );
900
+
901
+ await waitFor(() =>
902
+ expect(
903
+ messagesOf(hook.result.current, "task-child")?.[0]?.content,
904
+ ).toMatchObject([
905
+ { type: "text", text: "child answer" },
906
+ { type: "data", name: "chart", data: { points: [9] } },
907
+ ]),
908
+ );
909
+ });
910
+
911
+ it("carries the subagent's wall clock onto its task call", async () => {
912
+ const parentMessage: LangChainBaseMessage = {
913
+ id: "parent-ai",
914
+ _getType: () => "ai",
915
+ content: "delegating",
916
+ tool_calls: [{ id: "task-child", name: "task", args: {} }],
917
+ };
918
+ const stream = createStream(
919
+ new Map([
920
+ ["task-parent", subagent("task-parent", ["tools:parent"])],
921
+ [
922
+ "task-child",
923
+ subagent(
924
+ "task-child",
925
+ ["tools:parent", "tools:child"],
926
+ "running",
927
+ "task-parent",
928
+ 2,
929
+ new Date(1_000),
930
+ null,
931
+ ),
932
+ ],
933
+ ]),
934
+ new Map([
935
+ ["tools:parent", createStore([parentMessage])],
936
+ [
937
+ "tools:parent/tools:child",
938
+ createStore([message("child-ai", "ai", "child answer")]),
939
+ ],
940
+ ]),
941
+ );
942
+ const hook = renderHook(() =>
943
+ useSubagentTranscripts(stream as never, noUIMessages),
944
+ );
945
+
946
+ const taskCall = () =>
947
+ messagesOf(hook.result.current, "task-parent")?.[0]?.content.find(
948
+ (part) => part.type === "tool-call",
949
+ );
950
+
951
+ await waitFor(() =>
952
+ expect(taskCall()).toMatchObject({ timing: { startedAt: 1_000 } }),
953
+ );
954
+ expect(taskCall()).not.toHaveProperty("timing.completedAt");
955
+
956
+ stream.subagents = new Map([
957
+ ["task-parent", subagent("task-parent", ["tools:parent"])],
958
+ [
959
+ "task-child",
960
+ subagent(
961
+ "task-child",
962
+ ["tools:parent", "tools:child"],
963
+ "complete",
964
+ "task-parent",
965
+ 2,
966
+ new Date(1_000),
967
+ new Date(4_500),
968
+ ),
969
+ ],
970
+ ]);
971
+ hook.rerender();
972
+
973
+ await waitFor(() =>
974
+ expect(taskCall()).toMatchObject({
975
+ timing: { startedAt: 1_000, completedAt: 4_500 },
976
+ }),
977
+ );
978
+ });
979
+
980
+ it("records streaming timing for a nested message once its subagent finishes", async () => {
981
+ const store = createStore([message("child-ai", "ai", "partial")]);
982
+ const stream = createStream(
983
+ new Map([
984
+ [
985
+ "task-child",
986
+ subagent(
987
+ "task-child",
988
+ ["tools:parent", "tools:child"],
989
+ "running",
990
+ null,
991
+ 2,
992
+ ),
993
+ ],
994
+ ]),
995
+ new Map([["tools:parent/tools:child", store]]),
996
+ );
997
+ const hook = renderHook(() =>
998
+ useSubagentTranscripts(stream as never, noUIMessages),
999
+ );
1000
+
1001
+ await waitFor(() =>
1002
+ expect(messagesOf(hook.result.current, "task-child")).toHaveLength(1),
1003
+ );
1004
+ expect(
1005
+ messagesOf(hook.result.current, "task-child")?.[0]?.metadata,
1006
+ ).not.toHaveProperty("timing");
1007
+
1008
+ await act(async () => {
1009
+ store.setSnapshot([message("child-ai", "ai", "partial answer")]);
1010
+ });
1011
+
1012
+ stream.subagents = new Map([
1013
+ [
1014
+ "task-child",
1015
+ subagent(
1016
+ "task-child",
1017
+ ["tools:parent", "tools:child"],
1018
+ "complete",
1019
+ null,
1020
+ 2,
1021
+ ),
1022
+ ],
1023
+ ]);
1024
+ hook.rerender();
1025
+
1026
+ await waitFor(() =>
1027
+ expect(
1028
+ messagesOf(hook.result.current, "task-child")?.[0]?.metadata?.timing,
1029
+ ).toMatchObject({ totalChunks: 2, toolCallCount: 0 }),
1030
+ );
1031
+ const timing = messagesOf(hook.result.current, "task-child")?.[0]?.metadata
1032
+ ?.timing;
1033
+ expect(timing?.tokenCount).toBeGreaterThan(0);
1034
+ expect(timing?.totalStreamTime).toBeGreaterThanOrEqual(0);
1035
+ });
1036
+
1037
+ it("gives a task seeded from a checkpoint no wall clock", async () => {
1038
+ const parentMessage: LangChainBaseMessage = {
1039
+ id: "parent-ai",
1040
+ _getType: () => "ai",
1041
+ content: "delegating",
1042
+ tool_calls: [{ id: "task-child", name: "task", args: {} }],
1043
+ };
1044
+ const seeded = new Date(2_000);
1045
+ const stream = createStream(
1046
+ new Map([
1047
+ ["task-parent", subagent("task-parent", ["tools:parent"])],
1048
+ [
1049
+ "task-child",
1050
+ subagent(
1051
+ "task-child",
1052
+ ["tools:parent", "tools:child"],
1053
+ "complete",
1054
+ "task-parent",
1055
+ 2,
1056
+ seeded,
1057
+ seeded,
1058
+ ),
1059
+ ],
1060
+ ]),
1061
+ new Map([
1062
+ ["tools:parent", createStore([parentMessage])],
1063
+ [
1064
+ "tools:parent/tools:child",
1065
+ createStore([message("child-ai", "ai", "child answer")]),
1066
+ ],
1067
+ ]),
1068
+ );
1069
+ const hook = renderHook(() =>
1070
+ useSubagentTranscripts(stream as never, noUIMessages),
1071
+ );
1072
+
1073
+ await waitFor(() =>
1074
+ expect(hook.result.current.has("task-child")).toBe(true),
1075
+ );
1076
+ const taskCall = messagesOf(
1077
+ hook.result.current,
1078
+ "task-parent",
1079
+ )?.[0]?.content.find((part) => part.type === "tool-call");
1080
+
1081
+ expect(taskCall).toMatchObject({ toolCallId: "task-child" });
1082
+ expect(taskCall).not.toHaveProperty("timing");
1083
+ });
1084
+
1085
+ it("keeps the wall clock of a task whose namespace resolves after it finished", async () => {
1086
+ const stream = createStream(
1087
+ new Map([
1088
+ [
1089
+ "task-one",
1090
+ subagent(
1091
+ "task-one",
1092
+ ["tools:task-one"],
1093
+ "running",
1094
+ null,
1095
+ 1,
1096
+ new Date(1_000),
1097
+ null,
1098
+ ),
1099
+ ],
1100
+ ]),
1101
+ new Map([
1102
+ ["tools:task-one", createStore([message("one-ai", "ai", "partial")])],
1103
+ ["tools:promoted", createStore([message("one-ai", "ai", "answer")])],
1104
+ ]),
1105
+ );
1106
+ const hook = renderHook(() =>
1107
+ useSubagentTranscripts(stream as never, noUIMessages),
1108
+ );
1109
+
1110
+ await waitFor(() =>
1111
+ expect(hook.result.current.get("task-one")?.timing).toMatchObject({
1112
+ startedAt: 1_000,
1113
+ }),
1114
+ );
1115
+
1116
+ stream.subagents = new Map([
1117
+ [
1118
+ "task-one",
1119
+ subagent(
1120
+ "task-one",
1121
+ ["tools:promoted"],
1122
+ "complete",
1123
+ null,
1124
+ 1,
1125
+ new Date(1_000),
1126
+ new Date(6_000),
1127
+ ),
1128
+ ],
1129
+ ]);
1130
+ hook.rerender();
1131
+
1132
+ await waitFor(() =>
1133
+ expect(hook.result.current.get("task-one")?.timing).toMatchObject({
1134
+ startedAt: 1_000,
1135
+ completedAt: 6_000,
1136
+ }),
1137
+ );
1138
+ });
1139
+
1140
+ it("keeps finalized nested timing when the namespace resolves after completion", async () => {
1141
+ const placeholderStore = createStore([message("one-ai", "ai", "partial")]);
1142
+ const stream = createStream(
1143
+ new Map([["task-one", subagent("task-one", ["tools:task-one"])]]),
1144
+ new Map([
1145
+ ["tools:task-one", placeholderStore],
1146
+ ["tools:promoted", createStore([message("one-ai", "ai", "answer")])],
1147
+ ]),
1148
+ );
1149
+ const hook = renderHook(() =>
1150
+ useSubagentTranscripts(stream as never, noUIMessages),
1151
+ );
1152
+
1153
+ await waitFor(() =>
1154
+ expect(messagesOf(hook.result.current, "task-one")).toHaveLength(1),
1155
+ );
1156
+ await act(async () => {
1157
+ placeholderStore.setSnapshot([message("one-ai", "ai", "partial answer")]);
1158
+ });
1159
+
1160
+ stream.subagents = new Map([
1161
+ ["task-one", subagent("task-one", ["tools:task-one"], "complete")],
1162
+ ]);
1163
+ hook.rerender();
1164
+ await waitFor(() =>
1165
+ expect(
1166
+ messagesOf(hook.result.current, "task-one")?.[0]?.metadata?.timing,
1167
+ ).toBeDefined(),
1168
+ );
1169
+
1170
+ stream.subagents = new Map([
1171
+ ["task-one", subagent("task-one", ["tools:promoted"], "complete")],
1172
+ ]);
1173
+ hook.rerender();
1174
+
1175
+ await waitFor(() =>
1176
+ expect(
1177
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
1178
+ ).toMatchObject([{ type: "text", text: "answer" }]),
1179
+ );
1180
+ expect(
1181
+ messagesOf(hook.result.current, "task-one")?.[0]?.metadata?.timing,
1182
+ ).toBeDefined();
1183
+ });
1184
+
1185
+ it("keeps counting a streaming message across a namespace rebind", async () => {
1186
+ const placeholderStore = createStore([message("one-ai", "ai", "partial")]);
1187
+ const promotedStore = createStore([
1188
+ message("one-ai", "ai", "partial answer"),
1189
+ ]);
1190
+ const stream = createStream(
1191
+ new Map([["task-one", subagent("task-one", ["tools:task-one"])]]),
1192
+ new Map([
1193
+ ["tools:task-one", placeholderStore],
1194
+ ["tools:promoted", promotedStore],
1195
+ ]),
1196
+ );
1197
+ const hook = renderHook(() =>
1198
+ useSubagentTranscripts(stream as never, noUIMessages),
1199
+ );
1200
+
1201
+ await waitFor(() =>
1202
+ expect(messagesOf(hook.result.current, "task-one")).toHaveLength(1),
1203
+ );
1204
+
1205
+ stream.subagents = new Map([
1206
+ ["task-one", subagent("task-one", ["tools:promoted"])],
1207
+ ]);
1208
+ hook.rerender();
1209
+ await waitFor(() =>
1210
+ expect(
1211
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
1212
+ ).toMatchObject([{ type: "text", text: "partial answer" }]),
1213
+ );
1214
+
1215
+ stream.subagents = new Map([
1216
+ ["task-one", subagent("task-one", ["tools:promoted"], "complete")],
1217
+ ]);
1218
+ hook.rerender();
1219
+
1220
+ await waitFor(() =>
1221
+ expect(
1222
+ messagesOf(hook.result.current, "task-one")?.[0]?.metadata?.timing,
1223
+ ).toMatchObject({ totalChunks: 2 }),
1224
+ );
1225
+ });
1226
+
1227
+ it("finalizes nested timing when one update both promotes and completes", async () => {
1228
+ const placeholderStore = createStore([message("one-ai", "ai", "partial")]);
1229
+ const stream = createStream(
1230
+ new Map([["task-one", subagent("task-one", ["tools:task-one"])]]),
1231
+ new Map([
1232
+ ["tools:task-one", placeholderStore],
1233
+ [
1234
+ "tools:promoted",
1235
+ createStore([message("one-ai", "ai", "partial answer")]),
1236
+ ],
1237
+ ]),
1238
+ );
1239
+ const hook = renderHook(() =>
1240
+ useSubagentTranscripts(stream as never, noUIMessages),
1241
+ );
1242
+
1243
+ await waitFor(() =>
1244
+ expect(messagesOf(hook.result.current, "task-one")).toHaveLength(1),
1245
+ );
1246
+
1247
+ stream.subagents = new Map([
1248
+ ["task-one", subagent("task-one", ["tools:promoted"], "complete")],
1249
+ ]);
1250
+ hook.rerender();
1251
+
1252
+ await waitFor(() =>
1253
+ expect(
1254
+ messagesOf(hook.result.current, "task-one")?.[0]?.metadata?.timing,
1255
+ ).toMatchObject({ totalChunks: 2 }),
1256
+ );
1257
+ });
1258
+ });