@assistant-ui/react-ag-ui 0.0.65 → 0.0.67
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/runtime/AgUiThreadRuntimeCore.d.ts +19 -3
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +193 -39
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +11 -12
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +4 -16
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +80 -15
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/types.d.ts +22 -5
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js +8 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +76 -33
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +380 -6
- package/src/runtime/AgUiThreadRuntimeCore.ts +314 -70
- package/src/runtime/adapter/conversions.content.test.ts +41 -0
- package/src/runtime/adapter/conversions.ts +16 -19
- package/src/runtime/adapter/run-aggregator.reasoning.test.ts +143 -0
- package/src/runtime/adapter/run-aggregator.ts +128 -33
- package/src/runtime/types.ts +25 -5
- package/src/useAgUiRuntime.external-store.test.tsx +51 -0
- package/src/useAgUiRuntime.thread-switch.test.tsx +587 -3
- package/src/useAgUiRuntime.ts +98 -44
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
6
|
-
import
|
|
7
|
-
import type { ThreadMessage } from "@assistant-ui/core";
|
|
6
|
+
import { HttpAgent } from "@ag-ui/client";
|
|
7
|
+
import type { ThreadHistoryAdapter, ThreadMessage } from "@assistant-ui/core";
|
|
8
8
|
import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
UseAgUiRuntimeOptions,
|
|
11
|
+
UseAgUiThreadListAdapter,
|
|
12
|
+
} from "./runtime/types";
|
|
10
13
|
import { useAgUiRuntime } from "./useAgUiRuntime";
|
|
11
14
|
|
|
12
15
|
type ThreadLoad = Awaited<
|
|
@@ -40,17 +43,24 @@ function renderRuntime(
|
|
|
40
43
|
load: (id: string) => Promise<ThreadLoad>,
|
|
41
44
|
create: () => Promise<void> = async () => {},
|
|
42
45
|
agentOverride?: HttpAgent,
|
|
46
|
+
options: Pick<
|
|
47
|
+
UseAgUiRuntimeOptions,
|
|
48
|
+
"onCancel" | "unstable_enableMessageQueue"
|
|
49
|
+
> & { history?: ThreadHistoryAdapter } = {},
|
|
43
50
|
) {
|
|
44
51
|
const agent = {
|
|
45
52
|
runAgent: vi.fn(),
|
|
46
53
|
abortRun: vi.fn(),
|
|
47
54
|
} as unknown as HttpAgent;
|
|
48
55
|
const runtimeAgent = agentOverride ?? agent;
|
|
56
|
+
const { history, ...runtimeOptions } = options;
|
|
49
57
|
return renderHook(() => {
|
|
50
58
|
const [threadId, setThreadId] = useState("initial");
|
|
51
59
|
return useAgUiRuntime({
|
|
52
60
|
agent: runtimeAgent,
|
|
61
|
+
...runtimeOptions,
|
|
53
62
|
adapters: {
|
|
63
|
+
...(history && { history }),
|
|
54
64
|
threadList: {
|
|
55
65
|
threadId,
|
|
56
66
|
onSwitchToThread: (id) => {
|
|
@@ -73,6 +83,580 @@ afterEach(() => {
|
|
|
73
83
|
});
|
|
74
84
|
|
|
75
85
|
describe("useAgUiRuntime thread switching", () => {
|
|
86
|
+
it.each(["existing", "new"] as const)(
|
|
87
|
+
"persists a cancelled partial reply before switching to the %s thread",
|
|
88
|
+
async (destination) => {
|
|
89
|
+
const signals: AbortSignal[] = [];
|
|
90
|
+
const onAbort = vi.fn();
|
|
91
|
+
const appendHistory = vi.fn<ThreadHistoryAdapter["append"]>(
|
|
92
|
+
async () => {},
|
|
93
|
+
);
|
|
94
|
+
const history: ThreadHistoryAdapter = {
|
|
95
|
+
load: vi.fn(async () => ({ headId: null, messages: [] })),
|
|
96
|
+
append: appendHistory,
|
|
97
|
+
};
|
|
98
|
+
let subscriber!: Subscriber;
|
|
99
|
+
const runAgent = vi.fn(
|
|
100
|
+
async (
|
|
101
|
+
_input: unknown,
|
|
102
|
+
next: Subscriber,
|
|
103
|
+
options: { signal: AbortSignal },
|
|
104
|
+
) => {
|
|
105
|
+
subscriber = next;
|
|
106
|
+
signals.push(options.signal);
|
|
107
|
+
await new Promise<void>((resolve) =>
|
|
108
|
+
options.signal.addEventListener(
|
|
109
|
+
"abort",
|
|
110
|
+
() => {
|
|
111
|
+
onAbort();
|
|
112
|
+
resolve();
|
|
113
|
+
},
|
|
114
|
+
{ once: true },
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
120
|
+
let adapterCalled = false;
|
|
121
|
+
let cancelledAtAdapter = false;
|
|
122
|
+
let partialAtAdapter = false;
|
|
123
|
+
const inspectAtAdapter = () => {
|
|
124
|
+
adapterCalled = true;
|
|
125
|
+
cancelledAtAdapter = signals[0]?.aborted ?? false;
|
|
126
|
+
partialAtAdapter = appendHistory.mock.calls.some(
|
|
127
|
+
([entry]) =>
|
|
128
|
+
entry.message.role === "assistant" &&
|
|
129
|
+
entry.message.status?.type === "incomplete" &&
|
|
130
|
+
entry.message.content.some(
|
|
131
|
+
(part) => part.type === "text" && part.text === "partial",
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
const { result } = renderRuntime(
|
|
136
|
+
async () => {
|
|
137
|
+
inspectAtAdapter();
|
|
138
|
+
return { messages: [message("loaded")] };
|
|
139
|
+
},
|
|
140
|
+
async () => {
|
|
141
|
+
inspectAtAdapter();
|
|
142
|
+
},
|
|
143
|
+
agent,
|
|
144
|
+
{ history },
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
await waitFor(() =>
|
|
148
|
+
expect(result.current.thread.getState().isLoading).toBe(false),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
act(() => {
|
|
152
|
+
void result.current.thread.append("old prompt");
|
|
153
|
+
});
|
|
154
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
155
|
+
act(() => {
|
|
156
|
+
subscriber.onTextMessageStartEvent?.({
|
|
157
|
+
event: { type: "TEXT_MESSAGE_START", messageId: "old-output" },
|
|
158
|
+
});
|
|
159
|
+
subscriber.onTextMessageContentEvent?.({
|
|
160
|
+
event: {
|
|
161
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
162
|
+
messageId: "old-output",
|
|
163
|
+
delta: "partial",
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
expect(result.current.thread.getState().messages.length).toBeGreaterThan(
|
|
168
|
+
0,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
let switching!: Promise<void>;
|
|
172
|
+
act(() => {
|
|
173
|
+
if (destination === "existing") {
|
|
174
|
+
switching = result.current.threads.switchToThread("thread-a");
|
|
175
|
+
} else {
|
|
176
|
+
switching = result.current.threads.switchToNewThread();
|
|
177
|
+
}
|
|
178
|
+
expect(adapterCalled).toBe(true);
|
|
179
|
+
expect(partialAtAdapter).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
await act(async () => {
|
|
182
|
+
await switching;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(cancelledAtAdapter).toBe(true);
|
|
186
|
+
expect(signals[0]?.aborted).toBe(true);
|
|
187
|
+
expect(onAbort).toHaveBeenCalledOnce();
|
|
188
|
+
expect(partialAtAdapter).toBe(true);
|
|
189
|
+
expect(
|
|
190
|
+
result.current.thread.getState().messages.map((m) => m.id),
|
|
191
|
+
).toEqual(destination === "existing" ? ["loaded"] : []);
|
|
192
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
it.each(["existing", "new"] as const)(
|
|
197
|
+
"aborts an HttpAgent's in-flight request when switching to the %s thread",
|
|
198
|
+
async (destination) => {
|
|
199
|
+
const requestSignals: AbortSignal[] = [];
|
|
200
|
+
const fetch = vi.fn((_url: string, init: RequestInit) => {
|
|
201
|
+
const signal = init.signal!;
|
|
202
|
+
requestSignals.push(signal);
|
|
203
|
+
return new Promise<Response>((_resolve, reject) =>
|
|
204
|
+
signal.addEventListener("abort", () => reject(signal.reason), {
|
|
205
|
+
once: true,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
const agent = new HttpAgent({ url: "https://agent.test/run", fetch });
|
|
210
|
+
const { result } = renderRuntime(
|
|
211
|
+
async () => ({ messages: [message("loaded")] }),
|
|
212
|
+
async () => {},
|
|
213
|
+
agent,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
act(() => {
|
|
217
|
+
void result.current.thread.append("old prompt");
|
|
218
|
+
});
|
|
219
|
+
await waitFor(() => expect(requestSignals).toHaveLength(1));
|
|
220
|
+
|
|
221
|
+
await act(async () => {
|
|
222
|
+
await (destination === "existing"
|
|
223
|
+
? result.current.threads.switchToThread("thread-a")
|
|
224
|
+
: result.current.threads.switchToNewThread());
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
expect(requestSignals[0]?.aborted).toBe(true);
|
|
228
|
+
expect(result.current.thread.getState().isRunning).toBe(false);
|
|
229
|
+
expect(fetch).toHaveBeenCalledOnce();
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
it("still switches when the agent's abortRun throws", async () => {
|
|
234
|
+
const signals: AbortSignal[] = [];
|
|
235
|
+
const runAgent = vi.fn(
|
|
236
|
+
async (
|
|
237
|
+
_input: unknown,
|
|
238
|
+
_next: Subscriber,
|
|
239
|
+
options: { signal: AbortSignal },
|
|
240
|
+
) => {
|
|
241
|
+
signals.push(options.signal);
|
|
242
|
+
await new Promise<void>((resolve) =>
|
|
243
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
244
|
+
once: true,
|
|
245
|
+
}),
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
const agent = {
|
|
250
|
+
runAgent,
|
|
251
|
+
abortRun: vi.fn(() => {
|
|
252
|
+
throw new Error("abort failed");
|
|
253
|
+
}),
|
|
254
|
+
} as unknown as HttpAgent;
|
|
255
|
+
const { result } = renderRuntime(
|
|
256
|
+
async () => ({ messages: [message("loaded")] }),
|
|
257
|
+
async () => {},
|
|
258
|
+
agent,
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
act(() => {
|
|
262
|
+
void result.current.thread.append("old prompt");
|
|
263
|
+
});
|
|
264
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
265
|
+
|
|
266
|
+
await act(async () => {
|
|
267
|
+
await result.current.threads.switchToThread("thread-a");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(signals[0]?.aborted).toBe(true);
|
|
271
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
272
|
+
"loaded",
|
|
273
|
+
]);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("discards sends queued behind the active run when switching threads", async () => {
|
|
277
|
+
const runAgent = vi.fn(
|
|
278
|
+
async (
|
|
279
|
+
_input: unknown,
|
|
280
|
+
_subscriber: Subscriber,
|
|
281
|
+
options: { signal: AbortSignal },
|
|
282
|
+
) => {
|
|
283
|
+
await new Promise<void>((resolve) =>
|
|
284
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
285
|
+
once: true,
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
291
|
+
const { result } = renderRuntime(
|
|
292
|
+
async () => ({ messages: [message("loaded")] }),
|
|
293
|
+
undefined,
|
|
294
|
+
agent,
|
|
295
|
+
{ unstable_enableMessageQueue: true },
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
act(() => {
|
|
299
|
+
void result.current.thread.append("active");
|
|
300
|
+
});
|
|
301
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
302
|
+
await act(async () => {
|
|
303
|
+
await result.current.thread.append({
|
|
304
|
+
role: "user",
|
|
305
|
+
content: [{ type: "text", text: "queued" }],
|
|
306
|
+
parentId: result.current.thread.getState().messages.at(-1)?.id ?? null,
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
310
|
+
|
|
311
|
+
await act(async () => {
|
|
312
|
+
await result.current.threads.switchToThread("thread-a");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
316
|
+
"loaded",
|
|
317
|
+
]);
|
|
318
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("drops sends from onCancel and the caller during the switch", async () => {
|
|
322
|
+
const load = deferred<ThreadLoad>();
|
|
323
|
+
const signals: AbortSignal[] = [];
|
|
324
|
+
const runAgent = vi.fn(
|
|
325
|
+
async (
|
|
326
|
+
_input: unknown,
|
|
327
|
+
_subscriber: Subscriber,
|
|
328
|
+
options: { signal: AbortSignal },
|
|
329
|
+
) => {
|
|
330
|
+
signals.push(options.signal);
|
|
331
|
+
await new Promise<void>((resolve) =>
|
|
332
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
333
|
+
once: true,
|
|
334
|
+
}),
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
);
|
|
338
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
339
|
+
let result!: ReturnType<typeof renderRuntime>["result"];
|
|
340
|
+
let cancelled = false;
|
|
341
|
+
({ result } = renderRuntime(() => load.promise, undefined, agent, {
|
|
342
|
+
onCancel: () => {
|
|
343
|
+
if (cancelled) return;
|
|
344
|
+
cancelled = true;
|
|
345
|
+
void result.current.thread.append("started by onCancel");
|
|
346
|
+
},
|
|
347
|
+
}));
|
|
348
|
+
|
|
349
|
+
act(() => {
|
|
350
|
+
void result.current.thread.append("old prompt");
|
|
351
|
+
});
|
|
352
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
353
|
+
|
|
354
|
+
let switching!: Promise<void>;
|
|
355
|
+
act(() => {
|
|
356
|
+
switching = result.current.threads.switchToThread("thread-a");
|
|
357
|
+
});
|
|
358
|
+
act(() => {
|
|
359
|
+
void result.current.thread.append("during switch");
|
|
360
|
+
});
|
|
361
|
+
await act(async () => {
|
|
362
|
+
load.resolve({ messages: [message("loaded")] });
|
|
363
|
+
await switching;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
367
|
+
expect(signals.every((signal) => signal.aborted)).toBe(true);
|
|
368
|
+
expect(result.current.thread.getState().isRunning).toBe(false);
|
|
369
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
370
|
+
"loaded",
|
|
371
|
+
]);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("drops every send started by onCancel while switching", async () => {
|
|
375
|
+
const runAgent = vi.fn(
|
|
376
|
+
async (
|
|
377
|
+
_input: unknown,
|
|
378
|
+
_subscriber: Subscriber,
|
|
379
|
+
options: { signal: AbortSignal },
|
|
380
|
+
) => {
|
|
381
|
+
await new Promise<void>((resolve) =>
|
|
382
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
383
|
+
once: true,
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
389
|
+
let result!: ReturnType<typeof renderRuntime>["result"];
|
|
390
|
+
const onCancel = vi.fn(() => {
|
|
391
|
+
void result.current.thread.append("from onCancel");
|
|
392
|
+
});
|
|
393
|
+
({ result } = renderRuntime(
|
|
394
|
+
async () => ({ messages: [message("loaded")] }),
|
|
395
|
+
undefined,
|
|
396
|
+
agent,
|
|
397
|
+
{ onCancel },
|
|
398
|
+
));
|
|
399
|
+
|
|
400
|
+
act(() => {
|
|
401
|
+
void result.current.thread.append("old prompt");
|
|
402
|
+
});
|
|
403
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
404
|
+
await act(async () => {
|
|
405
|
+
await result.current.threads.switchToThread("thread-a");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
expect(onCancel).toHaveBeenCalledOnce();
|
|
409
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
410
|
+
expect(result.current.thread.getState().isRunning).toBe(false);
|
|
411
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
412
|
+
"loaded",
|
|
413
|
+
]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("drops a reload started by onCancel during the final reset", async () => {
|
|
417
|
+
const runAgent = vi.fn(
|
|
418
|
+
async (
|
|
419
|
+
_input: unknown,
|
|
420
|
+
_subscriber: Subscriber,
|
|
421
|
+
options: { signal: AbortSignal },
|
|
422
|
+
) => {
|
|
423
|
+
await new Promise<void>((resolve) =>
|
|
424
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
425
|
+
once: true,
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
},
|
|
429
|
+
);
|
|
430
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
431
|
+
let result!: ReturnType<typeof renderRuntime>["result"];
|
|
432
|
+
const onCancel = vi.fn(() => {
|
|
433
|
+
void result.current.thread.startRun({ parentId: null });
|
|
434
|
+
});
|
|
435
|
+
({ result } = renderRuntime(
|
|
436
|
+
async () => ({ messages: [message("loaded")] }),
|
|
437
|
+
undefined,
|
|
438
|
+
agent,
|
|
439
|
+
{ onCancel },
|
|
440
|
+
));
|
|
441
|
+
|
|
442
|
+
act(() => {
|
|
443
|
+
void result.current.thread.append("old prompt");
|
|
444
|
+
});
|
|
445
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
446
|
+
await act(async () => {
|
|
447
|
+
await result.current.threads.switchToThread("thread-a");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
expect(onCancel).toHaveBeenCalledOnce();
|
|
451
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
452
|
+
expect(result.current.thread.getState().isRunning).toBe(false);
|
|
453
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
454
|
+
"loaded",
|
|
455
|
+
]);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("lets a switch started by onCancel supersede the cancelled switch", async () => {
|
|
459
|
+
const runAgent = vi.fn(
|
|
460
|
+
async (
|
|
461
|
+
_input: unknown,
|
|
462
|
+
_subscriber: Subscriber,
|
|
463
|
+
options: { signal: AbortSignal },
|
|
464
|
+
) => {
|
|
465
|
+
await new Promise<void>((resolve) =>
|
|
466
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
467
|
+
once: true,
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
},
|
|
471
|
+
);
|
|
472
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
473
|
+
let result!: ReturnType<typeof renderRuntime>["result"];
|
|
474
|
+
let switchB!: Promise<void>;
|
|
475
|
+
({ result } = renderRuntime(
|
|
476
|
+
async (id) => ({ messages: [message(id)] }),
|
|
477
|
+
undefined,
|
|
478
|
+
agent,
|
|
479
|
+
{
|
|
480
|
+
onCancel: () => {
|
|
481
|
+
switchB = result.current.threads.switchToThread("thread-b");
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
));
|
|
485
|
+
|
|
486
|
+
act(() => {
|
|
487
|
+
void result.current.thread.append("old prompt");
|
|
488
|
+
});
|
|
489
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
490
|
+
await act(async () => {
|
|
491
|
+
const switchA = result.current.threads.switchToThread("thread-a");
|
|
492
|
+
await Promise.all([switchA, switchB]);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
expect(result.current.threads.getState().mainThreadId).toBe("thread-b");
|
|
496
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
497
|
+
"thread-b",
|
|
498
|
+
]);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("resumes an in-flight run through history after the switch", async () => {
|
|
502
|
+
const resume = vi.fn(async function* () {
|
|
503
|
+
yield { content: [{ type: "text" as const, text: "resumed" }] };
|
|
504
|
+
});
|
|
505
|
+
const history: ThreadHistoryAdapter = {
|
|
506
|
+
load: vi.fn(async () => ({ headId: null, messages: [] })),
|
|
507
|
+
append: vi.fn(async () => {}),
|
|
508
|
+
resume,
|
|
509
|
+
};
|
|
510
|
+
const { result } = renderRuntime(
|
|
511
|
+
async () => ({ messages: [message("loaded")], unstable_resume: true }),
|
|
512
|
+
undefined,
|
|
513
|
+
undefined,
|
|
514
|
+
{ history },
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
await act(async () => {
|
|
518
|
+
await result.current.threads.switchToThread("thread-a");
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
await waitFor(() => expect(resume).toHaveBeenCalledOnce());
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("does not dispatch a queued send on the aborted run's idle edge", async () => {
|
|
525
|
+
const runAgent = vi.fn(
|
|
526
|
+
async (
|
|
527
|
+
_input: unknown,
|
|
528
|
+
_subscriber: Subscriber,
|
|
529
|
+
options: { signal: AbortSignal },
|
|
530
|
+
) => {
|
|
531
|
+
await new Promise<void>((resolve) =>
|
|
532
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
533
|
+
once: true,
|
|
534
|
+
}),
|
|
535
|
+
);
|
|
536
|
+
},
|
|
537
|
+
);
|
|
538
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
539
|
+
const { result } = renderRuntime(
|
|
540
|
+
async () => ({ messages: [message("loaded")] }),
|
|
541
|
+
undefined,
|
|
542
|
+
agent,
|
|
543
|
+
{ unstable_enableMessageQueue: true },
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
act(() => {
|
|
547
|
+
void result.current.thread.append("active");
|
|
548
|
+
});
|
|
549
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
550
|
+
await act(async () => {
|
|
551
|
+
await result.current.thread.append({
|
|
552
|
+
role: "user",
|
|
553
|
+
content: [{ type: "text", text: "queued" }],
|
|
554
|
+
parentId: result.current.thread.getState().messages.at(-1)?.id ?? null,
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
558
|
+
|
|
559
|
+
let switching!: Promise<void>;
|
|
560
|
+
act(() => {
|
|
561
|
+
switching = result.current.threads.switchToThread("thread-a");
|
|
562
|
+
});
|
|
563
|
+
await act(async () => {
|
|
564
|
+
await switching;
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
568
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
569
|
+
"loaded",
|
|
570
|
+
]);
|
|
571
|
+
act(() => {
|
|
572
|
+
void result.current.thread.append("new thread prompt");
|
|
573
|
+
});
|
|
574
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("keeps the queue usable after onCancel queues a send mid-switch", async () => {
|
|
578
|
+
const staleLoad = deferred<ThreadLoad>();
|
|
579
|
+
const runAgent = vi.fn(
|
|
580
|
+
async (
|
|
581
|
+
_input: unknown,
|
|
582
|
+
_subscriber: Subscriber,
|
|
583
|
+
options: { signal: AbortSignal },
|
|
584
|
+
) => {
|
|
585
|
+
await new Promise<void>((resolve) =>
|
|
586
|
+
options.signal.addEventListener("abort", () => resolve(), {
|
|
587
|
+
once: true,
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
590
|
+
},
|
|
591
|
+
);
|
|
592
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
593
|
+
let result!: ReturnType<typeof renderRuntime>["result"];
|
|
594
|
+
const onCancel = vi.fn(() => {
|
|
595
|
+
void result.current.thread.append("sent mid-switch");
|
|
596
|
+
});
|
|
597
|
+
({ result } = renderRuntime(
|
|
598
|
+
(id) =>
|
|
599
|
+
id === "thread-a"
|
|
600
|
+
? staleLoad.promise
|
|
601
|
+
: Promise.resolve({ messages: [message("thread-b")] }),
|
|
602
|
+
undefined,
|
|
603
|
+
agent,
|
|
604
|
+
{ unstable_enableMessageQueue: true, onCancel },
|
|
605
|
+
));
|
|
606
|
+
|
|
607
|
+
act(() => {
|
|
608
|
+
void result.current.thread.append("active");
|
|
609
|
+
});
|
|
610
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
611
|
+
act(() => {
|
|
612
|
+
void result.current.threads.switchToThread("thread-a");
|
|
613
|
+
});
|
|
614
|
+
await act(async () => {
|
|
615
|
+
await result.current.threads.switchToThread("thread-b");
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
expect(onCancel).toHaveBeenCalledOnce();
|
|
619
|
+
expect(runAgent).toHaveBeenCalledOnce();
|
|
620
|
+
expect(result.current.thread.getState().messages.map((m) => m.id)).toEqual([
|
|
621
|
+
"thread-b",
|
|
622
|
+
]);
|
|
623
|
+
act(() => {
|
|
624
|
+
void result.current.thread.append("after the switch");
|
|
625
|
+
});
|
|
626
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it.each([false, true])(
|
|
630
|
+
"accepts a send after a newer switch completes with queue %s",
|
|
631
|
+
async (unstable_enableMessageQueue) => {
|
|
632
|
+
const staleLoad = deferred<ThreadLoad>();
|
|
633
|
+
const runAgent = vi.fn(async () => {});
|
|
634
|
+
const agent = { runAgent, abortRun: vi.fn() } as unknown as HttpAgent;
|
|
635
|
+
const { result } = renderRuntime(
|
|
636
|
+
(id) =>
|
|
637
|
+
id === "thread-a"
|
|
638
|
+
? staleLoad.promise
|
|
639
|
+
: Promise.resolve({ messages: [message("thread-b")] }),
|
|
640
|
+
undefined,
|
|
641
|
+
agent,
|
|
642
|
+
{ unstable_enableMessageQueue },
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
act(() => {
|
|
646
|
+
void result.current.threads.switchToThread("thread-a");
|
|
647
|
+
});
|
|
648
|
+
await act(async () => {
|
|
649
|
+
await result.current.threads.switchToThread("thread-b");
|
|
650
|
+
});
|
|
651
|
+
expect(result.current.threads.getState().mainThreadId).toBe("thread-b");
|
|
652
|
+
|
|
653
|
+
act(() => {
|
|
654
|
+
void result.current.thread.append("send on B");
|
|
655
|
+
});
|
|
656
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledOnce());
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
|
|
76
660
|
it.each([undefined, { owner: "thread-a" }])(
|
|
77
661
|
"ignores an older load and its resume request with state %j",
|
|
78
662
|
async (state) => {
|