@assistant-ui/react-langchain 0.0.31 → 0.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/attachSubagentTranscripts.d.ts +19 -12
  2. package/dist/attachSubagentTranscripts.d.ts.map +1 -1
  3. package/dist/attachSubagentTranscripts.js +8 -4
  4. package/dist/attachSubagentTranscripts.js.map +1 -1
  5. package/dist/convertMessages.d.ts +8 -11
  6. package/dist/convertMessages.d.ts.map +1 -1
  7. package/dist/convertMessages.js +53 -15
  8. package/dist/convertMessages.js.map +1 -1
  9. package/dist/converter.d.ts +137 -138
  10. package/dist/converter.d.ts.map +1 -1
  11. package/dist/converter.js +2 -1
  12. package/dist/converter.js.map +1 -1
  13. package/dist/findForkCheckpointInHistory.d.ts +17 -19
  14. package/dist/findForkCheckpointInHistory.d.ts.map +1 -1
  15. package/dist/hooks.d.ts +19 -22
  16. package/dist/hooks.d.ts.map +1 -1
  17. package/dist/index.d.ts +7 -8
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/resolveForkCheckpoint.d.ts +5 -7
  20. package/dist/resolveForkCheckpoint.d.ts.map +1 -1
  21. package/dist/runtimeExtras.d.ts +2 -5
  22. package/dist/runtimeExtras.d.ts.map +1 -1
  23. package/dist/sdkIdentity.d.ts +2 -5
  24. package/dist/sdkIdentity.d.ts.map +1 -1
  25. package/dist/sdkIdentity.js +1 -1
  26. package/dist/streamingTiming.d.ts +4 -7
  27. package/dist/streamingTiming.d.ts.map +1 -1
  28. package/dist/subagentMessagesProjection.d.ts +17 -0
  29. package/dist/subagentMessagesProjection.d.ts.map +1 -0
  30. package/dist/subagentMessagesProjection.js +40 -0
  31. package/dist/subagentMessagesProjection.js.map +1 -0
  32. package/dist/types.d.ts +106 -109
  33. package/dist/types.d.ts.map +1 -1
  34. package/dist/uiMessages.d.ts +37 -10
  35. package/dist/uiMessages.d.ts.map +1 -1
  36. package/dist/uiMessages.js +55 -5
  37. package/dist/uiMessages.js.map +1 -1
  38. package/dist/useStreamRuntime.d.ts +9 -12
  39. package/dist/useStreamRuntime.d.ts.map +1 -1
  40. package/dist/useStreamRuntime.js +102 -28
  41. package/dist/useStreamRuntime.js.map +1 -1
  42. package/dist/useSubagentTranscripts.d.ts +5 -8
  43. package/dist/useSubagentTranscripts.d.ts.map +1 -1
  44. package/dist/useSubagentTranscripts.js +129 -34
  45. package/dist/useSubagentTranscripts.js.map +1 -1
  46. package/package.json +16 -11
  47. package/src/attachSubagentTranscripts.test.ts +13 -5
  48. package/src/attachSubagentTranscripts.ts +26 -7
  49. package/src/convertMessages.test.ts +69 -0
  50. package/src/convertMessages.ts +46 -8
  51. package/src/converter.ts +6 -0
  52. package/src/subagentMessagesProjection.test.ts +175 -0
  53. package/src/subagentMessagesProjection.ts +50 -0
  54. package/src/uiMessages.test.ts +149 -0
  55. package/src/uiMessages.ts +79 -4
  56. package/src/useStreamRuntime.test.tsx +212 -0
  57. package/src/useStreamRuntime.ts +202 -50
  58. package/src/useStreamRuntime.voice.test.tsx +712 -0
  59. package/src/useSubagentTranscripts.test.tsx +795 -52
  60. package/src/useSubagentTranscripts.ts +269 -50
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { act, renderHook, waitFor } from "@testing-library/react";
4
4
  import { describe, expect, it, vi } from "vitest";
5
+ import type { ThreadMessage } from "@assistant-ui/core";
5
6
  import type { LangChainBaseMessage, UIMessage } from "./types";
6
7
 
