@assistant-ui/react-langchain 0.0.25 → 0.0.27

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.
@@ -5,11 +5,19 @@ import { describe, expect, it, vi } from "vitest";
5
5
  import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
6
6
  import type {
7
7
  AssistantRuntime,
8
+ AppendMessage,
8
9
  RemoteThreadListAdapter,
9
10
  } from "@assistant-ui/core";
10
11
  import { useAui } from "@assistant-ui/store";
11
12
  import type { LangChainBaseMessage } from "./types";
12
13
  import type { ReactNode } from "react";
14
+ import {
15
+ useLangChainRespond,
16
+ useLangChainRespondAll,
17
+ useLangChainSend,
18
+ useLangChainSendCommand,
19
+ useLangChainSubmit,
20
+ } from "./hooks";
13
21
 
14
22
  const { mockUseChannel, mockUseStream, streamController } = vi.hoisted(() => ({
15
23
  mockUseChannel: vi.fn(() => []),
@@ -147,6 +155,16 @@ const makeThreadListAdapter = (): RemoteThreadListAdapter => ({
147
155
  })),
148
156
  });
149
157
 
158
+ const deferred = <T,>() => {
159
+ let resolve!: (value: T) => void;
160
+ let reject!: (reason?: unknown) => void;
161
+ const promise = new Promise<T>((res, rej) => {
162
+ resolve = res;
163
+ reject = rej;
164
+ });
165
+ return { promise, resolve, reject };
166
+ };
167
+
150
168
  describe("useStreamRuntime thread options", () => {
151
169
  it("keeps stream options isolated between mounted threads", async () => {
152
170
  mockUseStream.mockReturnValue(createMockStream());
@@ -186,6 +204,532 @@ describe("useStreamRuntime thread options", () => {
186
204
 
187
205
  view.unmount();
188
206
  });
207
+
208
+ it("renders before initialization and submits with the initialized thread id", async () => {
209
+ const stream = createMockStream();
210
+ mockUseStream.mockReturnValue(stream);
211
+ const initialization = deferred<{
212
+ remoteId: string;
213
+ externalId: string;
214
+ }>();
215
+ const threadListAdapter = makeThreadListAdapter();
216
+ threadListAdapter.list = vi.fn(async () => ({ threads: [] }));
217
+ threadListAdapter.initialize = vi.fn(() => initialization.promise);
218
+
219
+ const capture: {
220
+ runtime: AssistantRuntime | null;
221
+ aui?: ReturnType<typeof useAui>;
222
+ } = { runtime: null };
223
+ const Capture = () => {
224
+ capture.aui = useAui();
225
+ return null;
226
+ };
227
+ const TestRuntime = () => {
228
+ const runtime = useStreamRuntime({
229
+ apiUrl: "/api",
230
+ unstable_threadListAdapter: threadListAdapter,
231
+ } as never);
232
+ capture.runtime = runtime;
233
+ return (
234
+ <AssistantRuntimeProvider runtime={runtime}>
235
+ <Capture />
236
+ </AssistantRuntimeProvider>
237
+ );
238
+ };
239
+
240
+ const view = render(<TestRuntime />);
241
+ await waitFor(() => expect(capture.aui).toBeDefined());
242
+
243
+ await act(async () => {
244
+ capture.runtime!.thread.append({
245
+ role: "user",
246
+ content: [{ type: "text", text: "hello" }],
247
+ });
248
+ await Promise.resolve();
249
+ });
250
+
251
+ expect(stream.submit).not.toHaveBeenCalled();
252
+ expect(getText(capture.aui!)).toEqual(["hello"]);
253
+
254
+ await act(async () => {
255
+ initialization.resolve({ remoteId: "thread-b", externalId: "thread-b" });
256
+ });
257
+
258
+ await waitFor(() =>
259
+ expect(stream.submit).toHaveBeenCalledWith(
260
+ {
261
+ messages: [
262
+ expect.objectContaining({
263
+ id: expect.any(String),
264
+ type: "human",
265
+ content: "hello",
266
+ }),
267
+ ],
268
+ },
269
+ { threadId: "thread-b" },
270
+ ),
271
+ );
272
+
273
+ stream.messages = [message("echo-hello", "human", "hello")];
274
+ view.rerender(<TestRuntime />);
275
+ await waitFor(() => {
276
+ expect(getText(capture.aui!)).toEqual(["hello"]);
277
+ expect(capture.aui!.thread.getState().messages).toHaveLength(1);
278
+ });
279
+ view.unmount();
280
+ });
281
+
282
+ it("omits the threadId override when initialization yields no external id", async () => {
283
+ const stream = createMockStream();
284
+ mockUseStream.mockReturnValue(stream);
285
+ const capture: { runtime: AssistantRuntime | null } = { runtime: null };
286
+ const TestRuntime = () => {
287
+ const runtime = useStreamRuntime({ apiUrl: "/api" } as never);
288
+ capture.runtime = runtime;
289
+ return <AssistantRuntimeProvider runtime={runtime} />;
290
+ };
291
+ const view = render(<TestRuntime />);
292
+ await waitFor(() => expect(capture.runtime).not.toBeNull());
293
+
294
+ await act(async () => {
295
+ await capture.runtime!.thread.append({
296
+ role: "user",
297
+ content: [{ type: "text", text: "one" }],
298
+ });
299
+ });
300
+ await act(async () => {
301
+ await capture.runtime!.thread.append({
302
+ role: "user",
303
+ content: [{ type: "text", text: "two" }],
304
+ });
305
+ });
306
+
307
+ expect(stream.submit).toHaveBeenCalledTimes(2);
308
+ for (const call of stream.submit.mock.calls) {
309
+ expect(call[1]).not.toHaveProperty("threadId");
310
+ }
311
+ view.unmount();
312
+ });
313
+
314
+ it.each(["initialization", "submit"] as const)(
315
+ "removes the staged message when %s fails",
316
+ async (failurePoint) => {
317
+ const stream = createMockStream();
318
+ mockUseStream.mockReturnValue(stream);
319
+ const initialization = deferred<{
320
+ remoteId: string;
321
+ externalId: string;
322
+ }>();
323
+ const threadListAdapter = makeThreadListAdapter();
324
+ threadListAdapter.list = vi.fn(async () => ({ threads: [] }));
325
+ threadListAdapter.initialize = vi.fn(() => initialization.promise);
326
+
327
+ const capture: {
328
+ runtime: AssistantRuntime | null;
329
+ aui?: ReturnType<typeof useAui>;
330
+ } = { runtime: null };
331
+ const Capture = () => {
332
+ capture.aui = useAui();
333
+ return null;
334
+ };
335
+ const TestRuntime = () => {
336
+ const runtime = useStreamRuntime({
337
+ apiUrl: "/api",
338
+ unstable_threadListAdapter: threadListAdapter,
339
+ } as never);
340
+ capture.runtime = runtime;
341
+ return (
342
+ <AssistantRuntimeProvider runtime={runtime}>
343
+ <Capture />
344
+ </AssistantRuntimeProvider>
345
+ );
346
+ };
347
+
348
+ const view = render(<TestRuntime />);
349
+ await waitFor(() => expect(capture.aui).toBeDefined());
350
+
351
+ if (failurePoint === "submit") {
352
+ stream.submit.mockRejectedValueOnce(new Error("submit failed"));
353
+ }
354
+
355
+ const core = (
356
+ capture.runtime!.thread as unknown as {
357
+ __internal_threadBinding: {
358
+ getState(): { append(message: AppendMessage): Promise<void> };
359
+ };
360
+ }
361
+ ).__internal_threadBinding.getState();
362
+ let appendPromise!: Promise<void>;
363
+ await act(async () => {
364
+ appendPromise = core.append({
365
+ role: "user",
366
+ content: [{ type: "text", text: "failed" }],
367
+ parentId: null,
368
+ sourceId: null,
369
+ runConfig: undefined,
370
+ attachments: [],
371
+ metadata: { custom: {} },
372
+ createdAt: new Date(0),
373
+ });
374
+ await Promise.resolve();
375
+ });
376
+ const appendResult = appendPromise.then(
377
+ () => undefined,
378
+ (error: unknown) => error,
379
+ );
380
+ expect(getText(capture.aui!)).toEqual(["failed"]);
381
+
382
+ await act(async () => {
383
+ if (failurePoint === "initialization") {
384
+ initialization.reject(new Error("initialize failed"));
385
+ } else {
386
+ initialization.resolve({
387
+ remoteId: "thread-failed",
388
+ externalId: "thread-failed",
389
+ });
390
+ }
391
+ });
392
+ await expect(appendResult).resolves.toMatchObject({
393
+ message:
394
+ failurePoint === "initialization"
395
+ ? "initialize failed"
396
+ : "submit failed",
397
+ });
398
+ await waitFor(() => expect(getText(capture.aui!)).toEqual([]));
399
+ if (failurePoint === "initialization") {
400
+ expect(stream.submit).not.toHaveBeenCalled();
401
+ } else {
402
+ expect(stream.submit).toHaveBeenCalledTimes(1);
403
+ }
404
+ view.unmount();
405
+ },
406
+ );
407
+ });
408
+
409
+ describe("useStreamRuntime run configuration", () => {
410
+ it("preserves custom configuration for automatic tool-result resumes", async () => {
411
+ const stream = createMockStream();
412
+ const { auiResult, rerender } = renderAui(stream);
413
+
414
+ await act(async () => {
415
+ await auiResult.current.thread.append({
416
+ role: "user",
417
+ content: [{ type: "text", text: "hello" }],
418
+ runConfig: { custom: { model_name: "gpt-5.4-nano" } },
419
+ });
420
+ });
421
+
422
+ stream.messages = [
423
+ {
424
+ id: "assistant-1",
425
+ _getType: () => "ai",
426
+ content: "",
427
+ tool_calls: [{ id: "tool-1", name: "lookup", args: {} }],
428
+ },
429
+ ];
430
+ rerender();
431
+
432
+ await waitFor(() => {
433
+ expect(auiResult.current.thread.getState().messages).toContainEqual(
434
+ expect.objectContaining({
435
+ id: "assistant-1",
436
+ content: expect.arrayContaining([
437
+ expect.objectContaining({
438
+ type: "tool-call",
439
+ toolCallId: "tool-1",
440
+ }),
441
+ ]),
442
+ }),
443
+ );
444
+ });
445
+
446
+ act(() => {
447
+ auiResult.current.thread
448
+ .message({ id: "assistant-1" })
449
+ .part({ toolCallId: "tool-1" })
450
+ .addToolResult({ answer: 42 });
451
+ });
452
+ await waitFor(() => expect(stream.submit).toHaveBeenCalledTimes(2));
453
+
454
+ const config = { config: { configurable: { model_name: "gpt-5.4-nano" } } };
455
+ expect(stream.submit).toHaveBeenNthCalledWith(
456
+ 1,
457
+ {
458
+ messages: [
459
+ expect.objectContaining({ type: "human", content: "hello" }),
460
+ ],
461
+ },
462
+ config,
463
+ );
464
+ expect(stream.submit).toHaveBeenNthCalledWith(
465
+ 2,
466
+ {
467
+ messages: [
468
+ {
469
+ type: "tool",
470
+ name: "lookup",
471
+ tool_call_id: "tool-1",
472
+ content: JSON.stringify({ answer: 42 }),
473
+ status: "success",
474
+ },
475
+ ],
476
+ },
477
+ config,
478
+ );
479
+ });
480
+
481
+ it("inherits custom configuration through exposed resume helpers", async () => {
482
+ const stream = createMockStream();
483
+ mockUseStream.mockReturnValue(stream);
484
+ const capture: {
485
+ runtime: AssistantRuntime | null;
486
+ respond?: ReturnType<typeof useLangChainRespond>;
487
+ respondAll?: ReturnType<typeof useLangChainRespondAll>;
488
+ sendCommand?: ReturnType<typeof useLangChainSendCommand>;
489
+ } = { runtime: null };
490
+
491
+ const Capture = () => {
492
+ capture.respond = useLangChainRespond();
493
+ capture.respondAll = useLangChainRespondAll();
494
+ capture.sendCommand = useLangChainSendCommand();
495
+ return null;
496
+ };
497
+ Capture.displayName = "Capture";
498
+
499
+ const TestRuntime = () => {
500
+ const runtime = useStreamRuntime({ apiUrl: "/api" } as never);
501
+ capture.runtime = runtime;
502
+ return (
503
+ <AssistantRuntimeProvider runtime={runtime}>
504
+ <Capture />
505
+ </AssistantRuntimeProvider>
506
+ );
507
+ };
508
+ TestRuntime.displayName = "TestRuntime";
509
+
510
+ const view = render(<TestRuntime />);
511
+ await waitFor(() => expect(capture.respond).toBeDefined());
512
+
513
+ await act(async () => {
514
+ await capture.runtime!.thread.append({
515
+ role: "user",
516
+ content: [{ type: "text", text: "hello" }],
517
+ runConfig: { custom: { model_name: "gpt-5.4-nano" } },
518
+ });
519
+ });
520
+
521
+ const config = { config: { configurable: { model_name: "gpt-5.4-nano" } } };
522
+ await act(async () => {
523
+ await capture.respond!({ approved: true });
524
+ await capture.respondAll!({ "interrupt-1": { approved: true } });
525
+ await capture.sendCommand!({ resume: "continue" });
526
+ });
527
+
528
+ expect(stream.respond).toHaveBeenCalledWith({ approved: true }, config);
529
+ expect(stream.respondAll).toHaveBeenCalledWith(
530
+ { "interrupt-1": { approved: true } },
531
+ config,
532
+ );
533
+ expect(stream.submit).toHaveBeenLastCalledWith(null, {
534
+ command: { resume: "continue" },
535
+ ...config,
536
+ });
537
+ view.unmount();
538
+ });
539
+
540
+ it("keeps a delayed tool result on the run that produced it", async () => {
541
+ const stream = createMockStream();
542
+ const { auiResult, rerender } = renderAui(stream);
543
+
544
+ await act(async () => {
545
+ await auiResult.current.thread.append({
546
+ role: "user",
547
+ content: [{ type: "text", text: "first" }],
548
+ runConfig: { custom: { model_name: "model-a" } },
549
+ });
550
+ });
551
+
552
+ stream.messages = [
553
+ {
554
+ id: "assistant-1",
555
+ _getType: () => "ai",
556
+ content: "",
557
+ tool_calls: [{ id: "tool-1", name: "lookup", args: {} }],
558
+ },
559
+ ];
560
+ rerender();
561
+ await waitFor(() => {
562
+ expect(auiResult.current.thread.getState().messages).toContainEqual(
563
+ expect.objectContaining({ id: "assistant-1" }),
564
+ );
565
+ });
566
+
567
+ await act(async () => {
568
+ await auiResult.current.thread.append({
569
+ role: "user",
570
+ content: [{ type: "text", text: "second" }],
571
+ runConfig: { custom: { model_name: "model-b" } },
572
+ });
573
+ });
574
+
575
+ act(() => {
576
+ auiResult.current.thread
577
+ .message({ id: "assistant-1" })
578
+ .part({ toolCallId: "tool-1" })
579
+ .addToolResult({ answer: 42 });
580
+ });
581
+ await waitFor(() => expect(stream.submit).toHaveBeenCalledTimes(3));
582
+
583
+ expect(stream.submit).toHaveBeenLastCalledWith(
584
+ {
585
+ messages: [
586
+ {
587
+ type: "tool",
588
+ name: "lookup",
589
+ tool_call_id: "tool-1",
590
+ content: JSON.stringify({ answer: 42 }),
591
+ status: "success",
592
+ },
593
+ ],
594
+ },
595
+ { config: { configurable: { model_name: "model-a" } } },
596
+ );
597
+ });
598
+
599
+ it("does not let a caller-supplied resume config replace the recorded configurable", async () => {
600
+ const stream = createMockStream();
601
+ mockUseStream.mockReturnValue(stream);
602
+ const capture: {
603
+ runtime: AssistantRuntime | null;
604
+ aui?: ReturnType<typeof useAui>;
605
+ submit?: ReturnType<typeof useLangChainSubmit>;
606
+ } = { runtime: null };
607
+
608
+ const Capture = () => {
609
+ capture.aui = useAui();
610
+ capture.submit = useLangChainSubmit();
611
+ return null;
612
+ };
613
+ Capture.displayName = "Capture";
614
+
615
+ const TestRuntime = () => {
616
+ const runtime = useStreamRuntime({ apiUrl: "/api" } as never);
617
+ capture.runtime = runtime;
618
+ return (
619
+ <AssistantRuntimeProvider runtime={runtime}>
620
+ <Capture />
621
+ </AssistantRuntimeProvider>
622
+ );
623
+ };
624
+ TestRuntime.displayName = "TestRuntime";
625
+
626
+ const view = render(<TestRuntime />);
627
+ await waitFor(() => expect(capture.submit).toBeDefined());
628
+
629
+ await act(async () => {
630
+ await capture.runtime!.thread.append({
631
+ role: "user",
632
+ content: [{ type: "text", text: "hello" }],
633
+ runConfig: { custom: { model_name: "gpt-5.4-nano" } },
634
+ });
635
+ });
636
+
637
+ await act(async () => {
638
+ await capture.submit!(null, { command: { resume: "continue" } });
639
+ await capture.submit!(null, {
640
+ command: { resume: "continue" },
641
+ config: { recursion_limit: 5 },
642
+ });
643
+ });
644
+
645
+ stream.messages = [
646
+ {
647
+ id: "assistant-1",
648
+ _getType: () => "ai",
649
+ content: "",
650
+ tool_calls: [{ id: "tool-1", name: "lookup", args: {} }],
651
+ },
652
+ ];
653
+ view.rerender(<TestRuntime />);
654
+ await waitFor(() => {
655
+ expect(capture.aui!.thread.getState().messages).toContainEqual(
656
+ expect.objectContaining({ id: "assistant-1" }),
657
+ );
658
+ });
659
+
660
+ act(() => {
661
+ capture
662
+ .aui!.thread.message({ id: "assistant-1" })
663
+ .part({ toolCallId: "tool-1" })
664
+ .addToolResult({ answer: 42 });
665
+ });
666
+ await waitFor(() => expect(stream.submit).toHaveBeenCalledTimes(4));
667
+
668
+ expect(stream.submit).toHaveBeenNthCalledWith(2, null, {
669
+ command: { resume: "continue" },
670
+ config: { configurable: { model_name: "gpt-5.4-nano" } },
671
+ });
672
+ expect(stream.submit).toHaveBeenNthCalledWith(3, null, {
673
+ command: { resume: "continue" },
674
+ config: { recursion_limit: 5 },
675
+ });
676
+ expect(stream.submit).toHaveBeenLastCalledWith(
677
+ expect.objectContaining({
678
+ messages: expect.arrayContaining([
679
+ expect.objectContaining({ tool_call_id: "tool-1" }),
680
+ ]),
681
+ }),
682
+ { config: { configurable: { model_name: "gpt-5.4-nano" } } },
683
+ );
684
+ view.unmount();
685
+ });
686
+
687
+ it("does not inject the recorded config into a raw new-run submit", async () => {
688
+ const stream = createMockStream();
689
+ mockUseStream.mockReturnValue(stream);
690
+ const capture: {
691
+ runtime: AssistantRuntime | null;
692
+ send?: ReturnType<typeof useLangChainSend>;
693
+ } = { runtime: null };
694
+
695
+ const Capture = () => {
696
+ capture.send = useLangChainSend();
697
+ return null;
698
+ };
699
+ Capture.displayName = "Capture";
700
+
701
+ const TestRuntime = () => {
702
+ const runtime = useStreamRuntime({ apiUrl: "/api" } as never);
703
+ capture.runtime = runtime;
704
+ return (
705
+ <AssistantRuntimeProvider runtime={runtime}>
706
+ <Capture />
707
+ </AssistantRuntimeProvider>
708
+ );
709
+ };
710
+ TestRuntime.displayName = "TestRuntime";
711
+
712
+ const view = render(<TestRuntime />);
713
+ await waitFor(() => expect(capture.send).toBeDefined());
714
+
715
+ await act(async () => {
716
+ await capture.runtime!.thread.append({
717
+ role: "user",
718
+ content: [{ type: "text", text: "hello" }],
719
+ runConfig: { custom: { model_name: "gpt-5.4-nano" } },
720
+ });
721
+ });
722
+
723
+ await act(async () => {
724
+ await capture.send!([{ type: "human", content: "next" }]);
725
+ });
726
+
727
+ expect(stream.submit).toHaveBeenLastCalledWith(
728
+ { messages: [{ type: "human", content: "next" }] },
729
+ undefined,
730
+ );
731
+ view.unmount();
732
+ });
189
733
  });
190
734
 
191
735
  describe("useStreamRuntime staged messages", () => {
@@ -241,6 +785,55 @@ describe("useStreamRuntime staged messages", () => {
241
785
  expect(stream.submit).not.toHaveBeenCalled();
242
786
  });
243
787
 
788
+ it("does not resurrect a staged draft that an edit already truncated", async () => {
789
+ const stream = createMockStream([
790
+ message("u1", "human", "first"),
791
+ message("a1", "ai", "first answer"),
792
+ message("u2", "human", "second"),
793
+ ]);
794
+ const { auiResult, rerender } = renderAui(stream);
795
+
796
+ await act(async () => {
797
+ auiResult.current.thread.append({
798
+ role: "user",
799
+ content: [{ type: "text", text: "draft" }],
800
+ startRun: false,
801
+ });
802
+ });
803
+ await waitFor(() => {
804
+ expect(getText(auiResult.current)).toEqual([
805
+ "first",
806
+ "first answer",
807
+ "second",
808
+ "draft",
809
+ ]);
810
+ });
811
+
812
+ await act(async () => {
813
+ auiResult.current.thread.append({
814
+ role: "user",
815
+ parentId: "u1",
816
+ content: [{ type: "text", text: "edited" }],
817
+ startRun: false,
818
+ });
819
+ });
820
+ await waitFor(() => {
821
+ expect(getText(auiResult.current)).toEqual(["first", "edited"]);
822
+ });
823
+
824
+ stream.messages = [
825
+ message("u1", "human", "first"),
826
+ message("a1", "ai", "first answer from refresh"),
827
+ message("u2", "human", "second from refresh"),
828
+ ];
829
+ rerender();
830
+
831
+ await waitFor(() => {
832
+ expect(getText(auiResult.current)).toEqual(["first", "edited"]);
833
+ });
834
+ expect(stream.submit).not.toHaveBeenCalled();
835
+ });
836
+
244
837
  it("keeps later staged messages visible after promoting one staged parent", async () => {
245
838
  const stream = createMockStream([message("u1", "human", "earlier")]);
246
839
  const { auiResult, rerender } = renderAui(stream);