@assistant-ui/react-langchain 0.0.32 → 0.0.34

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 (59) hide show
  1. package/LICENSE +1 -1
  2. package/dist/attachSubagentTranscripts.d.ts +13 -14
  3. package/dist/attachSubagentTranscripts.d.ts.map +1 -1
  4. package/dist/convertMessages.d.ts +6 -9
  5. package/dist/convertMessages.d.ts.map +1 -1
  6. package/dist/convertMessages.js +57 -15
  7. package/dist/convertMessages.js.map +1 -1
  8. package/dist/converter.d.ts +128 -128
  9. package/dist/converter.d.ts.map +1 -1
  10. package/dist/converter.js +2 -1
  11. package/dist/converter.js.map +1 -1
  12. package/dist/findForkCheckpointInHistory.d.ts +15 -16
  13. package/dist/findForkCheckpointInHistory.d.ts.map +1 -1
  14. package/dist/hooks.d.ts +5 -7
  15. package/dist/hooks.d.ts.map +1 -1
  16. package/dist/index.d.ts +7 -8
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/resolveForkCheckpoint.d.ts +4 -5
  19. package/dist/resolveForkCheckpoint.d.ts.map +1 -1
  20. package/dist/runtimeExtras.d.ts +1 -3
  21. package/dist/runtimeExtras.d.ts.map +1 -1
  22. package/dist/sdkIdentity.d.ts +1 -3
  23. package/dist/sdkIdentity.d.ts.map +1 -1
  24. package/dist/sdkIdentity.js +1 -1
  25. package/dist/streamingTiming.d.ts +2 -4
  26. package/dist/streamingTiming.d.ts.map +1 -1
  27. package/dist/subagentMessagesProjection.d.ts +17 -0
  28. package/dist/subagentMessagesProjection.d.ts.map +1 -0
  29. package/dist/subagentMessagesProjection.js +40 -0
  30. package/dist/subagentMessagesProjection.js.map +1 -0
  31. package/dist/types.d.ts +100 -103
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/uiMessages.d.ts +18 -6
  34. package/dist/uiMessages.d.ts.map +1 -1
  35. package/dist/uiMessages.js +33 -1
  36. package/dist/uiMessages.js.map +1 -1
  37. package/dist/useStreamRuntime.d.ts +7 -10
  38. package/dist/useStreamRuntime.d.ts.map +1 -1
  39. package/dist/useStreamRuntime.js +100 -26
  40. package/dist/useStreamRuntime.js.map +1 -1
  41. package/dist/useSubagentTranscripts.d.ts +3 -5
  42. package/dist/useSubagentTranscripts.d.ts.map +1 -1
  43. package/dist/useSubagentTranscripts.js +5 -3
  44. package/dist/useSubagentTranscripts.js.map +1 -1
  45. package/package.json +16 -9
  46. package/src/convertMessages.test.ts +153 -0
  47. package/src/convertMessages.ts +54 -8
  48. package/src/converter.ts +6 -0
  49. package/src/subagentMessagesProjection.test.ts +175 -0
  50. package/src/subagentMessagesProjection.ts +50 -0
  51. package/src/tests/settleOutsideAct.ts +15 -0
  52. package/src/uiMessages.test.ts +106 -0
  53. package/src/uiMessages.ts +43 -0
  54. package/src/useStreamRuntime.test.tsx +147 -7
  55. package/src/useStreamRuntime.ts +197 -45
  56. package/src/useStreamRuntime.voice.test.tsx +713 -0
  57. package/src/useSubagentTranscripts.fast-refresh.test.tsx +152 -0
  58. package/src/useSubagentTranscripts.test.tsx +2 -0
  59. package/src/useSubagentTranscripts.ts +5 -7
