@assistant-ui/core 0.3.11 → 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 (132) hide show
  1. package/dist/index.d.ts +6 -5
  2. package/dist/index.js +2 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/internal.d.ts +6 -5
  5. package/dist/internal.js +5 -4
  6. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  7. package/dist/react/client/interactable-model-context.js +5 -1
  8. package/dist/react/client/interactable-model-context.js.map +1 -1
  9. package/dist/runtime/api/message-runtime.d.ts +2 -3
  10. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  11. package/dist/runtime/api/message-runtime.js +2 -16
  12. package/dist/runtime/api/message-runtime.js.map +1 -1
  13. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  14. package/dist/runtime/api/thread-runtime.js +5 -1
  15. package/dist/runtime/api/thread-runtime.js.map +1 -1
  16. package/dist/runtime/base/base-composer-runtime-core.d.ts +25 -0
  17. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  18. package/dist/runtime/base/base-composer-runtime-core.js +64 -11
  19. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  20. package/dist/runtime/base/base-thread-runtime-core.d.ts +14 -0
  21. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  22. package/dist/runtime/base/base-thread-runtime-core.js +31 -0
  23. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  24. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  25. package/dist/runtime/base/default-edit-composer-runtime-core.js +1 -6
  26. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  27. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  28. package/dist/runtime/base/default-thread-composer-runtime-core.js +1 -4
  29. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  30. package/dist/runtime/internal.d.ts +4 -4
  31. package/dist/runtime/internal.js +4 -4
  32. package/dist/runtime/queue/external-thread-queue-adapter.d.ts +15 -0
  33. package/dist/runtime/queue/external-thread-queue-adapter.d.ts.map +1 -1
  34. package/dist/runtime/queue/message-queue.js +23 -10
  35. package/dist/runtime/queue/message-queue.js.map +1 -1
  36. package/dist/runtimes/external-store/external-store-adapter.d.ts +7 -0
  37. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  38. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +3 -1
  39. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  40. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +49 -13
  41. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  42. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  43. package/dist/runtimes/local/local-thread-runtime-core.js +2 -1
  44. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  45. package/dist/store/clients/external-thread.d.ts +61 -0
  46. package/dist/store/clients/external-thread.d.ts.map +1 -0
  47. package/dist/store/clients/external-thread.js +1445 -0
  48. package/dist/store/clients/external-thread.js.map +1 -0
  49. package/dist/store/clients/runtime-adapter.d.ts.map +1 -1
  50. package/dist/store/clients/runtime-adapter.js +5 -2
  51. package/dist/store/clients/runtime-adapter.js.map +1 -1
  52. package/dist/store/clients/single-thread-list.d.ts +9 -0
  53. package/dist/store/clients/single-thread-list.d.ts.map +1 -0
  54. package/dist/store/clients/single-thread-list.js +175 -0
  55. package/dist/store/clients/single-thread-list.js.map +1 -0
  56. package/dist/store/clients/suggestions.d.ts +3 -1
  57. package/dist/store/clients/suggestions.d.ts.map +1 -1
  58. package/dist/store/clients/suggestions.js +65 -37
  59. package/dist/store/clients/suggestions.js.map +1 -1
  60. package/dist/store/index.d.ts +5 -3
  61. package/dist/store/index.js +4 -2
  62. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  63. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +64 -50
  64. package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
  65. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  66. package/dist/store/runtime-clients/thread-runtime-client.js +98 -82
  67. package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
  68. package/dist/store/scope-registration.d.ts +2 -2
  69. package/dist/store/scopes/suggestions.d.ts +6 -1
  70. package/dist/store/scopes/suggestions.d.ts.map +1 -1
  71. package/dist/store/scopes/thread.d.ts +17 -0
  72. package/dist/store/scopes/thread.d.ts.map +1 -1
  73. package/dist/types/augmentations.d.ts +33 -0
  74. package/dist/types/augmentations.d.ts.map +1 -0
  75. package/dist/types/augmentations.js +0 -0
  76. package/dist/types/error.d.ts +15 -1
  77. package/dist/types/error.d.ts.map +1 -1
  78. package/dist/types/error.js +18 -1
  79. package/dist/types/error.js.map +1 -1
  80. package/dist/types/message.d.ts +2 -0
  81. package/dist/types/message.d.ts.map +1 -1
  82. package/dist/types/message.js.map +1 -1
  83. package/dist/utils/normalizePartStatus.d.ts +3 -2
  84. package/dist/utils/normalizePartStatus.d.ts.map +1 -1
  85. package/dist/utils/normalizePartStatus.js +15 -1
  86. package/dist/utils/normalizePartStatus.js.map +1 -1
  87. package/package.json +5 -5
  88. package/src/index.ts +7 -0
  89. package/src/internal.ts +1 -1
  90. package/src/react/client/interactable-model-context.ts +6 -1
  91. package/src/react/runtimes/useExternalStoreRuntime.suggestions.test.tsx +114 -0
  92. package/src/runtime/api/message-runtime.test.ts +1 -1
  93. package/src/runtime/api/message-runtime.ts +2 -35
  94. package/src/runtime/api/thread-runtime.ts +8 -1
  95. package/src/runtime/base/base-composer-runtime-core.ts +100 -8
  96. package/src/runtime/base/base-thread-runtime-core.ts +36 -0
  97. package/src/runtime/base/default-edit-composer-runtime-core.ts +1 -15
  98. package/src/runtime/base/default-thread-composer-runtime-core.ts +1 -11
  99. package/src/runtime/queue/external-thread-queue-adapter.ts +17 -0
  100. package/src/runtime/queue/message-queue.ts +26 -9
  101. package/src/runtimes/external-store/external-store-adapter.ts +7 -0
  102. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +100 -26
  103. package/src/runtimes/local/local-thread-runtime-core.ts +4 -1
  104. package/src/store/clients/external-thread.ts +1255 -0
  105. package/src/store/clients/runtime-adapter.ts +5 -2
  106. package/src/store/clients/single-thread-list.ts +116 -0
  107. package/src/store/clients/suggestions.ts +39 -13
  108. package/src/store/index.ts +6 -0
  109. package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -28
  110. package/src/store/runtime-clients/thread-runtime-client.ts +5 -0
  111. package/src/store/scopes/suggestions.ts +6 -0
  112. package/src/store/scopes/thread.ts +17 -0
  113. package/src/tests/append-interactable-snapshots.test.ts +350 -0
  114. package/src/tests/base-composer-runtime-core-send.test.ts +126 -0
  115. package/src/tests/base-composer-runtime-core.test.ts +108 -1
  116. package/src/tests/default-edit-composer-runtime-core.test.ts +0 -68
  117. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +300 -0
  118. package/src/tests/external-thread-attachments.test.tsx +584 -0
  119. package/src/tests/external-thread-branches.test.tsx +157 -0
  120. package/src/tests/external-thread-feedback.test.tsx +204 -0
  121. package/src/tests/external-thread-parity.test.tsx +548 -0
  122. package/src/tests/external-thread-refetch.test.tsx +117 -0
  123. package/src/tests/external-thread-speech.test.tsx +328 -0
  124. package/src/tests/external-thread-suggestions.test.tsx +59 -0
  125. package/src/tests/message-queue.test.ts +26 -0
  126. package/src/tests/thread-message-like.test.ts +39 -0
  127. package/src/tests/thread-switch-events.test.tsx +136 -0
  128. package/src/types/augmentations.ts +33 -0
  129. package/src/types/error.ts +24 -0
  130. package/src/types/message.ts +2 -0
  131. package/src/utils/normalizePartStatus.ts +27 -0
  132. package/src/tests/default-thread-composer-runtime-core.test.ts +0 -82
