@assistant-ui/core 0.3.12 → 0.3.13
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/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/react/client/interactable-model-context.d.ts.map +1 -1
- package/dist/react/client/interactable-model-context.js +5 -1
- package/dist/react/client/interactable-model-context.js.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-runtime.js +5 -1
- package/dist/runtime/api/thread-runtime.js.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts +25 -0
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.js +64 -11
- package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.d.ts +14 -0
- package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.js +31 -0
- package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
- package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/default-edit-composer-runtime-core.js +1 -6
- package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
- package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/default-thread-composer-runtime-core.js +1 -4
- package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
- package/dist/runtime/queue/external-thread-queue-adapter.d.ts +15 -0
- package/dist/runtime/queue/external-thread-queue-adapter.d.ts.map +1 -1
- package/dist/runtime/queue/message-queue.js +23 -10
- package/dist/runtime/queue/message-queue.js.map +1 -1
- package/dist/runtimes/external-store/external-store-adapter.d.ts +7 -0
- package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +3 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js +49 -13
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.js +2 -1
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/store/clients/external-thread.d.ts +7 -0
- package/dist/store/clients/external-thread.d.ts.map +1 -1
- package/dist/store/clients/external-thread.js +460 -369
- package/dist/store/clients/external-thread.js.map +1 -1
- package/dist/store/clients/runtime-adapter.d.ts.map +1 -1
- package/dist/store/clients/runtime-adapter.js +5 -2
- package/dist/store/clients/runtime-adapter.js.map +1 -1
- package/dist/store/clients/single-thread-list.d.ts.map +1 -1
- package/dist/store/clients/single-thread-list.js +31 -27
- package/dist/store/clients/single-thread-list.js.map +1 -1
- package/dist/store/clients/suggestions.d.ts +3 -1
- package/dist/store/clients/suggestions.d.ts.map +1 -1
- package/dist/store/clients/suggestions.js +65 -37
- package/dist/store/clients/suggestions.js.map +1 -1
- package/dist/store/index.d.ts +2 -2
- package/dist/store/index.js +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js +64 -50
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-runtime-client.js +98 -82
- package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
- package/dist/store/scope-registration.d.ts +2 -2
- package/dist/store/scopes/suggestions.d.ts +6 -1
- package/dist/store/scopes/suggestions.d.ts.map +1 -1
- package/dist/store/scopes/thread.d.ts +17 -0
- package/dist/store/scopes/thread.d.ts.map +1 -1
- package/dist/types/error.d.ts +15 -1
- package/dist/types/error.d.ts.map +1 -1
- package/dist/types/error.js +18 -1
- package/dist/types/error.js.map +1 -1
- package/dist/types/message.d.ts +2 -0
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/message.js.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +1 -0
- package/src/react/client/interactable-model-context.ts +6 -1
- package/src/react/runtimes/useExternalStoreRuntime.suggestions.test.tsx +114 -0
- package/src/runtime/api/thread-runtime.ts +8 -1
- package/src/runtime/base/base-composer-runtime-core.ts +100 -8
- package/src/runtime/base/base-thread-runtime-core.ts +36 -0
- package/src/runtime/base/default-edit-composer-runtime-core.ts +1 -15
- package/src/runtime/base/default-thread-composer-runtime-core.ts +1 -11
- package/src/runtime/queue/external-thread-queue-adapter.ts +17 -0
- package/src/runtime/queue/message-queue.ts +26 -9
- package/src/runtimes/external-store/external-store-adapter.ts +7 -0
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +100 -26
- package/src/runtimes/local/local-thread-runtime-core.ts +4 -1
- package/src/store/clients/external-thread.ts +119 -16
- package/src/store/clients/runtime-adapter.ts +5 -2
- package/src/store/clients/single-thread-list.ts +2 -1
- package/src/store/clients/suggestions.ts +39 -13
- package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -28
- package/src/store/runtime-clients/thread-runtime-client.ts +5 -0
- package/src/store/scopes/suggestions.ts +6 -0
- package/src/store/scopes/thread.ts +17 -0
- package/src/tests/append-interactable-snapshots.test.ts +350 -0
- package/src/tests/base-composer-runtime-core-send.test.ts +126 -0
- package/src/tests/base-composer-runtime-core.test.ts +108 -1
- package/src/tests/default-edit-composer-runtime-core.test.ts +0 -68
- package/src/tests/external-store-thread-runtime-core-adapter.test.ts +300 -0
- package/src/tests/external-thread-attachments.test.tsx +214 -0
- package/src/tests/external-thread-parity.test.tsx +54 -0
- package/src/tests/external-thread-refetch.test.tsx +117 -0
- package/src/tests/external-thread-suggestions.test.tsx +59 -0
- package/src/tests/message-queue.test.ts +26 -0
- package/src/tests/thread-message-like.test.ts +39 -0
- package/src/tests/thread-switch-events.test.tsx +136 -0
- package/src/types/error.ts +24 -0
- package/src/types/message.ts +2 -0
- package/src/tests/default-thread-composer-runtime-core.test.ts +0 -82
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
import type { ExternalStoreAdapter } from "../runtimes/external-store/external-store-adapter";
|
|
7
7
|
import type { ModelContextProvider } from "../model-context/types";
|
|
8
8
|
import type { AppendMessage, ThreadMessage } from "../types/message";
|
|
9
|
+
import { createMessageQueue } from "../runtime/queue/message-queue";
|
|
10
|
+
import { getThreadMessageText } from "../utils/text";
|
|
9
11
|
|
|
10
12
|
const createContextProvider = (): ModelContextProvider => ({
|
|
11
13
|
getModelContext: () => ({}),
|
|
@@ -235,6 +237,175 @@ describe("ExternalStoreThreadRuntimeCore adapter contract", () => {
|
|
|
235
237
|
expect(lastCall.map((m) => m.id)).toContain("server-msg");
|
|
236
238
|
});
|
|
237
239
|
|
|
240
|
+
it("does not revert a store update that lands before the resync flushes", async () => {
|
|
241
|
+
const optimisticAssistant = {
|
|
242
|
+
...createAssistantMessage("server-msg", "partial answer"),
|
|
243
|
+
status: { type: "running" as const },
|
|
244
|
+
metadata: {
|
|
245
|
+
unstable_state: null,
|
|
246
|
+
unstable_annotations: [],
|
|
247
|
+
unstable_data: [],
|
|
248
|
+
steps: [],
|
|
249
|
+
custom: {},
|
|
250
|
+
isOptimistic: true,
|
|
251
|
+
},
|
|
252
|
+
} as ThreadMessage;
|
|
253
|
+
const setMessages = vi.fn();
|
|
254
|
+
const adapter = createBaseAdapter({
|
|
255
|
+
messages: [createUserMessage("u1"), optimisticAssistant],
|
|
256
|
+
isRunning: true,
|
|
257
|
+
onCancel: vi.fn(),
|
|
258
|
+
setMessages,
|
|
259
|
+
});
|
|
260
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
261
|
+
|
|
262
|
+
core.cancelRun();
|
|
263
|
+
|
|
264
|
+
// The store settles the cancelled turn and re-supplies it in the same
|
|
265
|
+
// tick as the cancel.
|
|
266
|
+
core.__internal_setAdapter(
|
|
267
|
+
createBaseAdapter({
|
|
268
|
+
messages: [
|
|
269
|
+
createUserMessage("u1"),
|
|
270
|
+
createAssistantMessage("server-msg", "partial answer (stopped)"),
|
|
271
|
+
],
|
|
272
|
+
isRunning: false,
|
|
273
|
+
onCancel: vi.fn(),
|
|
274
|
+
setMessages,
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
279
|
+
expect(setMessages).toHaveBeenCalled();
|
|
280
|
+
const lastCall = setMessages.mock.lastCall?.[0] as ThreadMessage[];
|
|
281
|
+
const texts = lastCall.map(getThreadMessageText);
|
|
282
|
+
expect(texts).toContain("partial answer (stopped)");
|
|
283
|
+
expect(texts).not.toContain("partial answer");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("re-applies the user leaf rollback when the store updates before the flush", async () => {
|
|
287
|
+
const messages = [createUserMessage("u1", "cancel me")];
|
|
288
|
+
const setMessages = vi.fn();
|
|
289
|
+
const adapter = createBaseAdapter({
|
|
290
|
+
messages,
|
|
291
|
+
isRunning: true,
|
|
292
|
+
onCancel: vi.fn(),
|
|
293
|
+
setMessages,
|
|
294
|
+
});
|
|
295
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
296
|
+
|
|
297
|
+
core.cancelRun();
|
|
298
|
+
|
|
299
|
+
// The host flips isRunning after onCancel; the store still holds the
|
|
300
|
+
// rolled-back message because only the deferred resync removes it.
|
|
301
|
+
core.__internal_setAdapter(
|
|
302
|
+
createBaseAdapter({
|
|
303
|
+
messages,
|
|
304
|
+
isRunning: false,
|
|
305
|
+
onCancel: vi.fn(),
|
|
306
|
+
setMessages,
|
|
307
|
+
}),
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
311
|
+
expect(core.composer.text).toBe("cancel me");
|
|
312
|
+
expect(setMessages).toHaveBeenCalled();
|
|
313
|
+
const lastCall = setMessages.mock.lastCall?.[0] as ThreadMessage[];
|
|
314
|
+
expect(lastCall.map((m) => m.id)).not.toContain("u1");
|
|
315
|
+
expect(core.export().messages.map((m) => m.message.id)).not.toContain(
|
|
316
|
+
"u1",
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("keeps a rolled-back user leaf the store answered in the gap and takes the draft back", async () => {
|
|
321
|
+
const setMessages = vi.fn();
|
|
322
|
+
const adapter = createBaseAdapter({
|
|
323
|
+
messages: [createUserMessage("u1", "cancel me")],
|
|
324
|
+
isRunning: true,
|
|
325
|
+
onCancel: vi.fn(),
|
|
326
|
+
setMessages,
|
|
327
|
+
});
|
|
328
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
329
|
+
|
|
330
|
+
core.cancelRun();
|
|
331
|
+
expect(core.composer.text).toBe("cancel me");
|
|
332
|
+
|
|
333
|
+
core.__internal_setAdapter(
|
|
334
|
+
createBaseAdapter({
|
|
335
|
+
messages: [
|
|
336
|
+
createUserMessage("u1", "cancel me"),
|
|
337
|
+
createAssistantMessage("a1", "answered anyway"),
|
|
338
|
+
],
|
|
339
|
+
isRunning: false,
|
|
340
|
+
onCancel: vi.fn(),
|
|
341
|
+
setMessages,
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
346
|
+
expect(core.composer.text).toBe("");
|
|
347
|
+
const lastCall = setMessages.mock.lastCall?.[0] as ThreadMessage[];
|
|
348
|
+
expect(lastCall.map((m) => m.id)).toEqual(["u1", "a1"]);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("leaves an edited draft alone when the store answered in the gap", async () => {
|
|
352
|
+
const setMessages = vi.fn();
|
|
353
|
+
const adapter = createBaseAdapter({
|
|
354
|
+
messages: [createUserMessage("u1", "cancel me")],
|
|
355
|
+
isRunning: true,
|
|
356
|
+
onCancel: vi.fn(),
|
|
357
|
+
setMessages,
|
|
358
|
+
});
|
|
359
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
360
|
+
|
|
361
|
+
core.cancelRun();
|
|
362
|
+
core.composer.setText("edited");
|
|
363
|
+
|
|
364
|
+
core.__internal_setAdapter(
|
|
365
|
+
createBaseAdapter({
|
|
366
|
+
messages: [
|
|
367
|
+
createUserMessage("u1", "cancel me"),
|
|
368
|
+
createAssistantMessage("a1", "answered anyway"),
|
|
369
|
+
],
|
|
370
|
+
isRunning: false,
|
|
371
|
+
onCancel: vi.fn(),
|
|
372
|
+
setMessages,
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
377
|
+
expect(core.composer.text).toBe("edited");
|
|
378
|
+
const lastCall = setMessages.mock.lastCall?.[0] as ThreadMessage[];
|
|
379
|
+
expect(lastCall.map((m) => m.id)).toEqual(["u1", "a1"]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("drops a placeholder regenerated between cancel and flush", async () => {
|
|
383
|
+
const setMessages = vi.fn();
|
|
384
|
+
const adapter = createBaseAdapter({
|
|
385
|
+
messages: [createUserMessage("u1", "cancel me")],
|
|
386
|
+
isRunning: true,
|
|
387
|
+
onCancel: vi.fn(),
|
|
388
|
+
setMessages,
|
|
389
|
+
});
|
|
390
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
391
|
+
|
|
392
|
+
core.cancelRun();
|
|
393
|
+
|
|
394
|
+
core.__internal_setAdapter(
|
|
395
|
+
createBaseAdapter({
|
|
396
|
+
messages: [createUserMessage("u1", "cancel me")],
|
|
397
|
+
isRunning: true,
|
|
398
|
+
onCancel: vi.fn(),
|
|
399
|
+
setMessages,
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
404
|
+
expect(core.composer.text).toBe("cancel me");
|
|
405
|
+
const lastCall = setMessages.mock.lastCall?.[0] as ThreadMessage[];
|
|
406
|
+
expect(lastCall).toEqual([]);
|
|
407
|
+
});
|
|
408
|
+
|
|
238
409
|
it("evicts an empty optimistic head on cancel", async () => {
|
|
239
410
|
const optimisticAssistant = {
|
|
240
411
|
...createAssistantMessage("server-msg", ""),
|
|
@@ -264,6 +435,135 @@ describe("ExternalStoreThreadRuntimeCore adapter contract", () => {
|
|
|
264
435
|
const lastCall = setMessages.mock.lastCall?.[0] as ThreadMessage[];
|
|
265
436
|
expect(lastCall.map((m) => m.id)).not.toContain("server-msg");
|
|
266
437
|
});
|
|
438
|
+
|
|
439
|
+
it("pauses a queue instead of dispatching the next message", async () => {
|
|
440
|
+
const dispatched: string[] = [];
|
|
441
|
+
const queue = createMessageQueue({
|
|
442
|
+
run: (message) => {
|
|
443
|
+
dispatched.push(getThreadMessageText(message));
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
const adapter = createBaseAdapter({
|
|
447
|
+
isRunning: true,
|
|
448
|
+
onCancel: vi.fn(),
|
|
449
|
+
queue: queue.adapter,
|
|
450
|
+
});
|
|
451
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
452
|
+
const appendAtTail = (text: string) =>
|
|
453
|
+
core.append({
|
|
454
|
+
role: "user",
|
|
455
|
+
content: [{ type: "text", text }],
|
|
456
|
+
attachments: [],
|
|
457
|
+
createdAt: new Date(),
|
|
458
|
+
parentId: core.messages.at(-1)?.id ?? null,
|
|
459
|
+
sourceId: null,
|
|
460
|
+
runConfig: undefined,
|
|
461
|
+
metadata: { custom: {} },
|
|
462
|
+
} as AppendMessage);
|
|
463
|
+
|
|
464
|
+
await appendAtTail("first");
|
|
465
|
+
await appendAtTail("queued");
|
|
466
|
+
|
|
467
|
+
core.cancelRun();
|
|
468
|
+
// the aborted run settles; a queue host reports that the same way it
|
|
469
|
+
// reports any other run ending
|
|
470
|
+
queue.notifyIdle();
|
|
471
|
+
|
|
472
|
+
expect(dispatched).toEqual(["first"]);
|
|
473
|
+
expect(queue.adapter.steerItems.map((item) => item.prompt)).toEqual([
|
|
474
|
+
"queued",
|
|
475
|
+
]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("keeps a trailing user message out of the composer without setMessages", async () => {
|
|
479
|
+
const userMessage = createUserMessage("u1", "Do not duplicate me");
|
|
480
|
+
const adapter = createBaseAdapter({
|
|
481
|
+
messages: [userMessage],
|
|
482
|
+
isRunning: true,
|
|
483
|
+
onCancel: vi.fn(),
|
|
484
|
+
});
|
|
485
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
486
|
+
|
|
487
|
+
core.cancelRun();
|
|
488
|
+
|
|
489
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
490
|
+
expect(core.composer.text).toBe("");
|
|
491
|
+
expect(core.export().messages.map((m) => m.message.id)).toContain("u1");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("keeps the message in the thread when the composer is busy", async () => {
|
|
495
|
+
const adapter = createBaseAdapter({
|
|
496
|
+
messages: [createUserMessage("u1", "cancel me")],
|
|
497
|
+
isRunning: true,
|
|
498
|
+
onCancel: vi.fn(),
|
|
499
|
+
setMessages: vi.fn(),
|
|
500
|
+
});
|
|
501
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
502
|
+
core.composer.setText("something else");
|
|
503
|
+
|
|
504
|
+
core.cancelRun();
|
|
505
|
+
|
|
506
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
507
|
+
expect(core.composer.text).toBe("something else");
|
|
508
|
+
expect(core.export().messages.map((m) => m.message.id)).toContain("u1");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("keeps a message with non-text content in the thread", async () => {
|
|
512
|
+
const userMessage = {
|
|
513
|
+
...createUserMessage("u1", "look at this"),
|
|
514
|
+
content: [
|
|
515
|
+
{ type: "text" as const, text: "look at this" },
|
|
516
|
+
{ type: "image" as const, image: "https://example.com/cat.png" },
|
|
517
|
+
],
|
|
518
|
+
} as ThreadMessage;
|
|
519
|
+
const adapter = createBaseAdapter({
|
|
520
|
+
messages: [userMessage],
|
|
521
|
+
isRunning: true,
|
|
522
|
+
onCancel: vi.fn(),
|
|
523
|
+
setMessages: vi.fn(),
|
|
524
|
+
});
|
|
525
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
526
|
+
|
|
527
|
+
core.cancelRun();
|
|
528
|
+
|
|
529
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
530
|
+
expect(core.composer.text).toBe("");
|
|
531
|
+
expect(core.export().messages.map((m) => m.message.id)).toContain("u1");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("restores the message whole when the composer is free", async () => {
|
|
535
|
+
const attachment = {
|
|
536
|
+
id: "a1",
|
|
537
|
+
type: "file" as const,
|
|
538
|
+
name: "spec.pdf",
|
|
539
|
+
contentType: "application/pdf",
|
|
540
|
+
status: { type: "complete" as const },
|
|
541
|
+
content: [],
|
|
542
|
+
};
|
|
543
|
+
const quote = { text: "quoted", messageId: "m-1" };
|
|
544
|
+
const userMessage = {
|
|
545
|
+
...createUserMessage("u1", "cancel me"),
|
|
546
|
+
attachments: [attachment],
|
|
547
|
+
metadata: { custom: { quote } },
|
|
548
|
+
} as ThreadMessage;
|
|
549
|
+
const adapter = createBaseAdapter({
|
|
550
|
+
messages: [userMessage],
|
|
551
|
+
isRunning: true,
|
|
552
|
+
onCancel: vi.fn(),
|
|
553
|
+
setMessages: vi.fn(),
|
|
554
|
+
});
|
|
555
|
+
const core = new ExternalStoreThreadRuntimeCore(contextProvider, adapter);
|
|
556
|
+
|
|
557
|
+
core.cancelRun();
|
|
558
|
+
|
|
559
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
560
|
+
expect(core.composer.text).toBe("cancel me");
|
|
561
|
+
expect(core.composer.attachments).toEqual([attachment]);
|
|
562
|
+
expect(core.composer.quote).toEqual(quote);
|
|
563
|
+
expect(core.export().messages.map((m) => m.message.id)).not.toContain(
|
|
564
|
+
"u1",
|
|
565
|
+
);
|
|
566
|
+
});
|
|
267
567
|
});
|
|
268
568
|
|
|
269
569
|
describe("optimistic assistant message", () => {
|
|
@@ -9,6 +9,10 @@ import type {
|
|
|
9
9
|
ExternalThreadProps,
|
|
10
10
|
} from "../store/clients/external-thread";
|
|
11
11
|
import { ExternalThread } from "../store/clients/external-thread";
|
|
12
|
+
import type {
|
|
13
|
+
CompleteAttachment,
|
|
14
|
+
PendingAttachment,
|
|
15
|
+
} from "../types/attachment";
|
|
12
16
|
|
|
13
17
|
const renderThreadWithProps = (props: Partial<ExternalThreadProps>) => {
|
|
14
18
|
const captured: { aui?: ReturnType<typeof useAui> } = {};
|
|
@@ -215,6 +219,59 @@ describe("ExternalThread attachments", () => {
|
|
|
215
219
|
);
|
|
216
220
|
});
|
|
217
221
|
|
|
222
|
+
it("preserves composer state while attachments are prepared for send", async () => {
|
|
223
|
+
let resolveSend!: (attachment: CompleteAttachment) => void;
|
|
224
|
+
const onNew = vi.fn();
|
|
225
|
+
const file = new File(["data"], "notes.txt", { type: "text/plain" });
|
|
226
|
+
const adapter = {
|
|
227
|
+
accept: "*",
|
|
228
|
+
add: async () => ({
|
|
229
|
+
id: "att-1",
|
|
230
|
+
type: "file" as const,
|
|
231
|
+
name: file.name,
|
|
232
|
+
contentType: file.type,
|
|
233
|
+
file,
|
|
234
|
+
status: {
|
|
235
|
+
type: "requires-action" as const,
|
|
236
|
+
reason: "composer-send" as const,
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
send: () =>
|
|
240
|
+
new Promise<CompleteAttachment>((resolve) => {
|
|
241
|
+
resolveSend = resolve;
|
|
242
|
+
}),
|
|
243
|
+
remove: async () => {},
|
|
244
|
+
};
|
|
245
|
+
const aui = renderThreadWithProps({ attachmentAdapter: adapter, onNew });
|
|
246
|
+
const composer = () => aui().thread.composer();
|
|
247
|
+
|
|
248
|
+
await act(() => composer().addAttachment(file));
|
|
249
|
+
act(() => {
|
|
250
|
+
composer().setText("first message");
|
|
251
|
+
composer().setRole("assistant");
|
|
252
|
+
composer().setRunConfig({ model: "model-a" });
|
|
253
|
+
composer().send();
|
|
254
|
+
composer().setRole("system");
|
|
255
|
+
composer().setRunConfig({ model: "model-b" });
|
|
256
|
+
});
|
|
257
|
+
await act(async () => {
|
|
258
|
+
resolveSend({
|
|
259
|
+
id: "att-1",
|
|
260
|
+
type: "file",
|
|
261
|
+
name: file.name,
|
|
262
|
+
contentType: file.type,
|
|
263
|
+
status: { type: "complete" },
|
|
264
|
+
content: [],
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await waitFor(() => expect(onNew).toHaveBeenCalledTimes(1));
|
|
269
|
+
expect(onNew.mock.calls[0]![0]).toMatchObject({
|
|
270
|
+
role: "assistant",
|
|
271
|
+
runConfig: { model: "model-a" },
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
218
275
|
it.each([
|
|
219
276
|
[
|
|
220
277
|
"clearAttachments",
|
|
@@ -263,6 +320,163 @@ describe("ExternalThread attachments", () => {
|
|
|
263
320
|
},
|
|
264
321
|
);
|
|
265
322
|
|
|
323
|
+
it.each([
|
|
324
|
+
[
|
|
325
|
+
"clearAttachments",
|
|
326
|
+
(c: { clearAttachments(): Promise<void> }) => c.clearAttachments(),
|
|
327
|
+
],
|
|
328
|
+
["reset", (c: { reset(): Promise<void> }) => c.reset()],
|
|
329
|
+
] as const)(
|
|
330
|
+
"does not restore an attachment that resolves after %s",
|
|
331
|
+
async (_name, invoke) => {
|
|
332
|
+
let resolveAdd!: (attachment: PendingAttachment) => void;
|
|
333
|
+
const add = vi.fn(
|
|
334
|
+
() =>
|
|
335
|
+
new Promise<PendingAttachment>((resolve) => {
|
|
336
|
+
resolveAdd = resolve;
|
|
337
|
+
}),
|
|
338
|
+
);
|
|
339
|
+
const aui = renderThreadWithProps({
|
|
340
|
+
attachmentAdapter: {
|
|
341
|
+
accept: "*",
|
|
342
|
+
add,
|
|
343
|
+
send: async () => ({}) as never,
|
|
344
|
+
remove: async () => {},
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
const file = new File(["data"], "notes.txt", { type: "text/plain" });
|
|
348
|
+
|
|
349
|
+
let addPromise!: Promise<void>;
|
|
350
|
+
act(() => {
|
|
351
|
+
addPromise = aui().thread.composer().addAttachment(file);
|
|
352
|
+
});
|
|
353
|
+
await act(() => invoke(aui().thread.composer()));
|
|
354
|
+
await act(async () => {
|
|
355
|
+
resolveAdd({
|
|
356
|
+
id: "att-1",
|
|
357
|
+
type: "file",
|
|
358
|
+
name: file.name,
|
|
359
|
+
contentType: file.type,
|
|
360
|
+
file,
|
|
361
|
+
status: { type: "requires-action", reason: "composer-send" },
|
|
362
|
+
});
|
|
363
|
+
await addPromise;
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
expect(aui().thread.composer().getState().attachments).toHaveLength(0);
|
|
367
|
+
},
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
it("stops attachment generator updates after removal", async () => {
|
|
371
|
+
let resumeAdd!: () => void;
|
|
372
|
+
const drainedAfterRemoval = vi.fn();
|
|
373
|
+
const file = new File(["data"], "notes.txt", { type: "text/plain" });
|
|
374
|
+
const pendingAttachment: PendingAttachment = {
|
|
375
|
+
id: "att-1",
|
|
376
|
+
type: "file",
|
|
377
|
+
name: file.name,
|
|
378
|
+
contentType: file.type,
|
|
379
|
+
file,
|
|
380
|
+
status: { type: "requires-action", reason: "composer-send" },
|
|
381
|
+
};
|
|
382
|
+
const add = vi.fn(async function* () {
|
|
383
|
+
yield pendingAttachment;
|
|
384
|
+
await new Promise<void>((resolve) => {
|
|
385
|
+
resumeAdd = resolve;
|
|
386
|
+
});
|
|
387
|
+
yield {
|
|
388
|
+
...pendingAttachment,
|
|
389
|
+
status: { type: "running", reason: "uploading", progress: 1 },
|
|
390
|
+
} satisfies PendingAttachment;
|
|
391
|
+
drainedAfterRemoval();
|
|
392
|
+
});
|
|
393
|
+
const aui = renderThreadWithProps({
|
|
394
|
+
attachmentAdapter: {
|
|
395
|
+
accept: "*",
|
|
396
|
+
add,
|
|
397
|
+
send: async () => ({}) as never,
|
|
398
|
+
remove: async () => {},
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
const composer = () => aui().thread.composer();
|
|
402
|
+
|
|
403
|
+
let addPromise!: Promise<void>;
|
|
404
|
+
act(() => {
|
|
405
|
+
addPromise = composer().addAttachment(file);
|
|
406
|
+
});
|
|
407
|
+
await waitFor(() =>
|
|
408
|
+
expect(composer().getState().attachments).toHaveLength(1),
|
|
409
|
+
);
|
|
410
|
+
await act(() => composer().attachment({ id: "att-1" }).remove());
|
|
411
|
+
await act(async () => {
|
|
412
|
+
resumeAdd();
|
|
413
|
+
await addPromise;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
expect(composer().getState().attachments).toHaveLength(0);
|
|
417
|
+
expect(drainedAfterRemoval).not.toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("does not restore attachment generator updates after send", async () => {
|
|
421
|
+
let resumeAdd!: () => void;
|
|
422
|
+
const drainedAfterSend = vi.fn();
|
|
423
|
+
const onNew = vi.fn();
|
|
424
|
+
const file = new File(["data"], "notes.txt", { type: "text/plain" });
|
|
425
|
+
const pendingAttachment: PendingAttachment = {
|
|
426
|
+
id: "att-1",
|
|
427
|
+
type: "file",
|
|
428
|
+
name: file.name,
|
|
429
|
+
contentType: file.type,
|
|
430
|
+
file,
|
|
431
|
+
status: { type: "requires-action", reason: "composer-send" },
|
|
432
|
+
};
|
|
433
|
+
const add = vi.fn(async function* () {
|
|
434
|
+
yield pendingAttachment;
|
|
435
|
+
await new Promise<void>((resolve) => {
|
|
436
|
+
resumeAdd = resolve;
|
|
437
|
+
});
|
|
438
|
+
yield {
|
|
439
|
+
...pendingAttachment,
|
|
440
|
+
status: { type: "running", reason: "uploading", progress: 1 },
|
|
441
|
+
} satisfies PendingAttachment;
|
|
442
|
+
drainedAfterSend();
|
|
443
|
+
});
|
|
444
|
+
const aui = renderThreadWithProps({
|
|
445
|
+
attachmentAdapter: {
|
|
446
|
+
accept: "*",
|
|
447
|
+
add,
|
|
448
|
+
send: async (attachment) => ({
|
|
449
|
+
...attachment,
|
|
450
|
+
status: { type: "complete" },
|
|
451
|
+
content: [],
|
|
452
|
+
}),
|
|
453
|
+
remove: async () => {},
|
|
454
|
+
},
|
|
455
|
+
onNew,
|
|
456
|
+
});
|
|
457
|
+
const composer = () => aui().thread.composer();
|
|
458
|
+
|
|
459
|
+
let addPromise!: Promise<void>;
|
|
460
|
+
act(() => {
|
|
461
|
+
addPromise = composer().addAttachment(file);
|
|
462
|
+
});
|
|
463
|
+
await waitFor(() =>
|
|
464
|
+
expect(composer().getState().attachments).toHaveLength(1),
|
|
465
|
+
);
|
|
466
|
+
act(() => {
|
|
467
|
+
composer().setText("hello");
|
|
468
|
+
composer().send();
|
|
469
|
+
});
|
|
470
|
+
await waitFor(() => expect(onNew).toHaveBeenCalledTimes(1));
|
|
471
|
+
await act(async () => {
|
|
472
|
+
resumeAdd();
|
|
473
|
+
await addPromise;
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(composer().getState().attachments).toHaveLength(0);
|
|
477
|
+
expect(drainedAfterSend).not.toHaveBeenCalled();
|
|
478
|
+
});
|
|
479
|
+
|
|
266
480
|
it("routes edit-composer attachments through the adapter", async () => {
|
|
267
481
|
const add = vi.fn(async ({ file }: { file: File }) => ({
|
|
268
482
|
id: "att-edit",
|
|
@@ -279,6 +279,60 @@ describe("ExternalThread composer", () => {
|
|
|
279
279
|
expect(enqueue).not.toHaveBeenCalled();
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
it("pauses the queue on cancel", async () => {
|
|
283
|
+
const notifyCancelled = vi.fn();
|
|
284
|
+
const onCancel = vi.fn();
|
|
285
|
+
const { aui } = renderThread({
|
|
286
|
+
messages: [],
|
|
287
|
+
isRunning: true,
|
|
288
|
+
onCancel,
|
|
289
|
+
queue: {
|
|
290
|
+
items: [],
|
|
291
|
+
steerItems: [],
|
|
292
|
+
enqueue: vi.fn(),
|
|
293
|
+
steer: vi.fn(),
|
|
294
|
+
move: vi.fn(),
|
|
295
|
+
edit: vi.fn(),
|
|
296
|
+
remove: vi.fn(),
|
|
297
|
+
__internal_notifyCancelled: notifyCancelled,
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
aui().thread.cancelRun();
|
|
302
|
+
|
|
303
|
+
expect(notifyCancelled).toHaveBeenCalledTimes(1);
|
|
304
|
+
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(notifyCancelled.mock.invocationCallOrder[0]!).toBeLessThan(
|
|
306
|
+
onCancel.mock.invocationCallOrder[0]!,
|
|
307
|
+
);
|
|
308
|
+
expect(aui().thread.getState().capabilities.cancel).toBe(true);
|
|
309
|
+
expect(aui().thread.composer().getState().canCancel).toBe(true);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("leaves the queue alone when the host cannot cancel", async () => {
|
|
313
|
+
const notifyCancelled = vi.fn();
|
|
314
|
+
const { aui } = renderThread({
|
|
315
|
+
messages: [],
|
|
316
|
+
isRunning: true,
|
|
317
|
+
queue: {
|
|
318
|
+
items: [],
|
|
319
|
+
steerItems: [],
|
|
320
|
+
enqueue: vi.fn(),
|
|
321
|
+
steer: vi.fn(),
|
|
322
|
+
move: vi.fn(),
|
|
323
|
+
edit: vi.fn(),
|
|
324
|
+
remove: vi.fn(),
|
|
325
|
+
__internal_notifyCancelled: notifyCancelled,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
aui().thread.cancelRun();
|
|
330
|
+
|
|
331
|
+
expect(notifyCancelled).not.toHaveBeenCalled();
|
|
332
|
+
expect(aui().thread.getState().capabilities.cancel).toBe(false);
|
|
333
|
+
expect(aui().thread.composer().getState().canCancel).toBe(false);
|
|
334
|
+
});
|
|
335
|
+
|
|
282
336
|
it("routes edit-composer sends to onEdit with sourceId, bypassing the queue", async () => {
|
|
283
337
|
const onEdit = vi.fn();
|
|
284
338
|
const enqueue = vi.fn();
|