@assistant-ui/core 0.3.5 → 0.3.7

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 (207) hide show
  1. package/dist/index.d.ts +4 -4
  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/RuntimeAdapter.js +1 -1
  13. package/dist/react/RuntimeAdapter.js.map +1 -1
  14. package/dist/react/client/Interactables.js +1 -1
  15. package/dist/react/client/Interactables.js.map +1 -1
  16. package/dist/react/client/Tools.d.ts +1 -1
  17. package/dist/react/client/Tools.js +1 -1
  18. package/dist/react/client/Tools.js.map +1 -1
  19. package/dist/react/index.d.ts +9 -9
  20. package/dist/react/index.js +4 -4
  21. package/dist/react/model-context/interactableTool.d.ts +1 -1
  22. package/dist/react/model-context/useAssistantTool.d.ts +1 -1
  23. package/dist/react/providers/AttachmentByIndexProvider.d.ts.map +1 -1
  24. package/dist/react/providers/AttachmentByIndexProvider.js +33 -39
  25. package/dist/react/providers/AttachmentByIndexProvider.js.map +1 -1
  26. package/dist/react/providers/ChainOfThoughtByIndicesProvider.d.ts.map +1 -1
  27. package/dist/react/providers/ChainOfThoughtByIndicesProvider.js +15 -20
  28. package/dist/react/providers/ChainOfThoughtByIndicesProvider.js.map +1 -1
  29. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +17 -20
  30. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js.map +1 -1
  31. package/dist/react/providers/MessageByIdProvider.js +28 -38
  32. package/dist/react/providers/MessageByIdProvider.js.map +1 -1
  33. package/dist/react/providers/MessageByIndexProvider.js +28 -38
  34. package/dist/react/providers/MessageByIndexProvider.js.map +1 -1
  35. package/dist/react/providers/PartByIndexProvider.js +17 -20
  36. package/dist/react/providers/PartByIndexProvider.js.map +1 -1
  37. package/dist/react/providers/QueueItemByIndexProvider.js +21 -21
  38. package/dist/react/providers/QueueItemByIndexProvider.js.map +1 -1
  39. package/dist/react/providers/ReadonlyThreadProvider.js +24 -32
  40. package/dist/react/providers/ReadonlyThreadProvider.js.map +1 -1
  41. package/dist/react/providers/SuggestionByIndexProvider.js +17 -20
  42. package/dist/react/providers/SuggestionByIndexProvider.js.map +1 -1
  43. package/dist/react/providers/TextMessagePartProvider.d.ts.map +1 -1
  44. package/dist/react/providers/TextMessagePartProvider.js +16 -19
  45. package/dist/react/providers/TextMessagePartProvider.js.map +1 -1
  46. package/dist/react/providers/ThreadListItemByIndexProvider.js +17 -20
  47. package/dist/react/providers/ThreadListItemByIndexProvider.js.map +1 -1
  48. package/dist/react/providers/ThreadListItemRuntimeProvider.js +15 -18
  49. package/dist/react/providers/ThreadListItemRuntimeProvider.js.map +1 -1
  50. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +7 -3
  51. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  52. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +5 -2
  53. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  54. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +3 -2
  55. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  56. package/dist/react/runtimes/createMessageConverter.d.ts +1 -1
  57. package/dist/react/runtimes/createRuntimeExtras.d.ts +4 -1
  58. package/dist/react/runtimes/createRuntimeExtras.d.ts.map +1 -1
  59. package/dist/react/runtimes/createRuntimeExtras.js +6 -17
  60. package/dist/react/runtimes/createRuntimeExtras.js.map +1 -1
  61. package/dist/react/runtimes/useLocalRuntime.d.ts +3 -1
  62. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  63. package/dist/react/runtimes/useLocalRuntime.js +4 -2
  64. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  65. package/dist/react/types/scopes/dataRenderers.d.ts +1 -1
  66. package/dist/react/types/scopes/interactables.d.ts +1 -1
  67. package/dist/react/types/scopes/tools.d.ts +1 -1
  68. package/dist/runtime/api/composer-runtime.d.ts +5 -1
  69. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  70. package/dist/runtime/api/composer-runtime.js +8 -1
  71. package/dist/runtime/api/composer-runtime.js.map +1 -1
  72. package/dist/runtime/api/message-runtime.js +1 -1
  73. package/dist/runtime/api/message-runtime.js.map +1 -1
  74. package/dist/runtime/api/thread-runtime.d.ts +4 -2
  75. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  76. package/dist/runtime/base/base-composer-runtime-core.d.ts +2 -1
  77. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  78. package/dist/runtime/base/base-composer-runtime-core.js +1 -1
  79. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  80. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts +3 -1
  81. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  82. package/dist/runtime/base/default-thread-composer-runtime-core.js +14 -3
  83. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  84. package/dist/runtime/interfaces/composer-runtime-core.d.ts +2 -1
  85. package/dist/runtime/interfaces/composer-runtime-core.d.ts.map +1 -1
  86. package/dist/runtime/interfaces/thread-runtime-core.d.ts +3 -1
  87. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  88. package/dist/runtime/queue/external-thread-queue-adapter.d.ts +19 -6
  89. package/dist/runtime/queue/external-thread-queue-adapter.d.ts.map +1 -1
  90. package/dist/runtime/queue/message-queue.d.ts +10 -1
  91. package/dist/runtime/queue/message-queue.d.ts.map +1 -1
  92. package/dist/runtime/queue/message-queue.js +138 -36
  93. package/dist/runtime/queue/message-queue.js.map +1 -1
  94. package/dist/runtime/utils/runtime-extras-brand.d.ts +21 -0
  95. package/dist/runtime/utils/runtime-extras-brand.d.ts.map +1 -0
  96. package/dist/runtime/utils/runtime-extras-brand.js +37 -0
  97. package/dist/runtime/utils/runtime-extras-brand.js.map +1 -0
  98. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  99. package/dist/runtime/utils/thread-message-like.js +2 -1
  100. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  101. package/dist/runtimes/external-store/external-store-adapter.d.ts +1 -1
  102. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +3 -1
  103. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  104. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +7 -6
  105. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  106. package/dist/runtimes/local/local-runtime-options.d.ts +13 -0
  107. package/dist/runtimes/local/local-runtime-options.d.ts.map +1 -1
  108. package/dist/runtimes/local/local-thread-runtime-core.d.ts +5 -1
  109. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  110. package/dist/runtimes/local/local-thread-runtime-core.js +30 -14
  111. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  112. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -1
  113. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +1 -1
  114. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  115. package/dist/runtimes/remote-thread-list/empty-thread-core.js +1 -1
  116. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  117. package/dist/runtimes/remote-thread-list/types.d.ts +6 -3
  118. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  119. package/dist/store/clients/runtime-adapter.d.ts.map +1 -1
  120. package/dist/store/clients/runtime-adapter.js +1 -1
  121. package/dist/store/clients/runtime-adapter.js.map +1 -1
  122. package/dist/store/clients/suggestions.js +1 -1
  123. package/dist/store/clients/suggestions.js.map +1 -1
  124. package/dist/store/clients/thread-message-client.js +1 -1
  125. package/dist/store/clients/thread-message-client.js.map +1 -1
  126. package/dist/store/index.d.ts +10 -1
  127. package/dist/store/index.js +7 -1
  128. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  129. package/dist/store/runtime-clients/composer-runtime-client.js +12 -5
  130. package/dist/store/runtime-clients/composer-runtime-client.js.map +1 -1
  131. package/dist/store/runtime-clients/message-runtime-client.d.ts.map +1 -1
  132. package/dist/store/runtime-clients/message-runtime-client.js +1 -1
  133. package/dist/store/runtime-clients/message-runtime-client.js.map +1 -1
  134. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  135. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +1 -1
  136. package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
  137. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  138. package/dist/store/runtime-clients/thread-list-runtime-client.js +1 -1
  139. package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
  140. package/dist/store/runtime-clients/thread-runtime-client.js +1 -1
  141. package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
  142. package/dist/store/scopes/composer.d.ts +7 -3
  143. package/dist/store/scopes/composer.d.ts.map +1 -1
  144. package/dist/store/scopes/queue-item.d.ts +10 -0
  145. package/dist/store/scopes/queue-item.d.ts.map +1 -1
  146. package/dist/store/scopes/queue-item.js.map +1 -1
  147. package/package.json +15 -15
  148. package/src/index.ts +5 -1
  149. package/src/react/AssistantProvider.tsx +20 -18
  150. package/src/react/AssistantRuntimeProvider.test.tsx +55 -1
  151. package/src/react/AssistantRuntimeProvider.tsx +12 -2
  152. package/src/react/RuntimeAdapter.test.ts +85 -0
  153. package/src/react/RuntimeAdapter.ts +1 -1
  154. package/src/react/client/Interactables.test.ts +3 -2
  155. package/src/react/client/Interactables.ts +4 -4
  156. package/src/react/client/Tools.ts +4 -4
  157. package/src/react/index.ts +1 -0
  158. package/src/react/providers/AttachmentByIndexProvider.tsx +15 -7
  159. package/src/react/providers/ChainOfThoughtByIndicesProvider.tsx +12 -4
  160. package/src/react/providers/ChainOfThoughtPartByIndexProvider.tsx +8 -4
  161. package/src/react/providers/MessageByIdProvider.tsx +8 -4
  162. package/src/react/providers/MessageByIndexProvider.tsx +8 -4
  163. package/src/react/providers/PartByIndexProvider.tsx +8 -4
  164. package/src/react/providers/QueueItemByIndexProvider.tsx +9 -5
  165. package/src/react/providers/ReadonlyThreadProvider.tsx +9 -4
  166. package/src/react/providers/SuggestionByIndexProvider.tsx +8 -4
  167. package/src/react/providers/TextMessagePartProvider.tsx +13 -4
  168. package/src/react/providers/ThreadListItemByIndexProvider.tsx +8 -4
  169. package/src/react/providers/ThreadListItemRuntimeProvider.tsx +8 -4
  170. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +19 -4
  171. package/src/react/runtimes/createRuntimeExtras.test.ts +14 -1
  172. package/src/react/runtimes/createRuntimeExtras.ts +15 -26
  173. package/src/react/runtimes/useLocalRuntime.ts +4 -0
  174. package/src/runtime/api/composer-runtime.ts +11 -2
  175. package/src/runtime/api/message-runtime.test.ts +25 -0
  176. package/src/runtime/api/message-runtime.ts +1 -1
  177. package/src/runtime/base/base-composer-runtime-core.ts +5 -1
  178. package/src/runtime/base/default-thread-composer-runtime-core.ts +27 -3
  179. package/src/runtime/interfaces/composer-runtime-core.ts +2 -1
  180. package/src/runtime/interfaces/thread-runtime-core.ts +3 -1
  181. package/src/runtime/queue/external-thread-queue-adapter.ts +19 -3
  182. package/src/runtime/queue/message-queue.ts +200 -38
  183. package/src/runtime/utils/runtime-extras-brand.ts +47 -0
  184. package/src/runtime/utils/thread-message-like.ts +2 -4
  185. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +10 -8
  186. package/src/runtimes/local/local-runtime-options.ts +15 -0
  187. package/src/runtimes/local/local-thread-runtime-core.test.ts +43 -0
  188. package/src/runtimes/local/local-thread-runtime-core.ts +48 -13
  189. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +1 -1
  190. package/src/runtimes/remote-thread-list/empty-thread-core.ts +1 -1
  191. package/src/runtimes/remote-thread-list/types.ts +9 -2
  192. package/src/store/clients/runtime-adapter.ts +2 -5
  193. package/src/store/clients/suggestions.ts +1 -1
  194. package/src/store/clients/thread-message-client.ts +1 -1
  195. package/src/store/index.ts +31 -0
  196. package/src/store/runtime-clients/composer-runtime-client.ts +15 -10
  197. package/src/store/runtime-clients/message-runtime-client.ts +2 -5
  198. package/src/store/runtime-clients/thread-list-item-runtime-client.ts +2 -1
  199. package/src/store/runtime-clients/thread-list-runtime-client.ts +2 -5
  200. package/src/store/runtime-clients/thread-runtime-client.ts +2 -2
  201. package/src/store/scopes/composer.ts +6 -4
  202. package/src/store/scopes/queue-item.ts +8 -1
  203. package/src/tests/external-store-thread-runtime-core.test.ts +56 -13
  204. package/src/tests/framework-neutral-boundary.test.ts +257 -0
  205. package/src/tests/message-queue.test.ts +494 -34
  206. package/src/tests/thread-message-like.test.ts +66 -0
  207. package/LICENSE +0 -21
