@assistant-ui/core 0.3.4 → 0.3.6

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 (147) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.js.map +1 -1
  3. package/dist/internal.js +1 -1
  4. package/dist/react/AssistantProvider.d.ts +3 -2
  5. package/dist/react/AssistantProvider.d.ts.map +1 -1
  6. package/dist/react/AssistantProvider.js +19 -26
  7. package/dist/react/AssistantProvider.js.map +1 -1
  8. package/dist/react/AssistantRuntimeProvider.d.ts +7 -2
  9. package/dist/react/AssistantRuntimeProvider.d.ts.map +1 -1
  10. package/dist/react/AssistantRuntimeProvider.js +9 -7
  11. package/dist/react/AssistantRuntimeProvider.js.map +1 -1
  12. package/dist/react/providers/AttachmentByIndexProvider.d.ts.map +1 -1
  13. package/dist/react/providers/AttachmentByIndexProvider.js +33 -39
  14. package/dist/react/providers/AttachmentByIndexProvider.js.map +1 -1
  15. package/dist/react/providers/ChainOfThoughtByIndicesProvider.d.ts.map +1 -1
  16. package/dist/react/providers/ChainOfThoughtByIndicesProvider.js +15 -20
  17. package/dist/react/providers/ChainOfThoughtByIndicesProvider.js.map +1 -1
  18. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +17 -20
  19. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js.map +1 -1
  20. package/dist/react/providers/MessageByIdProvider.js +28 -38
  21. package/dist/react/providers/MessageByIdProvider.js.map +1 -1
  22. package/dist/react/providers/MessageByIndexProvider.js +28 -38
  23. package/dist/react/providers/MessageByIndexProvider.js.map +1 -1
  24. package/dist/react/providers/PartByIndexProvider.js +17 -20
  25. package/dist/react/providers/PartByIndexProvider.js.map +1 -1
  26. package/dist/react/providers/QueueItemByIndexProvider.js +21 -21
  27. package/dist/react/providers/QueueItemByIndexProvider.js.map +1 -1
  28. package/dist/react/providers/ReadonlyThreadProvider.js +24 -32
  29. package/dist/react/providers/ReadonlyThreadProvider.js.map +1 -1
  30. package/dist/react/providers/SuggestionByIndexProvider.js +17 -20
  31. package/dist/react/providers/SuggestionByIndexProvider.js.map +1 -1
  32. package/dist/react/providers/TextMessagePartProvider.d.ts.map +1 -1
  33. package/dist/react/providers/TextMessagePartProvider.js +16 -19
  34. package/dist/react/providers/TextMessagePartProvider.js.map +1 -1
  35. package/dist/react/providers/ThreadListItemByIndexProvider.js +17 -20
  36. package/dist/react/providers/ThreadListItemByIndexProvider.js.map +1 -1
  37. package/dist/react/providers/ThreadListItemRuntimeProvider.js +15 -18
  38. package/dist/react/providers/ThreadListItemRuntimeProvider.js.map +1 -1
  39. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +7 -3
  40. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  41. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +5 -2
  42. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  43. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +4 -3
  44. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  45. package/dist/react/runtimes/useLocalRuntime.d.ts +3 -1
  46. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  47. package/dist/react/runtimes/useLocalRuntime.js +4 -2
  48. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  49. package/dist/runtime/api/composer-runtime.d.ts +5 -1
  50. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  51. package/dist/runtime/api/composer-runtime.js +8 -1
  52. package/dist/runtime/api/composer-runtime.js.map +1 -1
  53. package/dist/runtime/api/message-runtime.js +1 -1
  54. package/dist/runtime/api/message-runtime.js.map +1 -1
  55. package/dist/runtime/api/thread-runtime.d.ts +4 -2
  56. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  57. package/dist/runtime/base/base-composer-runtime-core.d.ts +2 -1
  58. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  59. package/dist/runtime/base/base-composer-runtime-core.js +1 -1
  60. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  61. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts +3 -1
  62. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  63. package/dist/runtime/base/default-thread-composer-runtime-core.js +14 -3
  64. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  65. package/dist/runtime/interfaces/composer-runtime-core.d.ts +2 -1
  66. package/dist/runtime/interfaces/composer-runtime-core.d.ts.map +1 -1
  67. package/dist/runtime/interfaces/thread-runtime-core.d.ts +3 -1
  68. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  69. package/dist/runtime/queue/external-thread-queue-adapter.d.ts +19 -6
  70. package/dist/runtime/queue/external-thread-queue-adapter.d.ts.map +1 -1
  71. package/dist/runtime/queue/message-queue.d.ts +10 -1
  72. package/dist/runtime/queue/message-queue.d.ts.map +1 -1
  73. package/dist/runtime/queue/message-queue.js +138 -36
  74. package/dist/runtime/queue/message-queue.js.map +1 -1
  75. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  76. package/dist/runtime/utils/thread-message-like.js +2 -1
  77. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  78. package/dist/runtimes/external-store/external-store-adapter.d.ts +1 -1
  79. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +3 -1
  80. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  81. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +7 -6
  82. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  83. package/dist/runtimes/local/local-runtime-options.d.ts +13 -0
  84. package/dist/runtimes/local/local-runtime-options.d.ts.map +1 -1
  85. package/dist/runtimes/local/local-thread-runtime-core.d.ts +5 -1
  86. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  87. package/dist/runtimes/local/local-thread-runtime-core.js +30 -14
  88. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  89. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -1
  90. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +1 -1
  91. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  92. package/dist/runtimes/remote-thread-list/empty-thread-core.js +1 -1
  93. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  94. package/dist/runtimes/remote-thread-list/types.d.ts +6 -3
  95. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  96. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  97. package/dist/store/runtime-clients/composer-runtime-client.js +11 -4
  98. package/dist/store/runtime-clients/composer-runtime-client.js.map +1 -1
  99. package/dist/store/scopes/composer.d.ts +7 -3
  100. package/dist/store/scopes/composer.d.ts.map +1 -1
  101. package/dist/store/scopes/queue-item.d.ts +10 -0
  102. package/dist/store/scopes/queue-item.d.ts.map +1 -1
  103. package/dist/store/scopes/queue-item.js.map +1 -1
  104. package/package.json +6 -6
  105. package/src/index.ts +5 -1
  106. package/src/react/AssistantProvider.tsx +20 -18
  107. package/src/react/AssistantRuntimeProvider.test.tsx +55 -1
  108. package/src/react/AssistantRuntimeProvider.tsx +12 -2
  109. package/src/react/providers/AttachmentByIndexProvider.tsx +15 -7
  110. package/src/react/providers/ChainOfThoughtByIndicesProvider.tsx +12 -4
  111. package/src/react/providers/ChainOfThoughtPartByIndexProvider.tsx +8 -4
  112. package/src/react/providers/MessageByIdProvider.tsx +8 -4
  113. package/src/react/providers/MessageByIndexProvider.tsx +8 -4
  114. package/src/react/providers/PartByIndexProvider.tsx +8 -4
  115. package/src/react/providers/QueueItemByIndexProvider.tsx +9 -5
  116. package/src/react/providers/ReadonlyThreadProvider.tsx +9 -4
  117. package/src/react/providers/SuggestionByIndexProvider.tsx +8 -4
  118. package/src/react/providers/TextMessagePartProvider.tsx +13 -4
  119. package/src/react/providers/ThreadListItemByIndexProvider.tsx +8 -4
  120. package/src/react/providers/ThreadListItemRuntimeProvider.tsx +8 -4
  121. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +23 -5
  122. package/src/react/runtimes/useLocalRuntime.ts +4 -0
  123. package/src/runtime/api/composer-runtime.ts +11 -2
  124. package/src/runtime/api/message-runtime.test.ts +25 -0
  125. package/src/runtime/api/message-runtime.ts +1 -1
  126. package/src/runtime/base/base-composer-runtime-core.ts +5 -1
  127. package/src/runtime/base/default-thread-composer-runtime-core.ts +27 -3
  128. package/src/runtime/interfaces/composer-runtime-core.ts +2 -1
  129. package/src/runtime/interfaces/thread-runtime-core.ts +3 -1
  130. package/src/runtime/queue/external-thread-queue-adapter.ts +19 -3
  131. package/src/runtime/queue/message-queue.ts +200 -38
  132. package/src/runtime/utils/thread-message-like.ts +2 -4
  133. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +10 -8
  134. package/src/runtimes/local/local-runtime-options.ts +15 -0
  135. package/src/runtimes/local/local-thread-runtime-core.test.ts +43 -0
  136. package/src/runtimes/local/local-thread-runtime-core.ts +48 -13
  137. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +1 -1
  138. package/src/runtimes/remote-thread-list/empty-thread-core.ts +1 -1
  139. package/src/runtimes/remote-thread-list/types.ts +9 -2
  140. package/src/store/runtime-clients/composer-runtime-client.ts +13 -5
  141. package/src/store/scopes/composer.ts +6 -4
  142. package/src/store/scopes/queue-item.ts +8 -1
  143. package/src/tests/RemoteThreadListThreadListRuntimeCore-switchToThread-order.test.ts +31 -0
  144. package/src/tests/external-store-thread-runtime-core.test.ts +56 -13
  145. package/src/tests/framework-neutral-boundary.test.ts +221 -0
  146. package/src/tests/message-queue.test.ts +494 -34
  147. package/src/tests/thread-message-like.test.ts +66 -0