7
8
  const { streamController } = vi.hoisted(() => ({
@@ -17,15 +18,15 @@ import {
17
18
  useSubagentTranscripts,
18
19
  } from "./useSubagentTranscripts";
19
20
 
20
- type FakeStore = {
21
- getSnapshot(): LangChainBaseMessage[];
21
+ type FakeStore<T> = {
22
+ getSnapshot(): T;
22
23
  subscribe(listener: () => void): () => void;
23
24
  notify(): void;
24
- setSnapshot(messages: LangChainBaseMessage[]): void;
25
+ setSnapshot(snapshot: T): void;
25
26
  };
26
27
 
27
- const createStore = (messages: LangChainBaseMessage[] = []): FakeStore => {
28
- let snapshot = messages;
28
+ const createFakeStore = <T,>(initial: T): FakeStore<T> => {
29
+ let snapshot = initial;
29
30
  const listeners = new Set<() => void>();
30
31
  return {
31
32
  getSnapshot: () => snapshot,
@@ -36,13 +37,27 @@ const createStore = (messages: LangChainBaseMessage[] = []): FakeStore => {
36
37
  notify() {
37
38
  for (const listener of listeners) listener();
38
39
  },
39
- setSnapshot(messages) {
40
- snapshot = messages;
40
+ setSnapshot(next) {
41
+ snapshot = next;
41
42
  for (const listener of listeners) listener();
42
43
  },
43
44
  };
44
45
  };
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
+
46
61
  const message = (
47
62
  id: string,
48
63
  type: "human" | "ai",
@@ -59,25 +74,42 @@ const subagent = (
59
74
  status: "running" | "complete" | "error" = "running",
60
75
  parentId: string | null = null,
61
76
  depth = 1,
77
+ startedAt = new Date(1_000),
78
+ completedAt: Date | null = status === "running" ? null : new Date(4_000),
62
79
  ) => ({
63
80
  id,
64
81
  namespace,
65
82
  status,
66
83
  parentId,
67
84
  depth,
85
+ startedAt,
86
+ completedAt,
68
87
  });
69
88
 
89
+ const messagesOf = (
90
+ transcripts: ReadonlyMap<string, { messages: readonly ThreadMessage[] }>,
91
+ id: string,
92
+ ) => transcripts.get(id)?.messages;
93
+
70
94
  const createStream = (
71
95
  subagents: ReadonlyMap<string, ReturnType<typeof subagent>>,
72
- stores: Map<string, FakeStore>,
96
+ stores: Map<string, FakeStore<LangChainBaseMessage[]>>,
97
+ uiStores = new Map<string, FakeStore<UIChannelEvent[]>>(),
73
98
  ) => {
74
99
  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
- });
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
+ );
81
113
  const resolveSubagentNamespace = vi.fn(async () => {});
82
114
  return {
83
115
  subagents,
@@ -87,6 +119,7 @@ const createStream = (
87
119
  },
88
120
  acquire,
89
121
  releases,
122
+ uiReleases,
90
123
  resolveSubagentNamespace,
91
124
  };
92
125
  };
@@ -101,6 +134,34 @@ const chart = (id: string, messageId: string): UIMessage => ({
101
134
  metadata: { message_id: messageId },
102
135
  });
103
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
+
104
165
  describe("useSubagentTranscripts", () => {
105
166
  it("acquires each namespace once and releases every projection on unmount", async () => {
106
167
  const stores = new Map([
@@ -122,17 +183,139 @@ describe("useSubagentTranscripts", () => {
122
183
  hook.rerender();
123
184
 
124
185
  expect(stream.acquire).toHaveBeenCalledTimes(2);
125
- expect(stream.resolveSubagentNamespace).toHaveBeenCalledTimes(2);
186
+ expect(stream.resolveSubagentNamespace).not.toHaveBeenCalled();
126
187
  hook.unmount();
127
188
  expect(stream.releases.get("tools:one")).toHaveBeenCalledOnce();
128
189
  expect(stream.releases.get("tools:two")).toHaveBeenCalledOnce();
129
190
  });
130
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
+
131
311
  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()]]);
312
+ const stores = new Map([["tools:task-one", createStore()]]);
133
313
  const stream = createStream(
134
314
  new Map([
135
- ["task-one", subagent("task-one", ["tools:one"], "running", null, 16)],
315
+ [
316
+ "task-one",
317
+ subagent("task-one", ["tools:task-one"], "running", null, 16),
318
+ ],
136
319
  ]),
137
320
  stores,
138
321
  );
@@ -150,7 +333,7 @@ describe("useSubagentTranscripts", () => {
150
333
  hook.rerender();
151
334
 
152
335
  await waitFor(() =>
153
- expect(stream.releases.get("tools:one")).toHaveBeenCalledOnce(),
336
+ expect(stream.releases.get("tools:task-one")).toHaveBeenCalledOnce(),
154
337
  );
155
338
  expect(stream.acquire).toHaveBeenCalledOnce();
156
339
  expect(stream.resolveSubagentNamespace).toHaveBeenCalledOnce();
@@ -164,9 +347,9 @@ describe("useSubagentTranscripts", () => {
164
347
  message("promoted-ai", "ai", "promoted"),
165
348
  ]);
166
349
  const stream = createStream(
167
- new Map([["task-one", subagent("task-one", ["tools:placeholder"])]]),
350
+ new Map([["task-one", subagent("task-one", ["tools:task-one"])]]),
168
351
  new Map([
169
- ["tools:placeholder", placeholderStore],
352
+ ["tools:task-one", placeholderStore],
170
353
  ["tools:promoted", promotedStore],
171
354
  ]),
172
355
  );
@@ -175,9 +358,9 @@ describe("useSubagentTranscripts", () => {
175
358
  );
176
359
 
177
360
  await waitFor(() =>
178
- expect(hook.result.current.get("task-one")?.[0]?.content).toMatchObject([
179
- { type: "text", text: "placeholder" },
180
- ]),
361
+ expect(
362
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
363
+ ).toMatchObject([{ type: "text", text: "placeholder" }]),
181
364
  );
182
365
 
183
366
  stream.subagents = new Map([
@@ -186,11 +369,11 @@ describe("useSubagentTranscripts", () => {
186
369
  hook.rerender();
187
370
 
188
371
  await waitFor(() =>
189
- expect(hook.result.current.get("task-one")?.[0]?.content).toMatchObject([
190
- { type: "text", text: "promoted" },
191
- ]),
372
+ expect(
373
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
374
+ ).toMatchObject([{ type: "text", text: "promoted" }]),
192
375
  );
193
- expect(stream.releases.get("tools:placeholder")).toHaveBeenCalledOnce();
376
+ expect(stream.releases.get("tools:task-one")).toHaveBeenCalledOnce();
194
377
  expect(stream.acquire).toHaveBeenCalledWith(
195
378
  expect.objectContaining({ namespace: ["tools:promoted"] }),
196
379
  );
@@ -223,9 +406,9 @@ describe("useSubagentTranscripts", () => {
223
406
  });
224
407
 
225
408
  expect(hook.result.current).not.toBe(initial);
226
- expect(hook.result.current.get("task-one")?.[0]?.content).toMatchObject([
227
- { type: "text", text: "two" },
228
- ]);
409
+ expect(
410
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
411
+ ).toMatchObject([{ type: "text", text: "two" }]);
229
412
  });
230
413
 
231
414
  it("keeps unchanged transcript messages when a projection update replaces one", async () => {
@@ -240,15 +423,15 @@ describe("useSubagentTranscripts", () => {
240
423
  );
241
424
 
242
425
  await waitFor(() =>
243
- expect(hook.result.current.get("task-one")).toHaveLength(2),
426
+ expect(messagesOf(hook.result.current, "task-one")).toHaveLength(2),
244
427
  );
245
- const initial = hook.result.current.get("task-one");
428
+ const initial = messagesOf(hook.result.current, "task-one");
246
429
 
247
430
  await act(async () => {
248
431
  store.setSnapshot([question, message("subagent-ai", "ai", "partial")]);
249
432
  });
250
433
 
251
- const updated = hook.result.current.get("task-one");
434
+ const updated = messagesOf(hook.result.current, "task-one");
252
435
  expect(updated).toHaveLength(2);
253
436
  expect(updated).not.toBe(initial);
254
437
  expect(updated?.[0]).toBe(initial?.[0]);
@@ -273,7 +456,9 @@ describe("useSubagentTranscripts", () => {
273
456
  hook.rerender();
274
457
 
275
458
  await waitFor(() =>
276
- expect(hook.result.current.get("task-one")?.[0]?.content).toMatchObject([
459
+ expect(
460
+ messagesOf(hook.result.current, "task-one")?.[0]?.content,
461
+ ).toMatchObject([
277
462
  { type: "text", text: "answer" },
278
463
  { type: "data", name: "chart", data: { points: [1, 2, 3] } },
279
464
  ]),
@@ -309,7 +494,7 @@ describe("useSubagentTranscripts", () => {
309
494
  uiMessagesByParent = new Map([["one-ai", [ui]]]);
310
495
  hook.rerender();
311
496
  await waitFor(() =>
312
- expect(hook.result.current.get("task-one")).not.toBe(
497
+ expect(messagesOf(hook.result.current, "task-one")).not.toBe(
313
498
  initial.get("task-one"),
314
499
  ),
315
500
  );
@@ -332,7 +517,9 @@ describe("useSubagentTranscripts", () => {
332
517
  );
333
518
 
334
519
  await waitFor(() =>
335
- expect(hook.result.current.get("task-one")?.[0]?.status).toMatchObject({
520
+ expect(
521
+ messagesOf(hook.result.current, "task-one")?.[0]?.status,
522
+ ).toMatchObject({
336
523
  type: "running",
337
524
  }),
338
525
  );
@@ -343,7 +530,9 @@ describe("useSubagentTranscripts", () => {
343
530
  hook.rerender();
344
531
 
345
532
  await waitFor(() =>
346
- expect(hook.result.current.get("task-one")?.[0]?.status).toMatchObject({
533
+ expect(
534
+ messagesOf(hook.result.current, "task-one")?.[0]?.status,
535
+ ).toMatchObject({
347
536
  type: "complete",
348
537
  }),
349
538
  );
@@ -368,16 +557,16 @@ describe("useSubagentTranscripts", () => {
368
557
 
369
558
  await waitFor(() => expect(hook.result.current.size).toBe(2));
370
559
  const initial = hook.result.current;
371
- const initialOne = initial.get("task-one");
372
- const initialTwo = initial.get("task-two");
560
+ const initialOne = messagesOf(initial, "task-one");
561
+ const initialTwo = messagesOf(initial, "task-two");
373
562
 
374
563
  await act(async () => {
375
564
  oneStore.setSnapshot([message("one-ai", "ai", "updated")]);
376
565
  });
377
566
 
378
567
  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);
568
+ expect(messagesOf(hook.result.current, "task-one")).not.toBe(initialOne);
569
+ expect(messagesOf(hook.result.current, "task-two")).toBe(initialTwo);
381
570
  });
382
571
 
383
572
  it("nests a child discovered at the same depth as its parent", async () => {
@@ -407,18 +596,19 @@ describe("useSubagentTranscripts", () => {
407
596
  );
408
597
 
409
598
  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");
599
+ const taskCall = messagesOf(
600
+ hook.result.current,
601
+ "task-parent",
602
+ )?.[0]?.content.find((part) => part.type === "tool-call");
413
603
 
414
604
  expect(taskCall).toMatchObject({
415
- messages: hook.result.current.get("task-child"),
605
+ messages: messagesOf(hook.result.current, "task-child"),
416
606
  });
417
607
  });
418
608
 
419
609
  it("stops attaching descendants past sixteen levels of actual nesting", async () => {
420
610
  const subagents = new Map<string, ReturnType<typeof subagent>>();
421
- const stores = new Map<string, FakeStore>();
611
+ const stores = new Map<string, FakeStore<LangChainBaseMessage[]>>();
422
612
  const levels = MAX_SUBAGENT_DEPTH + 2;
423
613
  for (let level = 1; level <= levels; level += 1) {
424
614
  const id = `task-${level}`;
@@ -453,7 +643,7 @@ describe("useSubagentTranscripts", () => {
453
643
  );
454
644
 
455
645
  await waitFor(() => expect(hook.result.current.size).toBe(levels));
456
- let transcript = hook.result.current.get("task-1");
646
+ let transcript = messagesOf(hook.result.current, "task-1");
457
647
  let nested = 1;
458
648
  while (transcript) {
459
649
  const taskCall = transcript[0]?.content.find(
@@ -465,9 +655,10 @@ describe("useSubagentTranscripts", () => {
465
655
  }
466
656
 
467
657
  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");
658
+ const deepest = messagesOf(
659
+ hook.result.current,
660
+ `task-${MAX_SUBAGENT_DEPTH + 1}`,
661
+ )?.[0]?.content.find((part) => part.type === "tool-call");
471
662
  expect(deepest).not.toHaveProperty("messages");
472
663
  });
473
664
 
@@ -504,12 +695,564 @@ describe("useSubagentTranscripts", () => {
504
695
  );
505
696
 
506
697
  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");
698
+ const parentTranscript = messagesOf(hook.result.current, "task-parent");
699
+ const childTranscript = messagesOf(hook.result.current, "task-child");
509
700
  const taskCall = parentTranscript?.[0]?.content.find(
510
701
  (part) => part.type === "tool-call",
511
702
  );
512
703
 
513
704
  expect(taskCall).toMatchObject({ messages: childTranscript });
514
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
+ });
515
1258
  });