@@ -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 () => {
@@ -0,0 +1,47 @@
1
+ /** @deprecated Internal API for external-store adapter authors. Not part of the public API; may change or be removed without notice. */
2
+ export type RuntimeExtrasBrand<T extends object> = {
3
+ provide: (value: T) => T;
4
+ is: (extras: unknown) => extras is T;
5
+ tryGet: (extras: unknown) => T | undefined;
6
+ assert: (extras: unknown) => T;
7
+ };
8
+
9
+ /**
10
+ * Creates the framework-neutral half of a runtime extras channel: the brand
11
+ * symbol with its provide/read primitives. A runtime factory provides extras
12
+ * through the brand on any framework; the React entry wraps the same brand
13
+ * with a `use()` hook. The symbol is per-instance, so the adapter package
14
+ * creates the brand once and every binding imports that instance.
15
+ *
16
+ * @deprecated Internal API for external-store adapter authors. Not part of the public API; may change or be removed without notice.
17
+ */
18
+ export const createRuntimeExtrasBrand = <T extends object>(
19
+ runtimeName: string,
20
+ ): RuntimeExtrasBrand<T> => {
21
+ const brand = Symbol(`${runtimeName} extras`);
22
+
23
+ const is = (extras: unknown): extras is T =>
24
+ typeof extras === "object" && extras !== null && brand in extras;
25
+
26
+ const assert = (extras: unknown): T => {
27
+ if (!is(extras))
28
+ throw new Error(
29
+ `The current thread is not backed by the ${runtimeName} runtime.`,
30
+ );
31
+ return extras;
32
+ };
33
+
34
+ const provide = (value: T): T => {
35
+ Object.defineProperty(value, brand, {
36
+ value: true,
37
+ enumerable: false,
38
+ configurable: true,
39
+ });
40
+ return value;
41
+ };
42
+
43
+ const tryGet = (extras: unknown): T | undefined =>
44
+ is(extras) ? extras : undefined;
45
+
46
+ return { provide, is, tryGet, assert };
47
+ };
@@ -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
  };
