@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.
Files changed (107) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  5. package/dist/react/client/interactable-model-context.js +5 -1
  6. package/dist/react/client/interactable-model-context.js.map +1 -1
  7. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  8. package/dist/runtime/api/thread-runtime.js +5 -1
  9. package/dist/runtime/api/thread-runtime.js.map +1 -1
  10. package/dist/runtime/base/base-composer-runtime-core.d.ts +25 -0
  11. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  12. package/dist/runtime/base/base-composer-runtime-core.js +64 -11
  13. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  14. package/dist/runtime/base/base-thread-runtime-core.d.ts +14 -0
  15. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  16. package/dist/runtime/base/base-thread-runtime-core.js +31 -0
  17. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  18. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  19. package/dist/runtime/base/default-edit-composer-runtime-core.js +1 -6
  20. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  21. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  22. package/dist/runtime/base/default-thread-composer-runtime-core.js +1 -4
  23. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  24. package/dist/runtime/queue/external-thread-queue-adapter.d.ts +15 -0
  25. package/dist/runtime/queue/external-thread-queue-adapter.d.ts.map +1 -1
  26. package/dist/runtime/queue/message-queue.js +23 -10
  27. package/dist/runtime/queue/message-queue.js.map +1 -1
  28. package/dist/runtimes/external-store/external-store-adapter.d.ts +7 -0
  29. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  30. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +3 -1
  31. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  32. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +49 -13
  33. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  34. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  35. package/dist/runtimes/local/local-thread-runtime-core.js +2 -1
  36. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  37. package/dist/store/clients/external-thread.d.ts +7 -0
  38. package/dist/store/clients/external-thread.d.ts.map +1 -1
  39. package/dist/store/clients/external-thread.js +460 -369
  40. package/dist/store/clients/external-thread.js.map +1 -1
  41. package/dist/store/clients/runtime-adapter.d.ts.map +1 -1
  42. package/dist/store/clients/runtime-adapter.js +5 -2
  43. package/dist/store/clients/runtime-adapter.js.map +1 -1
  44. package/dist/store/clients/single-thread-list.d.ts.map +1 -1
  45. package/dist/store/clients/single-thread-list.js +31 -27
  46. package/dist/store/clients/single-thread-list.js.map +1 -1
  47. package/dist/store/clients/suggestions.d.ts +3 -1
  48. package/dist/store/clients/suggestions.d.ts.map +1 -1
  49. package/dist/store/clients/suggestions.js +65 -37
  50. package/dist/store/clients/suggestions.js.map +1 -1
  51. package/dist/store/index.d.ts +2 -2
  52. package/dist/store/index.js +1 -1
  53. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  54. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +64 -50
  55. package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
  56. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  57. package/dist/store/runtime-clients/thread-runtime-client.js +98 -82
  58. package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
  59. package/dist/store/scope-registration.d.ts +2 -2
  60. package/dist/store/scopes/suggestions.d.ts +6 -1
  61. package/dist/store/scopes/suggestions.d.ts.map +1 -1
  62. package/dist/store/scopes/thread.d.ts +17 -0
  63. package/dist/store/scopes/thread.d.ts.map +1 -1
  64. package/dist/types/error.d.ts +15 -1
  65. package/dist/types/error.d.ts.map +1 -1
  66. package/dist/types/error.js +18 -1
  67. package/dist/types/error.js.map +1 -1
  68. package/dist/types/message.d.ts +2 -0
  69. package/dist/types/message.d.ts.map +1 -1
  70. package/dist/types/message.js.map +1 -1
  71. package/package.json +5 -5
  72. package/src/index.ts +1 -0
  73. package/src/react/client/interactable-model-context.ts +6 -1
  74. package/src/react/runtimes/useExternalStoreRuntime.suggestions.test.tsx +114 -0
  75. package/src/runtime/api/thread-runtime.ts +8 -1
  76. package/src/runtime/base/base-composer-runtime-core.ts +100 -8
  77. package/src/runtime/base/base-thread-runtime-core.ts +36 -0
  78. package/src/runtime/base/default-edit-composer-runtime-core.ts +1 -15
  79. package/src/runtime/base/default-thread-composer-runtime-core.ts +1 -11
  80. package/src/runtime/queue/external-thread-queue-adapter.ts +17 -0
  81. package/src/runtime/queue/message-queue.ts +26 -9
  82. package/src/runtimes/external-store/external-store-adapter.ts +7 -0
  83. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +100 -26
  84. package/src/runtimes/local/local-thread-runtime-core.ts +4 -1
  85. package/src/store/clients/external-thread.ts +119 -16
  86. package/src/store/clients/runtime-adapter.ts +5 -2
  87. package/src/store/clients/single-thread-list.ts +2 -1
  88. package/src/store/clients/suggestions.ts +39 -13
  89. package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -28
  90. package/src/store/runtime-clients/thread-runtime-client.ts +5 -0
  91. package/src/store/scopes/suggestions.ts +6 -0
  92. package/src/store/scopes/thread.ts +17 -0
  93. package/src/tests/append-interactable-snapshots.test.ts +350 -0
  94. package/src/tests/base-composer-runtime-core-send.test.ts +126 -0
  95. package/src/tests/base-composer-runtime-core.test.ts +108 -1
  96. package/src/tests/default-edit-composer-runtime-core.test.ts +0 -68
  97. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +300 -0
  98. package/src/tests/external-thread-attachments.test.tsx +214 -0
  99. package/src/tests/external-thread-parity.test.tsx +54 -0
  100. package/src/tests/external-thread-refetch.test.tsx +117 -0
  101. package/src/tests/external-thread-suggestions.test.tsx +59 -0
  102. package/src/tests/message-queue.test.ts +26 -0
  103. package/src/tests/thread-message-like.test.ts +39 -0
  104. package/src/tests/thread-switch-events.test.tsx +136 -0
  105. package/src/types/error.ts +24 -0
  106. package/src/types/message.ts +2 -0
  107. 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();