@@ -6,6 +6,7 @@ import {
6
6
  type PendingAttachment,
7
7
  } from "../../types/attachment";
8
8
  import type { MessageRole, AppendMessage } from "../../types/message";
9
+ import { isMessageNotSentError } from "../../types/error";
9
10
  import type { QuoteInfo } from "../../types/quote";
10
11
  import type { Unsubscribe } from "../../types/unsubscribe";
11
12
  import type { RunConfig } from "../../types/message";
@@ -118,15 +119,22 @@ export abstract class BaseComposerRuntimeCore
118
119
  if (this._text === value) return;
119
120
 
120
121
  this._text = value;
121
- if (this._dictation) {
122
- this._dictationBaseText = value;
123
- this._currentInterimText = "";
124
- const { status, inputDisabled } = this._dictation;
125
- this._dictation = inputDisabled ? { status, inputDisabled } : { status };
126
- }
122
+ this._rebaseDictation(value);
127
123
  this._notifySubscribers();
128
124
  }
129
125
 
126
+ // A live dictation session appends to the text it last saw, so any write
127
+ // that bypasses `setText` has to move that baseline or the next transcript
128
+ // overwrites what was just written.
129
+ private _rebaseDictation(value: string) {
130
+ if (!this._dictation) return;
131
+
132
+ this._dictationBaseText = value;
133
+ this._currentInterimText = "";
134
+ const { status, inputDisabled } = this._dictation;
135
+ this._dictation = inputDisabled ? { status, inputDisabled } : { status };
136
+ }
137
+
130
138
  public setRole(role: MessageRole) {
131
139
  if (this._role === role) return;
132
140
 
@@ -245,6 +253,7 @@ export abstract class BaseComposerRuntimeCore
245
253
  if (generation === this._sendGeneration) {
246
254
  if (!this.text.trim() && this._quote === undefined) {
247
255
  this._text = text;
256
+ this._rebaseDictation(text);
248
257
  this._quote = quote;
249
258
  this._notifySubscribers();
250
259
  }
@@ -289,11 +298,94 @@ export abstract class BaseComposerRuntimeCore
289
298
  metadata: { custom: { ...(quote ? { quote } : {}) } },
290
299
  };
291
300
 
292
- const sendTask = this.handleSend(message, options);
293
- if (sendTask) void sendTask.catch(() => {});
301
+ const draft = { text, quote, attachments: finalAttachments };
302
+ let sendTask: void | Promise<void>;
303
+ try {
304
+ sendTask = this.handleSend(message, options);
305
+ } catch (error) {
306
+ this._restoreUnsentDraft(error, generation, draft);
307
+ throw error;
308
+ }
309
+ if (sendTask)
310
+ void sendTask.catch((error) => {
311
+ this._restoreUnsentDraft(error, generation, draft);
312
+ });
294
313
  this._notifyEventSubscribers("send", {});
295
314
  }