@@ -958,6 +958,49 @@ describe("LocalThreadRuntimeCore tool approval persistence", () => {
958
958
  });
959
959
  });
960
960
 
961
+ it("does not persist an existing falsy tool result again", async () => {
962
+ const { history, updated } = createHistory();
963
+ const twoHumanTools: ChatModelRunResult = {
964
+ content: [
965
+ {
966
+ ...toolCallPart("send_email"),
967
+ toolCallId: "call-1",
968
+ },
969
+ {
970
+ ...toolCallPart("send_email"),
971
+ toolCallId: "call-2",
972
+ },
973
+ ],
974
+ status: { type: "requires-action", reason: "tool-calls" },
975
+ };
976
+ const thread = createThread(
977
+ {
978
+ async run() {
979
+ return twoHumanTools;
980
+ },
981
+ },
982
+ { history },
983
+ );
984
+
985
+ await thread.append(userMessage("send two emails"));
986
+ await flush();
987
+
988
+ const options = {
989
+ messageId: thread.messages.at(-1)!.id,
990
+ toolName: "send_email",
991
+ toolCallId: "call-1",
992
+ result: false,
993
+ isError: false,
994
+ };
995
+ thread.addToolResult(options);
996
+ await flush();
997
+ expect(updated).toHaveLength(1);
998
+
999
+ thread.addToolResult(options);
1000
+ await flush();
1001
+ expect(updated).toHaveLength(1);
1002
+ });
1003
+
961
1004
  it("persists a multi-step run once instead of writing intermediate steps", async () => {
962
1005
  const { history, appended, updated } = createHistory();
963
1006
  const runs: ChatModelRunOptions[] = [];
@@ -28,6 +28,7 @@ import {
28
28
  createMessageQueue,
29
29
  type MessageQueueController,
30
30
  } from "../../runtime/queue/message-queue";
31
+ import type { QueuePlacement } from "../../runtime/queue/external-thread-queue-adapter";
31
32
  import {
32
33
  EMPTY_QUEUE_ITEMS,
33
34
  type QueueItemState,
@@ -68,6 +69,8 @@ export class LocalThreadRuntimeCore
68
69
 
69
70
  private _queue: MessageQueueController | null = null;
70
71
  private _queueRunInFlight = false;
72
+ private _activeRun: { cancelled: boolean } | null = null;
73
+ private _runGeneration = 0;
71
74
 
72
75
  private _historyWrites = new Map<string, Promise<void>>();
73
76
 
@@ -206,17 +209,23 @@ export class LocalThreadRuntimeCore
206
209
  // release the queue when the dispatch settles, even if it rejects
207
210
  // before reaching startRun's finally, so a failure can't deadlock it
208
211
  this._queueRunInFlight = true;
209
- void this._runAppend(message)
212
+ const generation = this._runGeneration;
213
+ // the tail may have moved since the message was enqueued
214
+ void this._runAppend({
215
+ ...message,
216
+ parentId: this.messages.at(-1)?.id ?? null,
217
+ })
210
218
  .finally(() => {
211
219
  this._queueRunInFlight = false;
212
- this._queue?.notifyIdle();
220
+ // a dispatch that failed before starting a run settles here;
221
+ // runs that did start release from _runLoop
222
+ if (this._runGeneration === generation) this._queue?.notifyIdle();
213
223
  })
214
224
  .catch(() => {});
215
225
  },
216
226
  });