@@ -4,6 +4,7 @@ import type { QuoteInfo } from "../../types/quote";
4
4
  import type { Unsubscribe } from "../../types/unsubscribe";
5
5
  import type { RunConfig } from "../../types/message";
6
6
  import type { QueueItemState } from "../../store/scopes/queue-item";
7
+ import type { QueuePlacement } from "../queue/external-thread-queue-adapter";
7
8
  import {
8
9
  LazyMemoizeSubject,
9
10
  EventSubscriptionSubject,
@@ -200,9 +201,12 @@ export type ComposerRuntime = {
200
201
  */
201
202
  cancel(): void;
202
203
 
203
- /** Promote a queued message so it processes next. */
204
+ /** @deprecated Use `moveQueueItem(queueItemId, { lane: "steer", insertAfter: null })` instead. Removal after 2026-11-05. */
204
205
  steerQueueItem(queueItemId: string): void;
205
206
 
207
+ /** Move a queued message between lanes or within a lane. */
208
+ moveQueueItem(queueItemId: string, placement: QueuePlacement): void;
209
+
206
210
  /** Remove a queued message. */
207
211
  removeQueueItem(queueItemId: string): void;
208
212
 
@@ -268,6 +272,7 @@ export abstract class ComposerRuntimeImpl implements ComposerRuntime {
268
272
  this.send = this.send.bind(this);
269
273
  this.cancel = this.cancel.bind(this);
270
274
  this.steerQueueItem = this.steerQueueItem.bind(this);
275
+ this.moveQueueItem = this.moveQueueItem.bind(this);
271
276
  this.removeQueueItem = this.removeQueueItem.bind(this);
272
277
  this.setRole = this.setRole.bind(this);
273
278
  this.getAttachmentByIndex = this.getAttachmentByIndex.bind(this);
@@ -322,9 +327,13 @@ export abstract class ComposerRuntimeImpl implements ComposerRuntime {
322
327
  }
323
328
 
324
329
  public steerQueueItem(queueItemId: string) {
330
+ this.moveQueueItem(queueItemId, { lane: "steer", insertAfter: null });
331
+ }
332
+
333
+ public moveQueueItem(queueItemId: string, placement: QueuePlacement) {
325
334
  const core = this._core.getState();
326
335
  if (!core) throw new Error("Composer is not available");
327
- core.steerQueueItem(queueItemId);
336
+ core.moveQueueItem(queueItemId, placement);
328
337
  }
329
338
 
330
339
  public removeQueueItem(queueItemId: string) {
@@ -173,6 +173,31 @@ describe("toMessagePartStatus", () => {
173
173
  });
174
174
  });
175
175
 
176
+ it.each([
177
+ ["false", false],
178
+ ["zero", 0],
179
+ ["an empty string", ""],
180
+ ["null", null],
181
+ ])("treats %s tool results as complete", (_label, result) => {
182
+ const message = createAssistantMessage(
183
+ [
184
+ {
185
+ type: "tool-call",
186
+ toolCallId: "call-1",
187
+ toolName: "weather",
188
+ args: {},
189
+ argsText: "{}",
190
+ result,
191
+ },
192
+ ],
193
+ { type: "running" },
194
+ );
195
+
196
+ expect(toMessagePartStatus(message, 0, message.content[0]!)).toEqual({
197
+ type: "complete",
198
+ });
199
+ });
200
+
176
201
  it("normalizes supplied upstream statuses", () => {
177
202
  const upstreamComplete = {
178
203
  type: "text",
@@ -44,7 +44,7 @@ export const toMessagePartStatus = (
44
44
  if (message.role !== "assistant") return COMPLETE_STATUS;
45
45
 
46
46
  if (part.type === "tool-call") {
47
- if (!part.result) {
47
+ if (part.result === undefined) {
48
48
  return message.status as ToolCallMessagePartStatus;
49
49
  } else {
50
50
  return COMPLETE_STATUS;
@@ -24,6 +24,7 @@ import type {
24
24
  SendOptions,
25
25
  } from "../interfaces/composer-runtime-core";
26
26
  import type { DictationAdapter } from "../../adapters/speech";
27
+ import type { QueuePlacement } from "../queue/external-thread-queue-adapter";
27
28
  import {
28
29
  EMPTY_QUEUE_ITEMS,
29
30
  type QueueItemState,
@@ -301,7 +302,10 @@ export abstract class BaseComposerRuntimeCore
301
302
  return EMPTY_QUEUE_ITEMS;
302
303
  }
303
304
 
304
- public steerQueueItem(_queueItemId: string): void {}
305
+ public moveQueueItem(
306
+ _queueItemId: string,
307
+ _placement: QueuePlacement,
308
+ ): void {}
305
309
  public removeQueueItem(_queueItemId: string): void {}
306
310
 
307
311
  protected abstract handleSend(
@@ -6,6 +6,7 @@ import type {
6
6
  ThreadComposerRuntimeCore,
7
7
  } from "../interfaces/composer-runtime-core";
8
8
  import type { ThreadRuntimeCore } from "../interfaces/thread-runtime-core";
9
+ import type { QueuePlacement } from "../queue/external-thread-queue-adapter";
9
10
  import {
10
11
  EMPTY_QUEUE_ITEMS,
11
12
  type QueueItemState,
@@ -26,12 +27,35 @@ export class DefaultThreadComposerRuntimeCore
26
27
  return !this.isEmpty && !this.runtime.isSendDisabled && !this._isSending;
27
28
  }
28
29
 
30
+ private _queueCache:
31
+ | {
32
+ steer: readonly QueueItemState[];
33
+ queue: readonly QueueItemState[];
34
+ flat: readonly QueueItemState[];
35
+ }
36
+ | undefined;
37
+
29
38
  public override get queue(): readonly QueueItemState[] {
30
- return this.runtime.getQueueItems?.() ?? EMPTY_QUEUE_ITEMS;
39
+ const steer = this.runtime.getSteerQueueItems?.() ?? EMPTY_QUEUE_ITEMS;
40
+ const queue = this.runtime.getQueueItems?.() ?? EMPTY_QUEUE_ITEMS;
41
+ const cache = this._queueCache;
42
+ if (cache && cache.steer === steer && cache.queue === queue)
43
+ return cache.flat;
44
+ const flat =
45
+ steer.length === 0
46
+ ? queue
47
+ : queue.length === 0
48
+ ? steer
49
+ : [...steer, ...queue];
50
+ this._queueCache = { steer, queue, flat };
51
+ return flat;
31
52
  }
32
53
 
33
- public override steerQueueItem(queueItemId: string): void {
34
- this.runtime.steerQueueItem?.(queueItemId);
54
+ public override moveQueueItem(
55
+ queueItemId: string,
56
+ placement: QueuePlacement,
57
+ ): void {
58
+ this.runtime.moveQueueItem?.(queueItemId, placement);
35
59
  }
36
60
 
37
61
  public override removeQueueItem(queueItemId: string): void {
@@ -5,6 +5,7 @@ import type { Unsubscribe } from "../../types/unsubscribe";
5
5
  import type { RunConfig } from "../../types/message";
6
6
  import type { DictationAdapter } from "../../adapters/speech";
7
7
  import type { QueueItemState } from "../../store/scopes/queue-item";
8
+ import type { QueuePlacement } from "../queue/external-thread-queue-adapter";
8
9
 
9
10
  export type AttachmentAddErrorReason =
10
11
  | "no-adapter"
@@ -82,7 +83,7 @@ export type ComposerRuntimeCore = Readonly<{
82
83
  cancel: () => void;
83
84
 
84
85
  queue: readonly QueueItemState[];
85
- steerQueueItem: (queueItemId: string) => void;
86
+ moveQueueItem: (queueItemId: string, placement: QueuePlacement) => void;
86
87
  removeQueueItem: (queueItemId: string) => void;
87
88
 
88
89
  dictation: DictationState | undefined;
@@ -17,6 +17,7 @@ import type {
17
17
  ThreadComposerRuntimeCore,
18
18
  } from "./composer-runtime-core";
19
19
  import type { QueueItemState } from "../../store/scopes/queue-item";
20
+ import type { QueuePlacement } from "../queue/external-thread-queue-adapter";
20
21
 
21
22
  export type RuntimeCapabilities = {
22
23
  readonly switchToBranch: boolean;
@@ -177,7 +178,8 @@ export type ThreadRuntimeCore = Readonly<{
177
178
  beginEdit: (messageId: string) => void;
178
179
 
179
180
  getQueueItems?: () => readonly QueueItemState[];
180
- steerQueueItem?: (queueItemId: string) => void;
181
+ getSteerQueueItems?: () => readonly QueueItemState[];
182
+ moveQueueItem?: (queueItemId: string, placement: QueuePlacement) => void;
181
183
  removeQueueItem?: (queueItemId: string) => void;
182
184
 
183
185
  speech: SpeechState | undefined;
@@ -1,14 +1,30 @@
1
1
  import type { AppendMessage } from "../../types/message";
2
2
  import type { QueueItemState } from "../../store/scopes/queue-item";
3
3
 
4
+ export type QueuePlacement = {
5
+ readonly lane?: "queue" | "steer";
6
+ readonly insertAfter?: string | null;
7
+ readonly insertBefore?: string | null;
8
+ };
9
+
4
10
  /**
5
11
  * The queue surface a runtime exposes so the composer can stay usable during a
6
12
  * run and render the pending messages.
7
13
  */
8
14
  export type ExternalThreadQueueAdapter = {
9
15
  items: readonly QueueItemState[];
10
- enqueue: (message: AppendMessage, options: { steer: boolean }) => void;
11
- steer: (queueItemId: string) => void;
16
+ steerItems: readonly QueueItemState[];
17
+ /** Send a message into the queue lane, processed in order. */
18
+ enqueue: (message: AppendMessage) => void;
19
+ /** Send a message into the steer lane, processed next. */
20
+ steer: (message: AppendMessage) => void;
21
+ /**
22
+ * Move a queued message between lanes or within a lane. An unanchored move
23
+ * into the steer lane mid-run interrupts — cancels the live run and
24
+ * dispatches the item — when the runtime supports cancellation; a move with
25
+ * `insertAfter`/`insertBefore` only places the item and never interrupts.
26
+ */
27
+ move: (queueItemId: string, placement: QueuePlacement) => void;
28
+ edit: (queueItemId: string, message: AppendMessage) => void;
12
29
  remove: (queueItemId: string) => void;
13
- clear: (reason: "edit" | "reload" | "cancel-run") => void;
14
30
  };
@@ -1,15 +1,22 @@
1
- import type { AppendMessage } from "../../types/message";
1
+ import type {
2
+ AppendMessage,
3
+ FileMessagePart,
4
+ TextMessagePart,
5
+ } from "../../types/message";
2
6
  import {
3
7
  EMPTY_QUEUE_ITEMS,
4
8
  type QueueItemState,
5
9
  } from "../../store/scopes/queue-item";
6
10
  import { generateId } from "../../utils/id";
7
11
  import { getThreadMessageText } from "../../utils/text";
8
- import type { ExternalThreadQueueAdapter } from "./external-thread-queue-adapter";
12
+ import type {
13
+ ExternalThreadQueueAdapter,
14
+ QueuePlacement,
15
+ } from "./external-thread-queue-adapter";
9
16
 
10
17
  export type MessageQueueDriver = {
11
18
  run: (message: AppendMessage, options: { steer: boolean }) => void;
12
- /** When omitted, `steer` degrades to "process next" instead of interrupting. */
19
+ /** When omitted, steering degrades to "process next" instead of interrupting. */
13
20
  cancel?: (() => void) | undefined;
14
21
  };
15
22
 
@@ -19,95 +26,239 @@ export type MessageQueueController = {
19
26
  notifyBusy: () => void;
20
27
  /** Advances to the next pending message; call on the run's falling edge. */
21
28
  notifyIdle: () => void;
29
+ /**
30
+ * Pauses queue advance for a user-initiated cancel, so the cancelled run's
31
+ * settle keeps the pending items instead of dispatching the next one; the
32
+ * next explicit send or run start re-arms draining. Call before aborting
33
+ * the run.
34
+ */
35
+ notifyCancelled: () => void;
36
+ /** Empties both lanes without dispatching. */
37
+ clear: () => void;
22
38
  subscribe: (callback: () => void) => () => void;
23
39
  };
24
40
 
41
+ type Lane = "queue" | "steer";
42
+
43
+ const getQueueItemParts = (
44
+ message: AppendMessage,
45
+ ): readonly (FileMessagePart | TextMessagePart)[] => {
46
+ // attachment-derived text is the file body, not user-authored prose;
47
+ // project only the file/image content of attachments
48
+ const source = [
49
+ ...message.content,
50
+ ...(message.attachments ?? []).flatMap((attachment) =>
51
+ (attachment.content ?? []).filter((part) => part.type !== "text"),
52
+ ),
53
+ ];
54
+ const parts: (FileMessagePart | TextMessagePart)[] = [];
55
+ for (const part of source) {
56
+ if (part.type === "file" || part.type === "text") {
57
+ parts.push(part);
58
+ } else if (part.type === "image") {
59
+ parts.push({
60
+ type: "file",
61
+ data: part.image,
62
+ mimeType: "image/*",
63
+ ...(part.filename !== undefined && { filename: part.filename }),
64
+ });
65
+ }
66
+ }
67
+ return parts;
68
+ };
69
+
25
70
  export const createMessageQueue = (
26
71
  driver: MessageQueueDriver,
27
72
  ): MessageQueueController => {
28
- let items: readonly QueueItemState[] = EMPTY_QUEUE_ITEMS;
73
+ let lanes: Record<Lane, readonly QueueItemState[]> = {
74
+ queue: EMPTY_QUEUE_ITEMS,
75
+ steer: EMPTY_QUEUE_ITEMS,
76
+ };
29
77
  const messages = new Map<string, AppendMessage>();
30
78
  const subscribers = new Set<() => void>();
31
79
 
32
80
  let running = false;
81
+ let paused = false;
33
82
  // swallow the cancelled run's settle when steering so it does not double-advance
34
83
  let suppressIdle = 0;
84
+ // settles from cancelled runs that must drop `running` without advancing
85
+ let cancelSettles = 0;
35
86
 
36
87
  const notify = () => {
37
88
  for (const callback of subscribers) callback();
38
89
  };
39
90
 
40
- const setItems = (next: readonly QueueItemState[]) => {
41
- items = next;
42
- adapter.items = next;
91
+ const setLanes = (next: Record<Lane, readonly QueueItemState[]>) => {
92
+ lanes = next;
93
+ adapter.items = next.queue;
94
+ adapter.steerItems = next.steer;
43
95
  notify();
44
96
  };
45
97
 
98
+ const toItem = (id: string, message: AppendMessage): QueueItemState => ({
99
+ id,
100
+ prompt: getThreadMessageText(message),
101
+ parts: getQueueItemParts(message),
102
+ });
103
+
104
+ const laneOf = (queueItemId: string): Lane | undefined => {
105
+ if (lanes.steer.some((item) => item.id === queueItemId)) return "steer";
106
+ if (lanes.queue.some((item) => item.id === queueItemId)) return "queue";
107
+ return undefined;
108
+ };
109
+
46
110
  const advance = () => {
47
- if (running || items.length === 0) return;
48
- const head = items[0]!;
111
+ if (running || paused) return;
112
+ const lane: Lane = lanes.steer.length > 0 ? "steer" : "queue";
113
+ const head = lanes[lane][0];
114
+ if (!head) return;
49
115
  const message = messages.get(head.id);
50
116
  messages.delete(head.id);
51
- setItems(items.slice(1));
117
+ setLanes({ ...lanes, [lane]: lanes[lane].slice(1) });
52
118
  if (!message) return;
53
119
  running = true;
54
120
  driver.run(message, { steer: false });
55
121
  };
56
122
 
57
- const enqueue = (message: AppendMessage, { steer }: { steer: boolean }) => {
123
+ const interrupt = (message: AppendMessage) => {
124
+ paused = false;
125
+ // the interrupted run settles exactly once, whether or not it was
126
+ // already cancel-notified
127
+ suppressIdle += Math.max(cancelSettles, 1);
128
+ cancelSettles = 0;
129
+ driver.cancel!();
130
+ running = true;
131
+ driver.run(message, { steer: true });
132
+ };
133
+
134
+ const push = (lane: Lane, message: AppendMessage) => {
135
+ paused = false;
58
136
  const id = generateId();
59
- const prompt = getThreadMessageText(message);
60
137
  messages.set(id, message);
61
- setItems([...items, { id, prompt }]);
62
- if (steer) {
63
- steerItem(id);
64
- } else {
65
- advance();
138
+ setLanes({ ...lanes, [lane]: [...lanes[lane], toItem(id, message)] });
139
+ advance();
140
+ };
141
+
142
+ const enqueue = (message: AppendMessage) => {
143
+ push("queue", message);
144
+ };
145
+
146
+ const steer = (message: AppendMessage) => {
147
+ if (running && driver.cancel) {
148
+ interrupt(message);
149
+ return;
66
150
  }
151
+ push("steer", message);
67
152
  };
68
153
 
69
- const steerItem = (queueItemId: string) => {
70
- if (!messages.has(queueItemId)) return;
154
+ const move = (queueItemId: string, placement: QueuePlacement) => {
155
+ const fromLane = laneOf(queueItemId);
156
+ if (!fromLane) throw new Error(`Unknown queue item "${queueItemId}".`);
157
+ const toLane = placement.lane ?? fromLane;
158
+
159
+ const item = lanes[fromLane].find((i) => i.id === queueItemId)!;
160
+ const dest = (toLane === fromLane ? lanes[fromLane] : lanes[toLane]).filter(
161
+ (i) => i.id !== queueItemId,
162
+ );
163
+
164
+ const anchorIndex = (anchor: string) => {
165
+ if (anchor === queueItemId)
166
+ throw new Error(`Queue item "${queueItemId}" cannot anchor itself.`);
167
+ const index = dest.findIndex((i) => i.id === anchor);
168
+ if (index === -1)
169
+ throw new Error(`Unknown anchor "${anchor}" in lane "${toLane}".`);
170
+ return index;
171
+ };
172
+
173
+ const { insertAfter, insertBefore } = placement;
174
+ let index: number;
175
+ if (insertAfter === undefined && insertBefore === undefined) {
176
+ index =
177
+ toLane === fromLane
178
+ ? lanes[fromLane].findIndex((i) => i.id === queueItemId)
179
+ : dest.length;
180
+ } else if (insertAfter !== undefined && insertBefore !== undefined) {
181
+ const after = insertAfter === null ? -1 : anchorIndex(insertAfter);
182
+ const before =
183
+ insertBefore === null ? dest.length : anchorIndex(insertBefore);
184
+ if (before !== after + 1)
185
+ throw new Error(
186
+ `insertAfter "${insertAfter}" and insertBefore "${insertBefore}" are not adjacent in lane "${toLane}".`,
187
+ );
188
+ index = after + 1;
189
+ } else if (insertAfter !== undefined) {
190
+ index = insertAfter === null ? 0 : anchorIndex(insertAfter) + 1;
191
+ } else {
192
+ index = insertBefore === null ? dest.length : anchorIndex(insertBefore!);
193
+ }
71
194
 
72
- if (driver.cancel && running) {
195
+ // placement and immediate dispatch cannot coexist: an anchored move into
196
+ // the steer lane places without interrupting; only an unanchored one
197
+ // cancels the live run and dispatches
198
+ if (
199
+ insertAfter === undefined &&
200
+ insertBefore === undefined &&
201
+ toLane === "steer" &&
202
+ fromLane !== "steer" &&
203
+ running &&
204
+ driver.cancel
205
+ ) {
73
206
  const message = messages.get(queueItemId)!;
74
207
  messages.delete(queueItemId);
75
- setItems(items.filter((item) => item.id !== queueItemId));
76
- suppressIdle++;
77
- driver.cancel();
78
- running = true;
79
- driver.run(message, { steer: true });
208
+ setLanes({
209
+ queue: lanes.queue.filter((i) => i.id !== queueItemId),
210
+ steer: lanes.steer,
211
+ });
212
+ interrupt(message);
80
213
  return;
81
214
  }
82
215
 
83
- const target = items.find((item) => item.id === queueItemId);
84
- if (!target) return;
85
- setItems([target, ...items.filter((item) => item.id !== queueItemId)]);
216
+ const nextDest = [...dest.slice(0, index), item, ...dest.slice(index)];
217
+ const next = { ...lanes, [toLane]: nextDest };
218
+ if (toLane !== fromLane)
219
+ next[fromLane] = lanes[fromLane].filter((i) => i.id !== queueItemId);
220
+ setLanes(next);
86
221
  advance();
87
222
  };
88
223
 
89
- const remove = (queueItemId: string) => {
90
- if (!messages.delete(queueItemId)) return;
91
- setItems(items.filter((item) => item.id !== queueItemId));
224
+ const edit = (queueItemId: string, message: AppendMessage) => {
225
+ const lane = laneOf(queueItemId);
226
+ if (!lane) throw new Error(`Unknown queue item "${queueItemId}".`);
227
+ messages.set(queueItemId, message);
228
+ setLanes({
229
+ ...lanes,
230
+ [lane]: lanes[lane].map((item) =>
231
+ item.id === queueItemId ? toItem(queueItemId, message) : item,
232
+ ),
233
+ });
92
234
  };
93
235
 
94
- const clear = () => {
95
- if (items.length === 0) return;
96
- messages.clear();
97
- setItems(EMPTY_QUEUE_ITEMS);
236
+ const remove = (queueItemId: string) => {
237
+ if (!messages.delete(queueItemId)) return;
238
+ setLanes({
239
+ queue: lanes.queue.filter((item) => item.id !== queueItemId),
240
+ steer: lanes.steer.filter((item) => item.id !== queueItemId),
241
+ });
98
242
  };
99
243
 
100
244
  const adapter: ExternalThreadQueueAdapter = {
101
- items,
245
+ items: lanes.queue,
246
+ steerItems: lanes.steer,
102
247
  enqueue,
103
- steer: steerItem,
248
+ steer,
249
+ move,
250
+ edit,
104
251
  remove,
105
- clear,
106
252
  };
107
253
 
108
254
  return {
109
255
  adapter,
110
256
  notifyBusy: () => {
257
+ paused = false;
258
+ // a cancelled run's settle that is still outstanding belongs to a run
259
+ // this new one replaces; swallow it entirely
260
+ suppressIdle += cancelSettles;
261
+ cancelSettles = 0;
111
262
  running = true;
112
263
  },
113
264
  notifyIdle: () => {
@@ -115,9 +266,20 @@ export const createMessageQueue = (
115
266
  suppressIdle--;
116
267
  return;
117
268
  }
269
+ if (cancelSettles > 0) cancelSettles--;
118
270
  running = false;
119
271
  advance();
120
272
  },
273
+ notifyCancelled: () => {
274
+ if (running && cancelSettles === 0) {
275
+ paused = true;
276
+ cancelSettles = 1;
277
+ }
278
+ },
279
+ clear: () => {
280
+ messages.clear();
281
+ setLanes({ queue: EMPTY_QUEUE_ITEMS, steer: EMPTY_QUEUE_ITEMS });
282
+ },
121
283
  subscribe: (callback) => {
122
284
  subscribers.add(callback);
123
285
  return () => {
@@ -1,5 +1,6 @@
1
1
  import { parsePartialJsonObject } from "assistant-stream/utils";
2
2
  import { generateId } from "../../utils/id";
3
+ import { parseDataUrl } from "../../utils/data-url";
3
4
  import type {
4
5
  ReasoningMessagePart,
5
6
  SourceMessagePart,
@@ -130,10 +131,7 @@ export const fromThreadMessageLike = (
130
131
  ...rest
131
132
  }: ImageMessagePart): ImageMessagePart | null => {
132
133
  if (typeof image !== "string") return null;
133
- const dataUri = image.match(
134
- /^data:image\/(png|jpeg|jpg|gif|webp|svg\+xml);base64,(.*)$/i,
135
- );
136
- if (dataUri) {
134
+ if (parseDataUrl(image)?.mimeType.startsWith("image/")) {
137
135
  return { ...rest, image };
138
136
  }
139
137
  if (/^(https:\/\/|blob:)/i.test(image)) {
@@ -27,6 +27,7 @@ import type {
27
27
  RuntimeCapabilities,
28
28
  ThreadRuntimeCore,
29
29
  } from "../../runtime/interfaces/thread-runtime-core";
30
+ import type { QueuePlacement } from "../../runtime/queue/external-thread-queue-adapter";
30
31
  import { BaseThreadRuntimeCore } from "../../runtime/base/base-thread-runtime-core";
31
32
  import type { ModelContextProvider } from "../../model-context/types";
32
33
  import {
@@ -470,7 +471,9 @@ export class ExternalStoreThreadRuntimeCore
470
471
  // Buffering does not start a run, so the tool-abort below must wait until
471
472
  // the queue flushes. By then the prior run (and its tools) has settled.
472
473
  if (!isEdit && this._store.queue) {
473
- this._store.queue.enqueue(message, { steer: message.steer ?? false });
474
+ if (message.steer ?? this._store.isRunning ?? false)
475
+ this._store.queue.steer(message);
476
+ else this._store.queue.enqueue(message);
474
477
  return;
475
478
  }
476
479
 
@@ -487,7 +490,6 @@ export class ExternalStoreThreadRuntimeCore
487
490
  if (isEdit) {
488
491
  if (!this._store.onEdit)
489
492
  throw new Error("Runtime does not support editing messages.");
490
- this._store.queue?.clear("edit");
491
493
  await this._store.onEdit(message);
492
494
  } else {
493
495
  await this._store.onNew(message);
@@ -520,8 +522,12 @@ export class ExternalStoreThreadRuntimeCore
520
522
  return this._store?.queue?.items ?? EMPTY_QUEUE_ITEMS;
521
523
  }
522
524
 
523
- public steerQueueItem(queueItemId: string) {
524
- this._store?.queue?.steer(queueItemId);
525
+ public getSteerQueueItems() {
526
+ return this._store?.queue?.steerItems ?? EMPTY_QUEUE_ITEMS;
527
+ }
528
+
529
+ public moveQueueItem(queueItemId: string, placement: QueuePlacement) {
530
+ this._store?.queue?.move(queueItemId, placement);
525
531
  }
526
532
 
527
533
  public removeQueueItem(queueItemId: string) {
@@ -532,8 +538,6 @@ export class ExternalStoreThreadRuntimeCore
532
538
  if (!this._store.onReload)
533
539
  throw new Error("Runtime does not support reloading messages.");
534
540
 
535
- this._store.queue?.clear("reload");
536
-
537
541
  // Auto-abort in-flight client-side tool executions when a run reloads;
538
542
  // any results that land afterward would target a turn that no longer
539
543
  // exists. See `append` above for full rationale.
@@ -579,8 +583,6 @@ export class ExternalStoreThreadRuntimeCore
579
583
  if (!this._store.onCancel)
580
584
  throw new Error("Runtime does not support cancelling runs.");
581
585
 
582
- this._store.queue?.clear("cancel-run");
583
-
584
586
  // Abort any in-flight client-side tool executions. Fire-and-forget —
585
587
  // the abort resolves once executions settle, but we don't gate the
586
588
  // cancel on it.
@@ -32,4 +32,19 @@ export type LocalRuntimeOptionsBase = {
32
32
  * `composer.queue` and sent once the run settles. Steering runs it next.
33
33
  */
34
34
  unstable_enableMessageQueue?: boolean | undefined;
35
+
36
+ /**
37
+ * Auto-clear the message queue when the thread rewinds (message edit).
38
+ * Defaults to `true`.
39
+ * @deprecated Removal after 2026-11-05 — the queue will always survive rewinds.
40
+ */
41
+ unstable_queueClearOnRewind?: boolean | undefined;
42
+
43
+ /**
44
+ * Auto-clear the message queue when the user cancels the run. Defaults to
45
+ * `true`. When `false`, cancel pauses the queue and keeps the pending
46
+ * items; the next send drains them.
47
+ * @deprecated Removal after 2026-11-05 — cancel will always pause the queue and keep the items.
48
+ */
49
+ unstable_queueClearOnCancel?: boolean | undefined;
35
50
  };