296
315
 
316
+ /**
317
+ * Take a message back into the composer when it has nowhere else to live:
318
+ * a send the runtime never dispatched, or a message a cancelled run is
319
+ * removing from the thread. Reports whether the composer accepted it, so a
320
+ * caller that is also removing the message can keep it instead of dropping
321
+ * it. Refused, and left untouched, while the composer holds anything of its
322
+ * own.
323
+ */
324
+ public restoreDraft(draft: {
325
+ text: string;
326
+ quote?: QuoteInfo | undefined;
327
+ attachments?: readonly Attachment[] | undefined;
328
+ }): boolean {
329
+ if (
330
+ this._text.trim() ||
331
+ this._quote !== undefined ||
332
+ this._attachments.length > 0
333
+ )
334
+ return false;
335
+
336
+ this._text = draft.text;
337
+ this._rebaseDictation(draft.text);
338
+ this._quote = draft.quote;
339
+ this._attachments = draft.attachments ?? [];
340
+ this._notifySubscribers();
341
+ return true;
342
+ }
343
+
344
+ /**
345
+ * Inverse of `restoreDraft`: clears the composer while it still holds
346
+ * exactly the given draft. A draft the user has edited since is left
347
+ * untouched.
348
+ */
349
+ public retractDraft(draft: {
350
+ text: string;
351
+ quote?: QuoteInfo | undefined;
352
+ attachments?: readonly Attachment[] | undefined;
353
+ }): void {
354
+ const attachmentsUntouched =
355
+ draft.attachments !== undefined
356
+ ? this._attachments === draft.attachments
357
+ : this._attachments.length === 0;
358
+ if (
359
+ this._text !== draft.text ||
360
+ this._quote !== draft.quote ||
361
+ !attachmentsUntouched
362
+ )
363
+ return;
364
+
365
+ this._text = "";
366
+ this._rebaseDictation("");
367
+ this._quote = undefined;
368
+ this._attachments = [];
369
+ this._notifySubscribers();
370
+ }
371
+
372
+ // The generation check is what a reset and a later send use to invalidate a
373
+ // draft, so of several queued drafts only the most recent one is still
374
+ // restorable.
375
+ private _restoreUnsentDraft(
376
+ error: unknown,
377
+ generation: number,
378
+ draft: {
379
+ text: string;
380
+ quote: QuoteInfo | undefined;
381
+ attachments: readonly CompleteAttachment[];
382
+ },
383
+ ) {
384
+ if (!isMessageNotSentError(error)) return;
385
+ if (generation !== this._sendGeneration) return;
386
+ this.restoreDraft(draft);
387
+ }
388
+
297
389
  public cancel() {
298
390
  this.handleCancel();
299
391
  }