217
227
  this._queue.subscribe(() => this._notifySubscribers());
218
228
  } else if (!canQueue && this._queue) {
219
- this._queue.adapter.clear("cancel-run");
220
229
  this._queue = null;
221
230
  }
222
231
  if (this.capabilities.queue !== canQueue) {
@@ -271,10 +280,17 @@ export class LocalThreadRuntimeCore
271
280
  const isTail = message.parentId === (this.messages.at(-1)?.id ?? null);
272
281
  const willRun = message.startRun ?? message.role === "user";
273
282
  if (this._queue && willRun && isTail) {
274
- this._queue.adapter.enqueue(message, { steer: message.steer ?? false });
283
+ if (message.steer ?? this._queueRunInFlight)
284
+ this._queue.adapter.steer(message);
285
+ else this._queue.adapter.enqueue(message);
275
286
  return;
276
287
  }
277
- if (this._queue && !isTail) this._queue.adapter.clear("edit");
288
+ if (
289
+ this._queue &&
290
+ !isTail &&
291
+ (this._options.unstable_queueClearOnRewind ?? true)
292
+ )
293
+ this._queue.clear();
278
294
  return this._runAppend(message);
279
295
  }
280
296
 
@@ -284,8 +300,12 @@ export class LocalThreadRuntimeCore
284
300
  return this._queue?.adapter.items ?? EMPTY_QUEUE_ITEMS;
