@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.
- package/dist/useStreamRuntime.d.ts.map +1 -1
- package/dist/useStreamRuntime.js +112 -25
- package/dist/useStreamRuntime.js.map +1 -1
- package/package.json +8 -8
- package/src/useStreamRuntime.test.tsx +593 -0
- package/src/useStreamRuntime.ts +201 -36
|
@@ -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);
|