@@ -0,0 +1,713 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, render, renderHook, waitFor } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type {
6
+ AppendMessage,
7
+ AssistantRuntime,
8
+ RealtimeVoiceAdapter,
9
+ RemoteThreadListAdapter,
10
+ ThreadMessage,
11
+ } from "@assistant-ui/core";
12
+ import { getThreadMessageText } from "@assistant-ui/core/internal";
13
+ import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
14
+ import type { LangChainBaseMessage } from "./types";
15
+ import { useStreamRuntime } from "./useStreamRuntime";
16
+ import { settleOutsideAct } from "./tests/settleOutsideAct";
17
+
18
+ const { mockUseStream, streamController } = vi.hoisted(() => ({
19
+ mockUseStream: vi.fn(),
20
+ streamController: Symbol("STREAM_CONTROLLER"),
21
+ }));
22
+
23
+ vi.mock("@langchain/react", async (importOriginal) => ({
24
+ ...(await importOriginal<typeof import("@langchain/react")>()),
25
+ STREAM_CONTROLLER: streamController,
26
+ useChannel: vi.fn(() => []),
27
+ useStream: mockUseStream,
28
+ }));
29
+
30
+ const createMockStream = (messages: LangChainBaseMessage[] = []) => ({
31
+ messages,
32
+ isLoading: false,
33
+ isThreadLoading: false,
34
+ values: {},
35
+ interrupts: [],
36
+ toolCalls: [],
37
+ subagents: new Map(),
38
+ subgraphs: [],
39
+ error: undefined,
40
+ submit: vi.fn(async (_values: Record<string, unknown>) => {}),
41
+ respond: vi.fn(),
42
+ respondAll: vi.fn(),
43
+ interrupt: vi.fn(),
44
+ stop: vi.fn(),
45
+ client: {},
46
+ [streamController]: {
47
+ messageMetadataStore: { getSnapshot: vi.fn() },
48
+ resolveSubagentNamespace: vi.fn(async () => {}),
49
+ registry: { acquire: vi.fn() },
50
+ },
51
+ });
52
+
53
+ const createVoiceAdapter = (
54
+ sendText?: RealtimeVoiceAdapter.Session["sendText"],
55
+ ) => {
56
+ let transcriptCallback:
57
+ | ((transcript: RealtimeVoiceAdapter.TranscriptItem) => void)
58
+ | undefined;
59
+ const session: RealtimeVoiceAdapter.Session = {
60
+ status: { type: "running" },
61
+ isMuted: false,
62
+ disconnect: vi.fn(),
63
+ mute: vi.fn(),
64
+ unmute: vi.fn(),
65
+ ...(sendText && { sendText }),
66
+ onStatusChange: () => () => {},
67
+ onTranscript: (callback) => {
68
+ transcriptCallback = callback;
69
+ return () => {
70
+ transcriptCallback = undefined;
71
+ };
72
+ },
73
+ onModeChange: () => () => {},
74
+ onVolumeChange: () => () => {},
75
+ };
76
+ return {
77
+ adapter: { connect: () => session } satisfies RealtimeVoiceAdapter,
78
+ emitTranscript: (transcript: RealtimeVoiceAdapter.TranscriptItem) =>
79
+ transcriptCallback?.(transcript),
80
+ };
81
+ };
82
+
83
+ const renderVoiceRuntime = async (
84
+ stream: ReturnType<typeof createMockStream>,
85
+ voice: ReturnType<typeof createVoiceAdapter>,
86
+ ) => {
87
+ mockUseStream.mockReturnValue(stream);
88
+ const rendered = renderHook(() =>
89
+ useStreamRuntime({
90
+ apiUrl: "/api",
91
+ assistantId: "agent",
92
+ adapters: { voice: voice.adapter },
93
+ }),
94
+ );
95
+ render(
96
+ <AssistantRuntimeProvider runtime={rendered.result.current}>
97
+ {null}
98
+ </AssistantRuntimeProvider>,
99
+ );
100
+ await waitFor(() =>
101
+ expect(rendered.result.current.thread.getState().capabilities.voice).toBe(
102
+ true,
103
+ ),
104
+ );
105
+ return rendered;
106
+ };
107
+
108
+ const rowsOf = (runtime: AssistantRuntime) =>
109
+ runtime.thread
110
+ .getState()
111
+ .messages.map((message) => [
112
+ message.id,
113
+ message.role,
114
+ getThreadMessageText(message),
115
+ message.metadata.modality,
116
+ ]);
117
+
118
+ const spokenTurns = [
119
+ { role: "user", text: "Spoken question" },
120
+ { role: "assistant", text: "Spoken answer" },
121
+ ] as const;
122
+
123
+ const speak = (
124
+ runtime: AssistantRuntime,
125
+ voice: ReturnType<typeof createVoiceAdapter>,
126
+ turns: readonly Pick<
127
+ RealtimeVoiceAdapter.TranscriptItem,
128
+ "role" | "text"
129
+ >[] = spokenTurns,
130
+ ) => {
131
+ const spoken: ThreadMessage[] = [];
132
+ act(() => {
133
+ runtime.thread.connectVoice();
134
+ for (const turn of turns) {
135
+ voice.emitTranscript({ ...turn, isFinal: true });
136
+ spoken.push(runtime.thread.getState().messages.at(-1)!);
137
+ }
138
+ });
139
+ return spoken;
140
+ };
141
+
142
+ describe("useStreamRuntime voice transcripts", () => {
143
+ it("keeps finalized transcripts under the runtime ids through disconnect and stream updates", async () => {
144
+ const stream = createMockStream();
145
+ const voice = createVoiceAdapter();
146
+ const { result, rerender } = await renderVoiceRuntime(stream, voice);
147
+ const [user, assistant] = speak(result.current, voice);
148
+ const spokenRows = [
149
+ [user!.id, "user", "Spoken question", "voice"],
150
+ [assistant!.id, "assistant", "Spoken answer", "voice"],
151
+ ];
152
+
153
+ expect(rowsOf(result.current)).toEqual(spokenRows);
154
+ act(() => result.current.thread.disconnectVoice());
155
+ expect(rowsOf(result.current)).toEqual(spokenRows);
156
+
157
+ stream.messages = [
158
+ {
159
+ id: "server-message",
160
+ _getType: () => "human",
161
+ content: "Spoken question",
162
+ },
163
+ ];
164
+ rerender();
165
+
166
+ expect(rowsOf(result.current)).toEqual([
167
+ ["server-message", "user", "Spoken question", undefined],
168
+ ...spokenRows,
169
+ ]);
170
+ expect(stream.submit).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it("submits transcripts once after cancellation stubs and before the new human message", async () => {
174
+ const stream = createMockStream([
175
+ {
176
+ id: "pending-tool",
177
+ _getType: () => "ai",
178
+ content: "",
179
+ tool_calls: [{ id: "call-1", name: "lookup", args: {} }],
180
+ status: { type: "incomplete", reason: "cancelled" },
181
+ },
182
+ ]);
183
+ const voice = createVoiceAdapter();
184
+ const { result } = await renderVoiceRuntime(stream, voice);
185
+ const [user, assistant] = speak(result.current, voice);
186
+ act(() => result.current.thread.disconnectVoice());
187
+
188
+ await act(async () => {
189
+ await result.current.thread.append("Next question");
190
+ });
191
+ await act(async () => {
192
+ await result.current.thread.append("Another question");
193
+ });
194
+
195
+ const cancellation = {
196
+ type: "tool",
197
+ name: "lookup",
198
+ tool_call_id: "call-1",
199
+ content: JSON.stringify({ cancelled: true }),
200
+ status: "error",
201
+ };
202
+ expect(stream.submit).toHaveBeenCalledTimes(2);
203
+ expect(stream.submit.mock.calls[0]![0]).toEqual({
204
+ messages: [
205
+ cancellation,
206
+ {
207
+ id: user!.id,
208
+ type: "human",
209
+ content: "Spoken question",
210
+ additional_kwargs: { modality: "voice" },
211
+ },
212
+ {
213
+ id: assistant!.id,
214
+ type: "ai",
215
+ content: "Spoken answer",
216
+ additional_kwargs: { modality: "voice" },
217
+ },
218
+ { id: expect.any(String), type: "human", content: "Next question" },
219
+ ],
220
+ });
221
+ expect(stream.submit.mock.calls[1]![0]).toEqual({
222
+ messages: [
223
+ cancellation,
224
+ { id: expect.any(String), type: "human", content: "Another question" },
225
+ ],
226
+ });
227
+ expect(rowsOf(result.current).slice(1, 3)).toEqual([
228
+ [user!.id, "user", "Spoken question", "voice"],
229
+ [assistant!.id, "assistant", "Spoken answer", "voice"],
230
+ ]);
231
+ });
232
+
233
+ it("reconciles echoed transcripts by id and keeps them spoken", async () => {
234
+ const stream = createMockStream();
235
+ const voice = createVoiceAdapter();
236
+ const { result, rerender } = await renderVoiceRuntime(stream, voice);
237
+ const [user, assistant] = speak(result.current, voice);
238
+ act(() => result.current.thread.disconnectVoice());
239
+ await act(async () => {
240
+ await result.current.thread.append("Next question");
241
+ });
242
+ const typed = result.current.thread.getState().messages.at(-1)!;
243
+ stream.messages = [
244
+ {
245
+ id: user!.id,
246
+ _getType: () => "human",
247
+ content: "Spoken question",
248
+ additional_kwargs: { modality: "voice" },
249
+ },
250
+ {
251
+ id: assistant!.id,
252
+ _getType: () => "ai",
253
+ content: "Spoken answer",
254
+ additional_kwargs: { modality: "voice" },
255
+ },
256
+ { id: typed.id, _getType: () => "human", content: "Next question" },
257
+ { id: "reply", _getType: () => "ai", content: "Typed reply" },
258
+ ];
259
+ rerender();
260
+
261
+ expect(rowsOf(result.current)).toEqual([
262
+ [user!.id, "user", "Spoken question", "voice"],
263
+ [assistant!.id, "assistant", "Spoken answer", "voice"],
264
+ [typed.id, "user", "Next question", undefined],
265
+ ["reply", "assistant", "Typed reply", undefined],
266
+ ]);
267
+ });
268
+
269
+ it("keeps an assistant transcript separate from the preceding assistant message", async () => {
270
+ const stream = createMockStream([
271
+ {
272
+ id: "greeting",
273
+ _getType: () => "ai",
274
+ content: "Hello",
275
+ },
276
+ ]);
277
+ const voice = createVoiceAdapter();
278
+ const { result } = await renderVoiceRuntime(stream, voice);
279
+ const [spoken] = speak(result.current, voice, [
280
+ { role: "assistant", text: "Spoken follow up" },
281
+ ]);
282
+ const expected = [
283
+ ["greeting", "assistant", "Hello", undefined],
284
+ [spoken!.id, "assistant", "Spoken follow up", "voice"],
285
+ ];
286
+
287
+ expect(rowsOf(result.current)).toEqual(expected);
288
+ act(() => result.current.thread.disconnectVoice());
289
+ expect(rowsOf(result.current)).toEqual(expected);
290
+ });
291
+
292
+ it("submits text typed into a voice session as a plain human message", async () => {
293
+ const stream = createMockStream();
294
+ const sendText = vi.fn(async () => {});
295
+ const voice = createVoiceAdapter(sendText);
296
+ const { result } = await renderVoiceRuntime(stream, voice);
297
+
298
+ act(() => result.current.thread.connectVoice());
299
+ await act(async () => {
300
+ await result.current.thread.append("Typed during the call");
301
+ });
302
+ const typed = result.current.thread.getState().messages.at(-1)!;
303
+ act(() => result.current.thread.disconnectVoice());
304
+
305
+ expect(sendText).toHaveBeenCalledExactlyOnceWith("Typed during the call");
306
+ expect(rowsOf(result.current)).toEqual([
307
+ [typed.id, "user", "Typed during the call", undefined],
308
+ ]);
309
+ expect(stream.submit).not.toHaveBeenCalled();
310
+ await act(async () => {
311
+ await result.current.thread.append("After the call");
312
+ });
313
+
314
+ expect(stream.submit.mock.calls[0]![0]).toEqual({
315
+ messages: [
316
+ { id: typed.id, type: "human", content: "Typed during the call" },
317
+ { id: expect.any(String), type: "human", content: "After the call" },
318
+ ],
319
+ });
320
+ });
321
+
322
+ it("includes only preceding unsent transcripts in a staged reload and retains them until echo", async () => {
323
+ const stream = createMockStream();
324
+ const voice = createVoiceAdapter();
325
+ const { result, rerender } = await renderVoiceRuntime(stream, voice);
326
+ const [user, assistant] = speak(result.current, voice);
327
+ act(() => result.current.thread.disconnectVoice());
328
+ await act(async () => {
329
+ await result.current.thread.append({
330
+ role: "user",
331
+ content: [{ type: "text", text: "Staged question" }],
332
+ startRun: false,
333
+ });
334
+ });
335
+ const parent = result.current.thread.getState().messages.at(-1)!;
336
+ const [later] = speak(result.current, voice, [
337
+ { role: "user", text: "Later transcript" },
338
+ ]);
339
+ act(() => result.current.thread.disconnectVoice());
340
+
341
+ await act(async () => {
342
+ await result.current.thread.startRun({
343
+ parentId: parent.id,
344
+ sourceId: null,
345
+ runConfig: {},
346
+ });
347
+ });
348
+
349
+ expect(stream.submit.mock.calls[0]![0]).toEqual({
350
+ messages: [
351
+ {
352
+ id: user!.id,
353
+ type: "human",
354
+ content: "Spoken question",
355
+ additional_kwargs: { modality: "voice" },
356
+ },
357
+ {
358
+ id: assistant!.id,
359
+ type: "ai",
360
+ content: "Spoken answer",
361
+ additional_kwargs: { modality: "voice" },
362
+ },
363
+ { id: parent.id, type: "human", content: "Staged question" },
364
+ ],
365
+ });
366
+ stream.messages = [];
367
+ rerender();
368
+ expect(rowsOf(result.current)).toEqual([
369
+ [user!.id, "user", "Spoken question", "voice"],
370
+ [assistant!.id, "assistant", "Spoken answer", "voice"],
371
+ [later!.id, "user", "Later transcript", "voice"],
372
+ ]);
373
+
374
+ await act(async () => {
375
+ await result.current.thread.append("Next question");
376
+ });
377
+ expect(stream.submit.mock.calls[1]![0]).toEqual({
378
+ messages: [
379
+ {
380
+ id: later!.id,
381
+ type: "human",
382
+ content: "Later transcript",
383
+ additional_kwargs: { modality: "voice" },
384
+ },
385
+ { id: expect.any(String), type: "human", content: "Next question" },
386
+ ],
387
+ });
388
+ });
389
+
390
+ it("does not resend submitted transcripts when reloading an unacknowledged staged parent", async () => {
391
+ const stream = createMockStream();
392
+ const voice = createVoiceAdapter();
393
+ const { result } = await renderVoiceRuntime(stream, voice);
394
+ speak(result.current, voice);
395
+ act(() => result.current.thread.disconnectVoice());
396
+ await act(async () => {
397
+ await result.current.thread.append("Next question");
398
+ });
399
+ const parent = result.current.thread.getState().messages.at(-1)!;
400
+ await act(async () => {
401
+ await result.current.thread.startRun({
402
+ parentId: parent.id,
403
+ sourceId: null,
404
+ runConfig: {},
405
+ });
406
+ });
407
+
408
+ expect(stream.submit.mock.calls[1]![0]).toEqual({
409
+ messages: [{ id: parent.id, type: "human", content: "Next question" }],
410
+ });
411
+ });
412
+ });
413
+
414
+ const makeThreadListAdapter = (): RemoteThreadListAdapter => ({
415
+ list: vi.fn(async () => ({
416
+ threads: [
417
+ {
418
+ status: "regular" as const,
419
+ remoteId: "thread-a",
420
+ externalId: "thread-a",
421
+ },
422
+ ],
423
+ })),
424
+ initialize: vi.fn(async () => ({
425
+ remoteId: "thread-a",
426
+ externalId: "thread-a",
427
+ })),
428
+ rename: vi.fn(async () => {}),
429
+ archive: vi.fn(async () => {}),
430
+ unarchive: vi.fn(async () => {}),
431
+ delete: vi.fn(async () => {}),
432
+ generateTitle: vi.fn(async () => new ReadableStream()),
433
+ fetch: vi.fn(async (threadId) => ({
434
+ status: "regular" as const,
435
+ remoteId: threadId,
436
+ externalId: threadId,
437
+ })),
438
+ });
439
+
440
+ const baseMessage = (
441
+ id: string,
442
+ type: "human" | "ai",
443
+ content: string,
444
+ additional_kwargs?: Record<string, unknown>,
445
+ ): LangChainBaseMessage => ({
446
+ id,
447
+ _getType: () => type,
448
+ content,
449
+ ...(additional_kwargs && { additional_kwargs }),
450
+ });
451
+
452
+ const renderThreadRuntime = async (
453
+ stream: ReturnType<typeof createMockStream>,
454
+ voice: ReturnType<typeof createVoiceAdapter>,
455
+ checkpoints: { id: string; messages: LangChainBaseMessage[] }[],
456
+ ) => {
457
+ stream.client = {
458
+ threads: {
459
+ getHistory: vi.fn(async () =>
460
+ checkpoints.map(({ id, messages }) => ({
461
+ values: { messages },
462
+ checkpoint: { checkpoint_id: id },
463
+ })),
464
+ ),
465
+ },
466
+ };
467
+ mockUseStream.mockReturnValue(stream);
468
+ const rendered = renderHook(() =>
469
+ useStreamRuntime({
470
+ apiUrl: "/api",
471
+ assistantId: "agent",
472
+ adapters: { voice: voice.adapter },
473
+ unstable_threadListAdapter: makeThreadListAdapter(),
474
+ } as never),
475
+ );
476
+ render(
477
+ <AssistantRuntimeProvider runtime={rendered.result.current}>
478
+ {null}
479
+ </AssistantRuntimeProvider>,
480
+ );
481
+ await settleOutsideAct(() =>
482
+ rendered.result.current.threads.switchToThread("thread-a"),
483
+ );
484
+ await waitFor(() =>
485
+ expect(rendered.result.current.thread.getState().capabilities.voice).toBe(
486
+ true,
487
+ ),
488
+ );
489
+ return rendered;
490
+ };
491
+
492
+ describe("useStreamRuntime voice transcripts across forks", () => {
493
+ it("forks an edit of the turn after persisted transcripts from before them and resubmits them", async () => {
494
+ const spokenUser = baseMessage("spoken-user", "human", "Spoken question", {
495
+ modality: "voice",
496
+ });
497
+ const spokenAi = baseMessage("spoken-ai", "ai", "Spoken answer", {
498
+ modality: "voice",
499
+ });
500
+ const typed = baseMessage("typed", "human", "Typed question");
501
+ const reply = baseMessage("reply", "ai", "Reply");
502
+ const stream = createMockStream([spokenUser, spokenAi, typed, reply]);
503
+ const { result } = await renderThreadRuntime(stream, createVoiceAdapter(), [
504
+ { id: "before-voice", messages: [] },
505
+ ]);
506
+
507
+ await act(async () => {
508
+ const composer = result.current.thread.getMessageById("typed").composer;
509
+ composer.beginEdit();
510
+ composer.setText("Edited question");
511
+ await composer.send();
512
+ });
513
+
514
+ await waitFor(() => expect(stream.submit).toHaveBeenCalledTimes(1));
515
+ expect(stream.submit).toHaveBeenCalledWith(
516
+ {
517
+ messages: [
518
+ {
519
+ id: "spoken-user",
520
+ type: "human",
521
+ content: "Spoken question",
522
+ additional_kwargs: { modality: "voice" },
523
+ },
524
+ {
525
+ id: "spoken-ai",
526
+ type: "ai",
527
+ content: "Spoken answer",
528
+ additional_kwargs: { modality: "voice" },
529
+ },
530
+ { type: "human", content: "Edited question" },
531
+ ],
532
+ },
533
+ expect.objectContaining({ forkFrom: "before-voice" }),
534
+ );
535
+ });
536
+
537
+ it("regenerates an unsent assistant transcript by sending the user transcript before it", async () => {
538
+ const typed = baseMessage("typed", "human", "Hi");
539
+ const reply = baseMessage("reply", "ai", "Hello");
540
+ const stream = createMockStream([typed, reply]);
541
+ const voice = createVoiceAdapter();
542
+ const { result } = await renderVoiceRuntime(stream, voice);
543
+ const [user, assistant] = speak(result.current, voice);
544
+ act(() => result.current.thread.disconnectVoice());
545
+
546
+ await act(async () => {
547
+ await result.current.thread.getMessageById(assistant!.id).reload();
548
+ });
549
+
550
+ expect(stream.submit).toHaveBeenCalledExactlyOnceWith(
551
+ {
552
+ messages: [
553
+ {
554
+ id: user!.id,
555
+ type: "human",
556
+ content: "Spoken question",
557
+ additional_kwargs: { modality: "voice" },
558
+ },
559
+ ],
560
+ },
561
+ undefined,
562
+ );
563
+ expect(
564
+ result.current.thread.getState().messages.map((message) => message.id),
565
+ ).toEqual(["typed", "reply", user!.id]);
566
+ });
567
+
568
+ it("drops unsent transcripts that an edit of an earlier turn truncates", async () => {
569
+ const typed = baseMessage("typed", "human", "Hi");
570
+ const reply = baseMessage("reply", "ai", "Hello");
571
+ const stream = createMockStream([typed, reply]);
572
+ const voice = createVoiceAdapter();
573
+ const { result } = await renderThreadRuntime(stream, voice, [
574
+ { id: "initial", messages: [] },
575
+ ]);
576
+ speak(result.current, voice);
577
+ act(() => result.current.thread.disconnectVoice());
578
+
579
+ await act(async () => {
580
+ const composer = result.current.thread.getMessageById("typed").composer;
581
+ composer.beginEdit();
582
+ composer.setText("Edited hi");
583
+ await composer.send();
584
+ });
585
+ await waitFor(() => expect(stream.submit).toHaveBeenCalledTimes(1));
586
+ expect(stream.submit).toHaveBeenCalledWith(
587
+ { messages: [{ type: "human", content: "Edited hi" }] },
588
+ expect.objectContaining({ forkFrom: "initial" }),
589
+ );
590
+ expect(
591
+ result.current.thread.getState().messages.map((message) => message.id),
592
+ ).toEqual(["typed", "reply"]);
593
+
594
+ await act(async () => {
595
+ await result.current.thread.append("Next question");
596
+ });
597
+ expect(stream.submit.mock.calls[1]![0]).toEqual({
598
+ messages: [
599
+ { id: expect.any(String), type: "human", content: "Next question" },
600
+ ],
601
+ });
602
+ });
603
+
604
+ it("carries transcripts again after a submit that failed", async () => {
605
+ const stream = createMockStream();
606
+ stream.submit.mockRejectedValueOnce(new Error("offline"));
607
+ const voice = createVoiceAdapter();
608
+ const { result } = await renderVoiceRuntime(stream, voice);
609
+ const [user, assistant] = speak(result.current, voice);
610
+ act(() => result.current.thread.disconnectVoice());
611
+
612
+ const core = (
613
+ result.current.thread as unknown as {
614
+ __internal_threadBinding: {
615
+ getState(): { append(message: AppendMessage): Promise<void> };
616
+ };
617
+ }
618
+ ).__internal_threadBinding.getState();
619
+ await act(async () => {
620
+ await expect(
621
+ core.append({
622
+ role: "user",
623
+ content: [{ type: "text", text: "First" }],
624
+ parentId: assistant!.id,
625
+ sourceId: null,
626
+ runConfig: undefined,
627
+ attachments: [],
628
+ metadata: { custom: {} },
629
+ createdAt: new Date(0),
630
+ }),
631
+ ).rejects.toThrow("offline");
632
+ });
633
+ await act(async () => {
634
+ await result.current.thread.append("Second");
635
+ });
636
+
637
+ expect(stream.submit.mock.calls[1]![0]).toEqual({
638
+ messages: [
639
+ {
640
+ id: user!.id,
641
+ type: "human",
642
+ content: "Spoken question",
643
+ additional_kwargs: { modality: "voice" },
644
+ },
645
+ {
646
+ id: assistant!.id,
647
+ type: "ai",
648
+ content: "Spoken answer",
649
+ additional_kwargs: { modality: "voice" },
650
+ },
651
+ { id: expect.any(String), type: "human", content: "Second" },
652
+ ],
653
+ });
654
+ });
655
+
656
+ it("keeps a transcript on one in-flight submit and hands it back when that submit fails", async () => {
657
+ const stream = createMockStream();
658
+ let rejectFirst!: (error: Error) => void;
659
+ stream.submit.mockImplementationOnce(
660
+ () =>
661
+ new Promise<void>((_resolve, reject) => {
662
+ rejectFirst = reject;
663
+ }),
664
+ );
665
+ const voice = createVoiceAdapter();
666
+ const { result } = await renderVoiceRuntime(stream, voice);
667
+ const [user, assistant] = speak(result.current, voice);
668
+ act(() => result.current.thread.disconnectVoice());
669
+ const core = (
670
+ result.current.thread as unknown as {
671
+ __internal_threadBinding: {
672
+ getState(): { append(message: AppendMessage): Promise<void> };
673
+ };
674
+ }
675
+ ).__internal_threadBinding.getState();
676
+ const appendText = (text: string) =>
677
+ core.append({
678
+ role: "user",
679
+ content: [{ type: "text", text }],
680
+ parentId: result.current.thread.getState().messages.at(-1)!.id,
681
+ sourceId: null,
682
+ runConfig: undefined,
683
+ attachments: [],
684
+ metadata: { custom: {} },
685
+ createdAt: new Date(0),
686
+ });
687
+
688
+ let first!: Promise<void>;
689
+ act(() => {
690
+ first = appendText("First");
691
+ });
692
+ await waitFor(() => expect(stream.submit).toHaveBeenCalledTimes(1));
693
+ await act(async () => {
694
+ await appendText("Overlap");
695
+ });
696
+ await act(async () => {
697
+ rejectFirst(new Error("offline"));
698
+ await expect(first).rejects.toThrow("offline");
699
+ });
700
+ await act(async () => {
701
+ await result.current.thread.append("Retry");
702
+ });
703
+
704
+ const submittedIds = stream.submit.mock.calls.map(([values]) =>
705
+ (values.messages as { id?: string }[]).map((message) => message.id),
706
+ );
707
+ expect(submittedIds).toEqual([
708
+ [user!.id, assistant!.id, expect.any(String)],
709
+ [expect.any(String)],
710
+ [user!.id, assistant!.id, expect.any(String)],
711
+ ]);
712
+ });
713
+ });