285
301
  }
286
302
 
287
- public steerQueueItem(queueItemId: string): void {
288
- this._queue?.adapter.steer(queueItemId);
303
+ public getSteerQueueItems(): readonly QueueItemState[] {
304
+ return this._queue?.adapter.steerItems ?? EMPTY_QUEUE_ITEMS;
305
+ }
306
+
307
+ public moveQueueItem(queueItemId: string, placement: QueuePlacement): void {
308
+ this._queue?.adapter.move(queueItemId, placement);
289
309
  }
290
310
 
291
311
  public removeQueueItem(queueItemId: string): void {
@@ -397,6 +417,10 @@ export class LocalThreadRuntimeCore
397
417
  message.status?.type === "requires-action" &&
398
418
  this._options.adapters.history?.update !== undefined;
399
419
 
420
+ const run = { cancelled: false };
421
+ this._activeRun = run;
422
+ this._runGeneration++;
423
+
400
424
  try {
401
425
  // mark busy for runs not started through the queue (regenerate, resume)
402
426
  this._queue?.notifyBusy();
@@ -417,9 +441,12 @@ export class LocalThreadRuntimeCore
417
441
  } while (shouldContinue(message, this._options.unstable_humanToolNames));
418
442
  } finally {
419
443
  this._notifyEventSubscribers("runEnd", {});
420
- // queue-driven runs release from the driver settle handler; a direct
421
- // run (regenerate, resume) releases here
422
- if (!this._queueRunInFlight) {
444
+ // the settle belongs to this run only while it is still the active run
445
+ // or was cancelled (the engine expects a cancelled run's settle); a run
446
+ // superseded by a newer one stays silent
447
+ const active = this._activeRun === run;
448
+ if (active) this._activeRun = null;
449
+ if (active || run.cancelled) {
423
450
  queueMicrotask(() => this._queue?.notifyIdle());
424
451
  }
425
452
  }
@@ -644,7 +671,8 @@ export class LocalThreadRuntimeCore
644
671
  }
645
672
 
646
673
  public detach() {
647
- this._queue?.adapter.clear("cancel-run");
674
+ // drop the queue so pending items cannot dispatch on a detached thread
675
+ this._queue = null;
648
676
  const error = new AbortError(true);
649
677
  this.abortController?.abort(error);
650
678
  this.abortController = null;
@@ -653,7 +681,14 @@ export class LocalThreadRuntimeCore
653
681
  }
654
682
 
655
683
  public cancelRun() {
656
- this._queue?.adapter.clear("cancel-run");
684
+ if (this._queue) {
685
+ if (this._options.unstable_queueClearOnCancel ?? true) {
686
+ this._queue.clear();
687
+ } else {
688
+ this._queue.notifyCancelled();
689
+ if (this._activeRun) this._activeRun.cancelled = true;
690
+ }
691
+ }
657
692
  const error = new AbortError(false);
658
693
  this.abortController?.abort(error);
659
694
  this.abortController = null;
@@ -681,7 +716,7 @@ export class LocalThreadRuntimeCore
681
716
  if (c.type !== "tool-call") return c;
682
717
  if (c.toolCallId !== toolCallId) return c;
683
718
  found = true;
684
- if (!c.result) added = true;
719
+ if (c.result === undefined) added = true;
685
720
  return {
686
721
  ...c,
687
722
  result,
@@ -163,7 +163,7 @@ export class ReadonlyThreadRuntimeCore
163
163
  },
164
164
 
165
165
  queue: [] as never[],
166
- steerQueueItem() {},
166
+ moveQueueItem() {},
167
167
  removeQueueItem() {},
168
168
 
169
169
  dictation: undefined,
@@ -144,7 +144,7 @@ export const EMPTY_THREAD_CORE: ThreadRuntimeCore = {
144
144
  },
145
145
 
146
146
  queue: [] as never[],
147
- steerQueueItem() {},
147
+ moveQueueItem() {},
148
148
  removeQueueItem() {},
149
149
 
150
150
  dictation: undefined,