@@ -35,6 +35,7 @@ import type { AttachmentAdapter } from "../../adapters/attachment";
35
35
  import type { RealtimeVoiceAdapter } from "../../adapters/voice";
36
36
  import type { ThreadMessageLike } from "../utils/thread-message-like";
37
37
  import { notifyEventListeners } from "../../utils/notify-event-listeners";
38
+ import { gateInteractableComposerMetadata } from "../../model-context/interactable-composer-metadata";
38
39
 
39
40
  type BaseThreadAdapters = {
40
41
  speech?: SpeechSynthesisAdapter | undefined;
@@ -123,6 +124,41 @@ export abstract class BaseThreadRuntimeCore implements ThreadRuntimeCore {
123
124
  return this._contextProvider.getModelContext();
124
125
  }
125
126
 
127
+ /**
128
+ * Stamps provider-contributed composer metadata onto an outgoing message.
129
+ * Called at dispatch rather than in the composer, so programmatic sends are
130
+ * covered too, and exactly once per message: a queued send is stamped when
131
+ * it leaves the lane, never when it enters.
132
+ *
133
+ * Only user messages are stamped, matching the readers: both the version
134
+ * fold and the model injection skip every other role.
135
+ *
136
+ * @param anchorId Message the gated branch prefix ends at. A queued send
137
+ * passes the current tail, having waited through a run that grew the prefix
138
+ * past the parent it was created with.
139
+ */
140
+ protected enrichAppendMetadata(
141
+ message: AppendMessage,
142
+ anchorId: string | null = message.parentId,
143
+ ): AppendMessage {
144
+ if (message.role !== "user") return message;
145
+ const messages = this.messages;
146
+ const parentIndex =
147
+ anchorId === null ? -1 : messages.findIndex((m) => m.id === anchorId);
148
+ const composerMetadata = gateInteractableComposerMetadata(
149
+ this.getModelContext().unstable_composerMetadata,
150
+ messages.slice(0, parentIndex + 1),
151
+ );
152
+ if (!composerMetadata) return message;
153
+ return {
154
+ ...message,
155
+ metadata: {
156
+ ...message.metadata,
157
+ custom: { ...message.metadata?.custom, ...composerMetadata },
158
+ },
159
+ };
160
+ }
161
+
126
162
  private _editComposers = new Map<string, DefaultEditComposerRuntimeCore>();
127
163
  public getEditComposer(messageId: string) {
128
164
  return this._editComposers.get(messageId);
@@ -7,7 +7,6 @@ import type { DictationAdapter } from "../../adapters/speech";
7
7
  import type { SendOptions } from "../interfaces/composer-runtime-core";
8
8
  import type { ThreadRuntimeCore } from "../interfaces/thread-runtime-core";
9
9
  import { BaseComposerRuntimeCore } from "./base-composer-runtime-core";
10
- import { gateInteractableComposerMetadata } from "../../model-context/interactable-composer-metadata";
11
10
 
12
11
  export class DefaultEditComposerRuntimeCore extends BaseComposerRuntimeCore {
13
12
  public get canCancel() {
@@ -97,21 +96,8 @@ export class DefaultEditComposerRuntimeCore extends BaseComposerRuntimeCore {
97
96
  ...this._nonTextPassthrough,
98
97
  ] as AppendMessage["content"])
99
98
  : message.content;
100
- // Gate live state against the new branch's prefix (messages up to the
101
- // parent): an unchanged interactable re-stamps the prior baseline, an
102
- // interactable edited since the original message stamps its newest state.
103
- const messages = this.runtime.messages;
104
- const parentIndex =
105
- this._parentId === null
106
- ? -1
107
- : messages.findIndex((m) => m.id === this._parentId);
108
- const composerMetadata = gateInteractableComposerMetadata(
109
- this.runtime.getModelContext().unstable_composerMetadata,
110
- messages.slice(0, parentIndex + 1),
111
- );
112
- const enriched = this.enrichWithComposerMetadata(message, composerMetadata);
113
99
  const appendTask = this.runtime.append({
114
- ...enriched,
100
+ ...message,
115
101
  content,
116
102
  parentId: this._parentId,
117
103
  sourceId: this._sourceId,
@@ -12,7 +12,6 @@ import {
12
12
  type QueueItemState,
13
13
  } from "../../store/scopes/queue-item";
14
14
  import { BaseComposerRuntimeCore } from "./base-composer-runtime-core";
15
- import { gateInteractableComposerMetadata } from "../../model-context/interactable-composer-metadata";
16
15
 
17
16
  export class DefaultThreadComposerRuntimeCore
18
17
  extends BaseComposerRuntimeCore
@@ -119,17 +118,8 @@ export class DefaultThreadComposerRuntimeCore
119
118
  message: Omit<AppendMessage, "parentId" | "sourceId">,
120
119
  options?: SendOptions,
121
120
  ) {
122
- // Merge provider-contributed metadata onto the outgoing user message
123
- // (same metadata.custom append path quotes ride). The interactables gate
124
- // runs here because it needs thread history, unavailable to the provider.
125
- const composerMetadata = gateInteractableComposerMetadata(
126
- this.runtime.getModelContext().unstable_composerMetadata,
127
- this.runtime.messages,
128
- );
129
- const enriched = this.enrichWithComposerMetadata(message, composerMetadata);
130
-
131
121
  return this.runtime.append({
132
- ...(enriched as AppendMessage),
122
+ ...(message as AppendMessage),
133
123
  parentId: this.runtime.messages.at(-1)?.id ?? null,
134
124
  sourceId: null,
135
125
  startRun: options?.startRun,
@@ -27,4 +27,21 @@ export type ExternalThreadQueueAdapter = {
27
27
  move: (queueItemId: string, placement: QueuePlacement) => void;
28
28
  edit: (queueItemId: string, message: AppendMessage) => void;
29
29
  remove: (queueItemId: string) => void;
30
+ /**
31
+ * Installed by the owning runtime, applied to a message as it leaves a lane.
32
+ * A message can wait through an entire run, so state the runtime attaches at
33
+ * send time is recomputed against the thread it actually lands on. Adapters
34
+ * from `createMessageQueue` provide this; a hand-rolled adapter that omits
35
+ * it dispatches whatever was enqueued.
36
+ */
37
+ __internal_setDispatchTransform?:
38
+ | ((transform: (message: AppendMessage) => AppendMessage) => void)
39
+ | undefined;
40
+ /**
41
+ * Called by the owning runtime when the user cancels the run, before the run
42
+ * is aborted. Pauses the queue so the cancelled run's settle keeps the
43
+ * pending items instead of dispatching the next one; the next send re-arms
44
+ * draining. A hand-rolled adapter that omits it drains on cancel.
45
+ */
46
+ __internal_notifyCancelled?: (() => void) | undefined;
30
47
  };
@@ -79,10 +79,12 @@ export const createMessageQueue = (
79
79
 
80
80
  let running = false;
81
81
  let paused = false;
82
+ let dispatchTransform: (message: AppendMessage) => AppendMessage = (m) => m;
82
83
  // swallow the cancelled run's settle when steering so it does not double-advance
83
84
  let suppressIdle = 0;
84
85
  // settles from cancelled runs that must drop `running` without advancing
85
86
  let cancelSettles = 0;
87
+ let interrupting = false;
86
88
 
87
89
  const notify = () => {
88
90
  for (const callback of subscribers) callback();
@@ -117,7 +119,7 @@ export const createMessageQueue = (
117
119
  setLanes({ ...lanes, [lane]: lanes[lane].slice(1) });
118
120
  if (!message) return;
119
121
  running = true;
120
- driver.run(message, { steer: false });
122
+ driver.run(dispatchTransform(message), { steer: false });
121
123
  };
122
124
 
123
125
  const interrupt = (message: AppendMessage) => {
@@ -126,9 +128,17 @@ export const createMessageQueue = (
126
128
  // already cancel-notified
127
129
  suppressIdle += Math.max(cancelSettles, 1);
128
130
  cancelSettles = 0;
129
- driver.cancel!();
131
+ // a driver whose cancel routes through the runtime notifies this queue
132
+ // back; the interrupt already accounted for that settle and is dispatching
133
+ // in its place
134
+ interrupting = true;
135
+ try {
136
+ driver.cancel!();
137
+ } finally {
138
+ interrupting = false;
139
+ }
130
140
  running = true;
131
- driver.run(message, { steer: true });
141
+ driver.run(dispatchTransform(message), { steer: true });
132
142
  };
133
143
 
134
144
  const push = (lane: Lane, message: AppendMessage) => {
@@ -241,6 +251,14 @@ export const createMessageQueue = (
241
251
  });
242
252
  };
243
253
 
254
+ const notifyCancelled = () => {
255
+ if (interrupting) return;
256
+ if (running && cancelSettles === 0) {
257
+ paused = true;
258
+ cancelSettles = 1;
259
+ }
260
+ };
261
+
244
262
  const adapter: ExternalThreadQueueAdapter = {
245
263
  items: lanes.queue,
246
264
  steerItems: lanes.steer,
@@ -249,6 +267,10 @@ export const createMessageQueue = (
249
267
  move,
250
268
  edit,
251
269
  remove,
270
+ __internal_setDispatchTransform: (transform) => {
271
+ dispatchTransform = transform;
272
+ },
273
+ __internal_notifyCancelled: notifyCancelled,
252
274
  };
253
275
 
254
276
  return {
@@ -270,12 +292,7 @@ export const createMessageQueue = (
270
292
  running = false;
271
293
  advance();
272
294
  },
273
- notifyCancelled: () => {
274
- if (running && cancelSettles === 0) {
275
- paused = true;
276
- cancelSettles = 1;
277
- }
278
- },
295
+ notifyCancelled,
279
296
  clear: () => {
280
297
  messages.clear();
281
298
  setLanes({ queue: EMPTY_QUEUE_ITEMS, steer: EMPTY_QUEUE_ITEMS });
@@ -109,6 +109,13 @@ type ExternalStoreAdapterBase<T> = {
109
109
  state?: ReadonlyJSONValue | undefined;
110
110
  extras?: unknown;
111
111
 
112
+ /**
113
+ * Applies a message list the runtime rewrote, and is what tells the runtime
114
+ * a removal it makes will survive the next snapshot. Without it, cancelling a
115
+ * run leaves a trailing user message in the thread and the composer
116
+ * untouched; an adapter that removes that message itself owns handing it
117
+ * back, because the runtime cannot see a removal it did not make.
118
+ */
112
119
  setMessages?: ((messages: readonly T[]) => void) | undefined;
113
120
  /**
114
121
  * Fires when the user explicitly switches branches via the runtime's
@@ -1,4 +1,5 @@
1
1
  import type { AppendMessage, ThreadMessage } from "../../types/message";
2
+ import type { Attachment } from "../../types/attachment";
2
3
  import type {
3
4
  AddToolResultOptions,
4
5
  ResumeRunConfig,
@@ -27,7 +28,10 @@ import type {
27
28
  RuntimeCapabilities,
28
29
  ThreadRuntimeCore,
29
30
  } from "../../runtime/interfaces/thread-runtime-core";
30
- import type { QueuePlacement } from "../../runtime/queue/external-thread-queue-adapter";
31
+ import type {
32
+ ExternalThreadQueueAdapter,
33
+ QueuePlacement,
34
+ } from "../../runtime/queue/external-thread-queue-adapter";
31
35
  import { BaseThreadRuntimeCore } from "../../runtime/base/base-thread-runtime-core";
32
36
  import type { ModelContextProvider } from "../../model-context/types";
33
37
  import {
@@ -37,6 +41,7 @@ import {
37
41
  import { generateId } from "../../utils/id";
38
42
  import { ToolInvocationTracker } from "../tool-invocations/ToolInvocationTracker";
39
43
  import { EMPTY_QUEUE_ITEMS } from "../../store/scopes/queue-item";
44
+ import type { QuoteInfo } from "../../types/quote";
40
45
 
41
46
  const EMPTY_ARRAY: readonly ThreadSuggestion[] = Object.freeze([]);
42
47
 
@@ -117,6 +122,8 @@ export class ExternalStoreThreadRuntimeCore
117
122
 
118
123
  private _store!: ExternalStoreAdapter<any>;
119
124
 
125
+ private _transformedQueue: ExternalThreadQueueAdapter | undefined;
126
+
120
127
  /**
121
128
  * Client-side tool-invocations pipeline. Constructed lazily on first
122
129
  * snapshot — only when `adapter.unstable_enableToolInvocations === true`.
@@ -147,6 +154,19 @@ export class ExternalStoreThreadRuntimeCore
147
154
 
148
155
  const oldStore = this._store as ExternalStoreAdapter<any> | undefined;
149
156
  this._store = store;
157
+ if (oldStore?.queue !== store.queue) {
158
+ this._transformedQueue = undefined;
159
+ store.queue?.__internal_setDispatchTransform?.((message) => {
160
+ // Re-point at the tail, as LocalThreadRuntimeCore's driver does, so
161
+ // the prefix gated against is the one the message lands on whatever
162
+ // the host routes by. Queuing only ever accepts a tail append, so a
163
+ // later tail is the same intent.
164
+ const parentId = this.messages.at(-1)?.id ?? null;
165
+ return this.enrichAppendMetadata({ ...message, parentId }, parentId);
166
+ });
167
+ if (store.queue?.__internal_setDispatchTransform)
168
+ this._transformedQueue = store.queue;
169
+ }
150
170
  if (this.extras !== store.extras) {
151
171
  this.extras = store.extras;
152
172
  }
@@ -465,22 +485,30 @@ export class ExternalStoreThreadRuntimeCore
465
485
  });
466
486
  }
467
487
 
468
- public async append(message: AppendMessage): Promise<void> {
488
+ public async append(rawMessage: AppendMessage): Promise<void> {
469
489
  // sourceId marks an edit send; the parent may coincide with the head
470
490
  // after a resync (e.g. cancelRun dropped the edited message).
471
491
  const isEdit =
472
- message.sourceId != null ||
473
- message.parentId !== (this.messages.at(-1)?.id ?? null);
492
+ rawMessage.sourceId != null ||
493
+ rawMessage.parentId !== (this.messages.at(-1)?.id ?? null);
474
494
 
475
495
  // Buffering does not start a run, so the tool-abort below must wait until
476
496
  // the queue flushes. By then the prior run (and its tools) has settled.
477
497
  if (!isEdit && this._store.queue) {
478
- if (message.steer ?? this._store.isRunning ?? false)
479
- this._store.queue.steer(message);
480
- else this._store.queue.enqueue(message);
498
+ // Skip only for the queue this core actually installed on: another
499
+ // core's transform would gate against its own thread's messages.
500
+ const queued =
501
+ this._store.queue === this._transformedQueue
502
+ ? rawMessage
503
+ : this.enrichAppendMetadata(rawMessage);
504
+ if (queued.steer ?? this._store.isRunning ?? false)
505
+ this._store.queue.steer(queued);
506
+ else this._store.queue.enqueue(queued);
481
507
  return;
482
508
  }
483
509
 
510
+ const message = this.enrichAppendMetadata(rawMessage);
511
+
484
512
  // Auto-abort in-flight client-side tool executions when a new run is
485
513
  // about to start. Without this, a tool that finishes after the new turn
486
514
  // begins would feed a stale result into `onAddToolResult`, racing with
@@ -592,37 +620,83 @@ export class ExternalStoreThreadRuntimeCore
592
620
  // cancel on it.
593
621
  void this._toolInvocations?.abort();
594
622
 
623
+ // Before the run is aborted, so the settle it produces keeps the pending
624
+ // items instead of dispatching the next one at the moment the user
625
+ // stopped.
626
+ this._store.queue?.__internal_notifyCancelled?.();
627
+
595
628
  this._store.onCancel();
596
629
 
597
- // Drop an empty optimistic head (placeholder or pre-stream message); a
598
- // partially-streamed one is kept and re-supplied by the store on resync.
599
- const head = this.repository.getMessages().at(-1);
600
- if (head && head.metadata.isOptimistic && head.content.length === 0) {
601
- this.repository.deleteMessage(head.id);
602
- }
630
+ this.dropEmptyOptimisticHead();
603
631
 
604
- let messages = this.repository.getMessages();
632
+ const messages = this.repository.getMessages();
605
633
  const previousMessage = messages[messages.length - 1];
606
- if (
634
+ const trailingUserLeaf =
635
+ this._store.setMessages !== undefined &&
607
636
  previousMessage?.role === "user" &&
608
- previousMessage.id === messages.at(-1)?.id // ensure the previous message is a leaf node
609
- ) {
610
- this.repository.deleteMessage(previousMessage.id);
611
- if (!this.composer.text.trim()) {
612
- this.composer.setText(getThreadMessageText(previousMessage));
637
+ previousMessage.id === messages.at(-1)?.id && // ensure the previous message is a leaf node
638
+ previousMessage.content.every((part) => part.type === "text")
639
+ ? previousMessage
640
+ : undefined;
641
+
642
+ // Handing the message to the composer and taking it out of the thread are
643
+ // one move: the composer refuses while the user is writing, and removing
644
+ // the message then would leave it nowhere. A message the composer cannot
645
+ // hold whole, carrying content parts it has no home for, is not moved.
646
+ let movedLeaf:
647
+ | {
648
+ id: string;
649
+ draft: {
650
+ text: string;
651
+ attachments: readonly Attachment[];
652
+ quote: QuoteInfo | undefined;
653
+ };
654
+ }
655
+ | undefined;
656
+ if (trailingUserLeaf) {
657
+ const draft = {
658
+ text: getThreadMessageText(trailingUserLeaf),
659
+ attachments: trailingUserLeaf.attachments,
660
+ quote: trailingUserLeaf.metadata.custom.quote as QuoteInfo | undefined,
661
+ };
662
+ if (this.composer.restoreDraft(draft)) {
663
+ this.repository.deleteMessage(trailingUserLeaf.id);
664
+ movedLeaf = { id: trailingUserLeaf.id, draft };
613
665
  }
614
-
615
- messages = this.repository.getMessages();
616
- } else {
617
- this._notifySubscribers();
618
666
  }
667
+ if (!movedLeaf) this._notifySubscribers();
619
668
 
620
- // resync messages (for reloading, to restore the previous branch)
669
+ // The resync commits what the cancel left (a kept optimistic message, the
670
+ // restored branch) back to the store a macrotask later. The store may move
671
+ // in that gap; a server settling the cancelled turn lands in the same
672
+ // tick. Read the repository at flush time and re-apply the rollbacks to
673
+ // it, instead of stamping a snapshot captured above over the newer state.
621
674
  setTimeout(() => {
622
- this.updateMessages(messages);
675
+ this.dropEmptyOptimisticHead();
676
+ if (movedLeaf) {
677
+ const current = this.repository.getMessages();
678
+ if (current.at(-1)?.id === movedLeaf.id) {
679
+ // Unanswered tail: the removal has not reached the store yet.
680
+ this.repository.deleteMessage(movedLeaf.id);
681
+ } else if (current.some((m) => m.id === movedLeaf.id)) {
682
+ // The store kept the turn in the thread; take the untouched draft
683
+ // back so the same content does not sit in both places.
684
+ this.composer.retractDraft(movedLeaf.draft);
685
+ }
686
+ }
687
+ this.updateMessages(this.repository.getMessages());
623
688
  }, 0);
624
689
  }
625
690
 
691
+ // Placeholder or pre-stream message; a partially-streamed one is kept and
692
+ // committed to the store by the cancel resync.
693
+ private dropEmptyOptimisticHead(): void {
694
+ const head = this.repository.getMessages().at(-1);
695
+ if (head && head.metadata.isOptimistic && head.content.length === 0) {
696
+ this.repository.deleteMessage(head.id);
697
+ }
698
+ }
699
+
626
700
  public addToolResult(options: AddToolResultOptions) {
627
701
  if (!this._store.onAddToolResult)
628
702
  throw new Error("Runtime does not support tool results.");
@@ -312,7 +312,10 @@ export class LocalThreadRuntimeCore
312
312
  this._queue?.adapter.remove(queueItemId);
313
313
  }
314
314
 
315
- private async _runAppend(message: AppendMessage): Promise<void> {
315
+ private async _runAppend(rawMessage: AppendMessage): Promise<void> {
316
+ // Stamped here rather than in `append` so a queued message is gated after
317
+ // the flush re-pointed its parentId at the current tail.
318
+ const message = this.enrichAppendMetadata(rawMessage);
316
319
  this.ensureInitialized();
317
320
 
318
321
  const initPromise = this._getInitializePromise?.();