@assistant-ui/core 0.3.18 → 0.3.19

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 (274) hide show
  1. package/dist/adapters/speech.d.ts.map +1 -1
  2. package/dist/adapters/speech.js +13 -3
  3. package/dist/adapters/speech.js.map +1 -1
  4. package/dist/adapters/thread-history.d.ts +4 -0
  5. package/dist/adapters/thread-history.d.ts.map +1 -1
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js.map +1 -1
  8. package/dist/internal.d.ts +1 -1
  9. package/dist/model-context/frame/host.d.ts.map +1 -1
  10. package/dist/model-context/frame/host.js +26 -11
  11. package/dist/model-context/frame/host.js.map +1 -1
  12. package/dist/model-context/interactable-composer-metadata.js +11 -9
  13. package/dist/model-context/interactable-composer-metadata.js.map +1 -1
  14. package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
  15. package/dist/react/adapters/LocalStorageThreadListAdapter.js +45 -7
  16. package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
  17. package/dist/react/client/DataRenderers.d.ts.map +1 -1
  18. package/dist/react/client/DataRenderers.js +20 -15
  19. package/dist/react/client/DataRenderers.js.map +1 -1
  20. package/dist/react/client/Interactables.d.ts.map +1 -1
  21. package/dist/react/client/Interactables.js +11 -5
  22. package/dist/react/client/Interactables.js.map +1 -1
  23. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  24. package/dist/react/client/RemoteThreadList.js +3 -2
  25. package/dist/react/client/RemoteThreadList.js.map +1 -1
  26. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  27. package/dist/react/client/interactable-model-context.js +3 -2
  28. package/dist/react/client/interactable-model-context.js.map +1 -1
  29. package/dist/react/index.d.ts +2 -2
  30. package/dist/react/index.js +2 -2
  31. package/dist/react/interactables-legacy/useAssistantInteractable.d.ts.map +1 -1
  32. package/dist/react/interactables-legacy/useAssistantInteractable.js +3 -0
  33. package/dist/react/interactables-legacy/useAssistantInteractable.js.map +1 -1
  34. package/dist/react/model-context/useInteractable.d.ts.map +1 -1
  35. package/dist/react/model-context/useInteractable.js +4 -1
  36. package/dist/react/model-context/useInteractable.js.map +1 -1
  37. package/dist/react/model-context/useInteractableVersions.js +1 -1
  38. package/dist/react/primitives/message/MessageGroupedParts.d.ts +11 -2
  39. package/dist/react/primitives/message/MessageGroupedParts.d.ts.map +1 -1
  40. package/dist/react/primitives/message/MessageGroupedParts.js +3 -2
  41. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  42. package/dist/react/primitives/part/PartMessages.d.ts +1 -0
  43. package/dist/react/primitives/part/PartMessages.d.ts.map +1 -1
  44. package/dist/react/primitives/part/PartMessages.js +1 -0
  45. package/dist/react/primitives/part/PartMessages.js.map +1 -1
  46. package/dist/react/primitives/thread/ThreadMessages.d.ts.map +1 -1
  47. package/dist/react/primitives/thread/ThreadMessages.js +6 -5
  48. package/dist/react/primitives/thread/ThreadMessages.js.map +1 -1
  49. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  50. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +2 -1
  51. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  52. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts +2 -24
  53. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts.map +1 -1
  54. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +135 -374
  55. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  56. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts +1 -0
  57. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  58. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js +22 -2
  59. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js.map +1 -1
  60. package/dist/react/runtimes/cloud/auiV0.d.ts +40 -15
  61. package/dist/react/runtimes/cloud/auiV0.d.ts.map +1 -1
  62. package/dist/react/runtimes/cloud/auiV0.js +62 -15
  63. package/dist/react/runtimes/cloud/auiV0.js.map +1 -1
  64. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.d.ts +2 -1
  65. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.d.ts.map +1 -1
  66. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.js +4 -0
  67. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.js.map +1 -1
  68. package/dist/react/runtimes/cloud/sdkIdentity.d.ts +6 -0
  69. package/dist/react/runtimes/cloud/sdkIdentity.d.ts.map +1 -0
  70. package/dist/react/runtimes/cloud/sdkIdentity.js +9 -0
  71. package/dist/react/runtimes/cloud/sdkIdentity.js.map +1 -0
  72. package/dist/react/runtimes/external-message-converter.d.ts +12 -2
  73. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  74. package/dist/react/runtimes/external-message-converter.js +26 -61
  75. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  76. package/dist/react/runtimes/useRemoteThreadListRuntime.js +1 -1
  77. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  78. package/dist/react/utils/groupParts.d.ts +6 -1
  79. package/dist/react/utils/groupParts.d.ts.map +1 -1
  80. package/dist/react/utils/groupParts.js +6 -0
  81. package/dist/react/utils/groupParts.js.map +1 -1
  82. package/dist/react/utils/useJSONEqual.d.ts +3 -1
  83. package/dist/react/utils/useJSONEqual.d.ts.map +1 -1
  84. package/dist/react/utils/useJSONEqual.js +6 -1
  85. package/dist/react/utils/useJSONEqual.js.map +1 -1
  86. package/dist/react/utils/useJSONSchemaDependency.d.ts +7 -0
  87. package/dist/react/utils/useJSONSchemaDependency.d.ts.map +1 -0
  88. package/dist/react/utils/useJSONSchemaDependency.js +20 -0
  89. package/dist/react/utils/useJSONSchemaDependency.js.map +1 -0
  90. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  91. package/dist/runtime/api/thread-runtime.js +2 -5
  92. package/dist/runtime/api/thread-runtime.js.map +1 -1
  93. package/dist/runtime/base/base-composer-runtime-core.d.ts +1 -0
  94. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  95. package/dist/runtime/base/base-composer-runtime-core.js +46 -10
  96. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  97. package/dist/runtime/base/base-thread-runtime-core.d.ts +2 -0
  98. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  99. package/dist/runtime/base/base-thread-runtime-core.js +155 -64
  100. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  101. package/dist/runtime/interfaces/thread-runtime-core.d.ts +6 -0
  102. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  103. package/dist/runtime/queue/message-queue.d.ts +5 -0
  104. package/dist/runtime/queue/message-queue.d.ts.map +1 -1
  105. package/dist/runtime/queue/message-queue.js +61 -6
  106. package/dist/runtime/queue/message-queue.js.map +1 -1
  107. package/dist/runtime/utils/auto-status.d.ts +6 -2
  108. package/dist/runtime/utils/auto-status.d.ts.map +1 -1
  109. package/dist/runtime/utils/auto-status.js +12 -4
  110. package/dist/runtime/utils/auto-status.js.map +1 -1
  111. package/dist/runtime/utils/external-message-conversion.d.ts +16 -4
  112. package/dist/runtime/utils/external-message-conversion.d.ts.map +1 -1
  113. package/dist/runtime/utils/external-message-conversion.js +48 -12
  114. package/dist/runtime/utils/external-message-conversion.js.map +1 -1
  115. package/dist/runtime/utils/message-repository.js +3 -3
  116. package/dist/runtime/utils/message-repository.js.map +1 -1
  117. package/dist/runtime/utils/thread-message-like.d.ts +2 -1
  118. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  119. package/dist/runtime/utils/thread-message-like.js +4 -2
  120. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  121. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  122. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +8 -1
  123. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  124. package/dist/runtimes/internal.d.ts +1 -1
  125. package/dist/runtimes/local/local-thread-runtime-core.d.ts +1 -1
  126. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  127. package/dist/runtimes/local/local-thread-runtime-core.js +4 -1
  128. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  129. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  130. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +2 -1
  131. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  132. package/dist/store/clients/external-thread.d.ts.map +1 -1
  133. package/dist/store/clients/external-thread.js +241 -200
  134. package/dist/store/clients/external-thread.js.map +1 -1
  135. package/dist/store/clients/thread-tasks.d.ts +13 -0
  136. package/dist/store/clients/thread-tasks.d.ts.map +1 -0
  137. package/dist/store/clients/thread-tasks.js +69 -0
  138. package/dist/store/clients/thread-tasks.js.map +1 -0
  139. package/dist/store/index.d.ts +3 -2
  140. package/dist/store/index.js +3 -3
  141. package/dist/store/primitive-predicates.d.ts.map +1 -1
  142. package/dist/store/primitive-predicates.js +4 -4
  143. package/dist/store/primitive-predicates.js.map +1 -1
  144. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  145. package/dist/store/runtime-clients/thread-runtime-client.js +176 -128
  146. package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
  147. package/dist/store/scope-registration.d.ts +2 -0
  148. package/dist/store/scope-registration.d.ts.map +1 -1
  149. package/dist/store/scopes/task.d.ts +36 -0
  150. package/dist/store/scopes/task.d.ts.map +1 -0
  151. package/dist/store/scopes/task.js +0 -0
  152. package/dist/store/scopes/thread.d.ts +20 -0
  153. package/dist/store/scopes/thread.d.ts.map +1 -1
  154. package/dist/subscribable/subscribable.d.ts +2 -1
  155. package/dist/subscribable/subscribable.d.ts.map +1 -1
  156. package/dist/subscribable/subscribable.js +23 -15
  157. package/dist/subscribable/subscribable.js.map +1 -1
  158. package/dist/types/index.d.ts +2 -2
  159. package/dist/types/message.d.ts +8 -1
  160. package/dist/types/message.d.ts.map +1 -1
  161. package/dist/types/message.js.map +1 -1
  162. package/dist/utils/abortable-iterable.d.ts.map +1 -1
  163. package/dist/utils/abortable-iterable.js +30 -14
  164. package/dist/utils/abortable-iterable.js.map +1 -1
  165. package/dist/utils/getGroupStatus.d.ts +18 -2
  166. package/dist/utils/getGroupStatus.d.ts.map +1 -1
  167. package/dist/utils/getGroupStatus.js +32 -7
  168. package/dist/utils/getGroupStatus.js.map +1 -1
  169. package/dist/utils/wire-media.d.ts.map +1 -1
  170. package/dist/utils/wire-media.js +2 -2
  171. package/dist/utils/wire-media.js.map +1 -1
  172. package/package.json +6 -6
  173. package/src/adapters/speech.test.ts +236 -0
  174. package/src/adapters/speech.ts +17 -10
  175. package/src/adapters/thread-history.ts +3 -0
  176. package/src/index.ts +1 -0
  177. package/src/model-context/frame/host.test.ts +113 -1
  178. package/src/model-context/frame/host.ts +39 -12
  179. package/src/model-context/interactable-composer-metadata.test.ts +30 -0
  180. package/src/model-context/interactable-composer-metadata.ts +10 -10
  181. package/src/react/adapters/LocalStorageThreadListAdapter.test.ts +244 -0
  182. package/src/react/adapters/LocalStorageThreadListAdapter.tsx +104 -7
  183. package/src/react/client/DataRenderers.test.tsx +45 -0
  184. package/src/react/client/DataRenderers.ts +13 -9
  185. package/src/react/client/Interactables.test.ts +44 -1
  186. package/src/react/client/Interactables.ts +7 -1
  187. package/src/react/client/RemoteThreadList.test.ts +41 -1
  188. package/src/react/client/RemoteThreadList.ts +15 -2
  189. package/src/react/client/interactable-model-context.test.ts +44 -0
  190. package/src/react/client/interactable-model-context.ts +3 -2
  191. package/src/react/index.ts +5 -1
  192. package/src/react/interactables-legacy/useAssistantInteractable.test.tsx +132 -0
  193. package/src/react/interactables-legacy/useAssistantInteractable.ts +10 -1
  194. package/src/react/model-context/useInteractable.test.tsx +177 -0
  195. package/src/react/model-context/useInteractable.ts +3 -0
  196. package/src/react/primitive-hooks/useActionBarCopy.test.ts +0 -1
  197. package/src/react/primitive-hooks/useSuggestionTrigger.test.ts +0 -1
  198. package/src/react/primitives/message/MessageGroupedParts.test.tsx +96 -0
  199. package/src/react/primitives/message/MessageGroupedParts.tsx +15 -4
  200. package/src/react/primitives/part/PartMessages.test.tsx +104 -1
  201. package/src/react/primitives/part/PartMessages.tsx +1 -0
  202. package/src/react/primitives/thread/ThreadMessages.test.tsx +142 -0
  203. package/src/react/primitives/thread/ThreadMessages.tsx +8 -5
  204. package/src/react/providers/ReadonlyThreadProvider.test.tsx +56 -0
  205. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +7 -1
  206. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.ts +20 -14
  207. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +221 -1
  208. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +166 -512
  209. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.test.ts +77 -0
  210. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.ts +27 -1
  211. package/src/react/runtimes/cloud/auiV0.ts +160 -25
  212. package/src/react/runtimes/cloud/createCloudThreadListAdapter.test.ts +21 -0
  213. package/src/react/runtimes/cloud/createCloudThreadListAdapter.ts +7 -1
  214. package/src/react/runtimes/cloud/sdkIdentity.ts +9 -0
  215. package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +18 -4
  216. package/src/react/runtimes/external-message-converter.test.ts +136 -1
  217. package/src/react/runtimes/external-message-converter.ts +39 -93
  218. package/src/react/runtimes/useRemoteThreadListRuntime.ts +1 -1
  219. package/src/react/utils/groupParts.ts +12 -1
  220. package/src/react/utils/useJSONEqual.ts +9 -0
  221. package/src/react/utils/useJSONSchemaDependency.ts +22 -0
  222. package/src/runtime/api/thread-runtime.test.ts +58 -0
  223. package/src/runtime/api/thread-runtime.ts +2 -4
  224. package/src/runtime/base/base-composer-runtime-core.ts +69 -16
  225. package/src/runtime/base/base-thread-runtime-core.test.ts +1008 -1
  226. package/src/runtime/base/base-thread-runtime-core.ts +222 -83
  227. package/src/runtime/interfaces/thread-runtime-core.ts +6 -0
  228. package/src/runtime/queue/message-queue.ts +68 -6
  229. package/src/runtime/utils/auto-status.test.ts +112 -1
  230. package/src/runtime/utils/auto-status.ts +31 -5
  231. package/src/runtime/utils/external-message-conversion.test.ts +134 -0
  232. package/src/runtime/utils/external-message-conversion.ts +122 -31
  233. package/src/runtime/utils/message-repository.test.ts +49 -0
  234. package/src/runtime/utils/message-repository.ts +3 -3
  235. package/src/runtime/utils/thread-message-like.test.ts +52 -0
  236. package/src/runtime/utils/thread-message-like.ts +4 -0
  237. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +31 -1
  238. package/src/runtimes/local/local-thread-runtime-core.test.ts +21 -0
  239. package/src/runtimes/local/local-thread-runtime-core.ts +11 -1
  240. package/src/runtimes/tool-invocations/EDGE_CASES.md +2 -6
  241. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +133 -0
  242. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +10 -6
  243. package/src/store/clients/external-thread.ts +19 -1
  244. package/src/store/clients/thread-tasks.test.ts +274 -0
  245. package/src/store/clients/thread-tasks.ts +138 -0
  246. package/src/store/index.ts +8 -0
  247. package/src/store/primitive-predicates.test.ts +48 -5
  248. package/src/store/primitive-predicates.ts +7 -2
  249. package/src/store/runtime-clients/thread-runtime-client.ts +31 -1
  250. package/src/store/scope-registration.ts +2 -0
  251. package/src/store/scopes/task.ts +34 -0
  252. package/src/store/scopes/thread.ts +16 -0
  253. package/src/store/thread-tasks.test.tsx +209 -0
  254. package/src/subscribable/subscribable.test.ts +156 -1
  255. package/src/subscribable/subscribable.ts +25 -15
  256. package/src/tests/MessageRepository.test.ts +1 -5
  257. package/src/tests/auiV0Encode.test.ts +300 -0
  258. package/src/tests/base-composer-runtime-core-send.test.ts +51 -0
  259. package/src/tests/base-composer-runtime-core.test.ts +314 -2
  260. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +151 -0
  261. package/src/tests/external-thread-attachments.test.tsx +69 -0
  262. package/src/tests/external-thread-parity.test.tsx +34 -0
  263. package/src/tests/groupParts.test.ts +71 -0
  264. package/src/tests/message-queue.test.ts +253 -0
  265. package/src/tests/remote-thread-list-reactive-threadId.test.ts +51 -0
  266. package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +43 -0
  267. package/src/types/index.ts +1 -0
  268. package/src/types/message.ts +8 -0
  269. package/src/utils/abortable-iterable.test.ts +115 -0
  270. package/src/utils/abortable-iterable.ts +34 -23
  271. package/src/utils/getGroupStatus.test.ts +70 -4
  272. package/src/utils/getGroupStatus.ts +35 -12
  273. package/src/utils/wire-media.test.ts +10 -0
  274. package/src/utils/wire-media.ts +6 -2
@@ -36,7 +36,10 @@ 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
38
  import { gateInteractableComposerMetadata } from "../../model-context/interactable-composer-metadata";
39
- import { BaseSubscribable } from "../../subscribable/subscribable";
39
+ import {
40
+ BaseSubscribable,
41
+ notifySubscribers,
42
+ } from "../../subscribable/subscribable";
40
43
 
41
44
  type BaseThreadAdapters = {
42
45
  speech?: SpeechSynthesisAdapter | undefined;
@@ -168,7 +171,16 @@ export abstract class BaseThreadRuntimeCore
168
171
  public getEditComposer(messageId: string) {
169
172
  return this._editComposers.get(messageId);
170
173
  }
174
+ protected _isVoiceMessage(messageId: string | null) {
175
+ return (
176
+ messageId !== null && this._voiceMessages.some((m) => m.id === messageId)
177
+ );
178
+ }
179
+
171
180
  public beginEdit(messageId: string) {
181
+ if (this._isVoiceMessage(messageId)) {
182
+ throw new Error("Voice transcript messages cannot be edited");
183
+ }
172
184
  if (this._editComposers.has(messageId))
173
185
  throw new Error("Edit already in progress");
174
186
 
@@ -227,11 +239,27 @@ export abstract class BaseThreadRuntimeCore
227
239
  notifyEventListeners(subscribers, payload, `Thread runtime "${event}"`);
228
240
  }
229
241
 
242
+ protected _notifyToolApprovalAnswered(
243
+ messageId: string,
244
+ toolCallId: string,
245
+ toolName: string,
246
+ approved: boolean,
247
+ ) {
248
+ this._notifyEventSubscribers("toolApprovalAnswered", {
249
+ messageId,
250
+ toolCallId,
251
+ toolName,
252
+ approved,
253
+ });
254
+ }
255
+
230
256
  public submitFeedback({ messageId, type }: SubmitFeedbackOptions) {
231
257
  const adapter = this.adapters?.feedback;
232
258
  if (!adapter) throw new Error("Feedback adapter not configured");
233
259
 
234
- const { message, parentId } = this.repository.getMessage(messageId);
260
+ const entry = this.getMessageById(messageId);
261
+ if (!entry) throw new Error(`Message not found: ${messageId}`);
262
+ const { message, parentId } = entry;
235
263
  adapter.submit({ message, type });
236
264
 
237
265
  if (message.role === "assistant") {
@@ -242,7 +270,18 @@ export abstract class BaseThreadRuntimeCore
242
270
  submittedFeedback: { type },
243
271
  },
244
272
  };
245
- this.repository.addOrUpdateMessage(parentId, updatedMessage);
273
+ const voiceIdx = this._voiceMessages.findIndex(
274
+ (voiceMessage) => voiceMessage.id === messageId,
275
+ );
276
+ if (voiceIdx === -1) {
277
+ this.repository.addOrUpdateMessage(parentId, updatedMessage);
278
+ } else {
279
+ this._voiceMessages[voiceIdx] = updatedMessage;
280
+ if (this._currentAssistantMsg === message) {
281
+ this._currentAssistantMsg = updatedMessage as ThreadAssistantMessage;
282
+ }
283
+ this._markVoiceMessagesDirty();
284
+ }
246
285
  }
247
286
 
248
287
  this._notifySubscribers();
@@ -255,36 +294,80 @@ export abstract class BaseThreadRuntimeCore
255
294
  const adapter = this.adapters?.speech;
256
295
  if (!adapter) throw new Error("Speech adapter not configured");
257
296
 
258
- const { message } = this.repository.getMessage(messageId);
259
-
260
- this._stopSpeaking?.();
297
+ const entry = this.getMessageById(messageId);
298
+ if (!entry) throw new Error(`Message not found: ${messageId}`);
299
+ const { message } = entry;
261
300
 
262
- const utterance = adapter.speak(getThreadMessageText(message));
263
- const unsub = utterance.subscribe(() => {
301
+ const previousStop = this._stopSpeaking;
302
+ let utterance: SpeechSynthesisAdapter.Utterance;
303
+ try {
304
+ previousStop?.();
305
+ utterance = adapter.speak(getThreadMessageText(message));
306
+ } catch (error) {
307
+ if (previousStop && !this._stopSpeaking) {
308
+ try {
309
+ this._notifySubscribers();
310
+ } catch (notificationError) {
311
+ console.error(
312
+ "[assistant-ui] Speech rollback notification threw",
313
+ notificationError,
314
+ );
315
+ }
316
+ }
317
+ throw error;
318
+ }
319
+ let unsub: Unsubscribe | undefined;
320
+ const clear = () => {
321
+ this._stopSpeaking = undefined;
322
+ this.speech = undefined;
323
+ const cleanup = unsub;
324
+ unsub = undefined;
325
+ cleanup?.();
326
+ };
327
+ const stop = () => {
328
+ if (this._stopSpeaking !== stop) return;
329
+ try {
330
+ clear();
331
+ } finally {
332
+ utterance.cancel();
333
+ }
334
+ };
335
+ const update = () => {
336
+ if (this._stopSpeaking !== stop) return;
264
337
  if (utterance.status.type === "ended") {
265
- this._stopSpeaking = undefined;
266
- this.speech = undefined;
338
+ notifySubscribers([clear, () => this._notifySubscribers()]);
267
339
  } else {
268
340
  this.speech = { messageId, status: utterance.status };
341
+ this._notifySubscribers();
269
342
  }
270
- this._notifySubscribers();
271
- });
272
-
273
- this.speech = { messageId, status: utterance.status };
274
- this._notifySubscribers();
275
-
276
- this._stopSpeaking = () => {
277
- utterance.cancel();
278
- unsub();
279
- this.speech = undefined;
280
- this._stopSpeaking = undefined;
281
343
  };
344
+
345
+ this._stopSpeaking = stop;
346
+ try {
347
+ unsub = utterance.subscribe(update);
348
+ if (this._stopSpeaking !== stop) {
349
+ unsub();
350
+ return;
351
+ }
352
+ update();
353
+ } catch (error) {
354
+ if (this._stopSpeaking === stop) {
355
+ try {
356
+ notifySubscribers([stop, () => this._notifySubscribers()]);
357
+ } catch (cleanupError) {
358
+ console.error(
359
+ "[assistant-ui] Speech rollback cleanup threw",
360
+ cleanupError,
361
+ );
362
+ }
363
+ }
364
+ throw error;
365
+ }
282
366
  }
283
367
 
284
368
  public stopSpeaking() {
285
369
  if (!this._stopSpeaking) throw new Error("No message is being spoken");
286
- this._stopSpeaking();
287
- this._notifySubscribers();
370
+ notifySubscribers([this._stopSpeaking, () => this._notifySubscribers()]);
288
371
  }
289
372
 
290
373
  private _voiceSession: RealtimeVoiceAdapter.Session | undefined;
@@ -305,67 +388,111 @@ export abstract class BaseThreadRuntimeCore
305
388
  const adapter = this.adapters?.voice;
306
389
  if (!adapter) throw new Error("Voice adapter not configured");
307
390
 
308
- this.disconnectVoice();
391
+ try {
392
+ this.disconnectVoice();
393
+ } catch (error) {
394
+ console.error(
395
+ "[assistant-ui] Voice cleanup threw before reconnect",
396
+ error,
397
+ );
398
+ }
309
399
 
310
400
  const session = adapter.connect({});
311
401
  this._voiceSession = session;
312
402
  const unsubs: Array<() => void> = [];
403
+ this._voiceUnsubs = unsubs;
313
404
 
314
- let currentMode: RealtimeVoiceAdapter.Mode = "listening";
405
+ // The cleanup-list identity preserves ownership after an ended status clears the session.
406
+ const finishDetachedSetup = () => {
407
+ if (this._voiceSession === session && this._voiceUnsubs === unsubs) {
408
+ return false;
409
+ }
315
410
 
316
- this.voice = {
317
- status: session.status,
318
- isMuted: session.isMuted,
319
- mode: currentMode,
411
+ try {
412
+ notifySubscribers(unsubs.splice(0));
413
+ } catch (error) {
414
+ console.error(
415
+ "[assistant-ui] Detached voice setup cleanup threw",
416
+ error,
417
+ );
418
+ }
419
+ return true;
320
420
  };
321
- this._voiceVolume = 0;
322
- this._notifySubscribers();
323
421
 
324
- unsubs.push(
325
- session.onStatusChange((status) => {
326
- if (status.type === "ended") {
327
- this._finishVoiceAssistantMessage();
328
- this._voiceSession = undefined;
329
- this.voice = undefined;
330
- } else {
331
- this.voice = {
332
- status,
333
- isMuted: session.isMuted,
334
- mode: currentMode,
335
- };
336
- }
337
- this._notifySubscribers();
338
- }),
339
- );
422
+ try {
423
+ let currentMode: RealtimeVoiceAdapter.Mode = "listening";
340
424
 
341
- unsubs.push(
342
- session.onModeChange((mode) => {
343
- currentMode = mode;
344
- if (this.voice) {
345
- this.voice = { ...this.voice, mode };
425
+ this.voice = {
426
+ status: session.status,
427
+ isMuted: session.isMuted,
428
+ mode: currentMode,
429
+ };
430
+ this._voiceVolume = 0;
431
+ this._notifySubscribers();
432
+ if (finishDetachedSetup()) return;
433
+
434
+ unsubs.push(
435
+ session.onStatusChange((status) => {
436
+ if (status.type === "ended") {
437
+ this._finishVoiceAssistantMessage();
438
+ this._voiceSession = undefined;
439
+ this.voice = undefined;
440
+ } else {
441
+ this.voice = {
442
+ status,
443
+ isMuted: session.isMuted,
444
+ mode: currentMode,
445
+ };
446
+ }
346
447
  this._notifySubscribers();
347
- }
348
- }),
349
- );
350
-
351
- unsubs.push(
352
- session.onVolumeChange((volume) => {
353
- this._voiceVolume = volume;
354
- notifyEventListeners(
355
- this._voiceVolumeSubscribers,
356
- undefined,
357
- "Voice volume",
358
- );
359
- }),
360
- );
361
-
362
- unsubs.push(
363
- session.onTranscript((transcript) => {
364
- this._handleVoiceTranscript(transcript);
365
- }),
366
- );
448
+ }),
449
+ );
450
+ if (finishDetachedSetup()) return;
451
+
452
+ unsubs.push(
453
+ session.onModeChange((mode) => {
454
+ currentMode = mode;
455
+ if (this.voice) {
456
+ this.voice = { ...this.voice, mode };
457
+ this._notifySubscribers();
458
+ }
459
+ }),
460
+ );
461
+ if (finishDetachedSetup()) return;
462
+
463
+ unsubs.push(
464
+ session.onVolumeChange((volume) => {
465
+ this._voiceVolume = volume;
466
+ notifyEventListeners(
467
+ this._voiceVolumeSubscribers,
468
+ undefined,
469
+ "Voice volume",
470
+ );
471
+ }),
472
+ );
473
+ if (finishDetachedSetup()) return;
367
474
 
368
- this._voiceUnsubs = unsubs;
475
+ unsubs.push(
476
+ session.onTranscript((transcript) => {
477
+ this._handleVoiceTranscript(transcript);
478
+ }),
479
+ );
480
+ finishDetachedSetup();
481
+ } catch (error) {
482
+ if (this._voiceSession === session && this._voiceUnsubs === unsubs) {
483
+ try {
484
+ this.disconnectVoice();
485
+ } catch (cleanupError) {
486
+ console.error(
487
+ "[assistant-ui] Voice rollback cleanup threw",
488
+ cleanupError,
489
+ );
490
+ }
491
+ } else {
492
+ finishDetachedSetup();
493
+ }
494
+ throw error;
495
+ }
369
496
  }
370
497
 
371
498
  private _currentAssistantMsg: ThreadAssistantMessage | null = null;
@@ -384,7 +511,7 @@ export abstract class BaseThreadRuntimeCore
384
511
  id: generateId(),
385
512
  role: "user",
386
513
  content: [{ type: "text", text: transcript.text }],
387
- metadata: { custom: {} },
514
+ metadata: { modality: "voice", custom: {} },
388
515
  createdAt: new Date(),
389
516
  status: { type: "complete", reason: "unknown" },
390
517
  attachments: [],
@@ -407,6 +534,7 @@ export abstract class BaseThreadRuntimeCore
407
534
  unstable_annotations: [],
408
535
  unstable_data: [],
409
536
  steps: [],
537
+ modality: "voice",
410
538
  custom: {},
411
539
  },
412
540
  status,
@@ -448,22 +576,33 @@ export abstract class BaseThreadRuntimeCore
448
576
  }
449
577
 
450
578
  public disconnectVoice() {
451
- this._finishVoiceAssistantMessage();
452
579
  this._currentAssistantMsg = null;
453
- for (const unsub of this._voiceUnsubs) unsub();
580
+ // Drain the shared list in place so reentrant setup cannot release the same handles again.
581
+ const unsubs = this._voiceUnsubs.splice(0);
454
582
  this._voiceUnsubs = [];
455
- this._voiceSession?.disconnect();
583
+ const session = this._voiceSession;
456
584
  this._voiceSession = undefined;
457
585
  this.voice = undefined;
458
586
  this._voiceVolume = 0;
459
- notifyEventListeners(
460
- this._voiceVolumeSubscribers,
461
- undefined,
462
- "Voice volume",
463
- );
587
+ const stopSpeaking =
588
+ this.speech && this._isVoiceMessage(this.speech.messageId)
589
+ ? this._stopSpeaking
590
+ : undefined;
464
591
  this._voiceMessages = [];
465
592
  this._markVoiceMessagesDirty();
466
- this._notifySubscribers();
593
+
594
+ notifySubscribers([
595
+ ...unsubs,
596
+ ...(stopSpeaking ? [stopSpeaking] : []),
597
+ ...(session ? [() => session.disconnect()] : []),
598
+ () =>
599
+ notifyEventListeners(
600
+ this._voiceVolumeSubscribers,
601
+ undefined,
602
+ "Voice volume",
603
+ ),
604
+ () => this._notifySubscribers(),
605
+ ]);
467
606
  }
468
607
 
469
608
  public muteVoice() {
@@ -98,6 +98,12 @@ export type SubmittedFeedback = {
98
98
  };
99
99
 
100
100
  export type ThreadRuntimeEventPayload = {
101
+ toolApprovalAnswered: {
102
+ messageId: string;
103
+ toolCallId: string;
104
+ toolName: string;
105
+ approved: boolean;
106
+ };
101
107
  /**
102
108
  * @deprecated State-derivable. Observe `state.isRunning` flipping to `true`
103
109
  * via `subscribe` + `getState` instead. Note: this event fires at the
@@ -13,6 +13,11 @@ import type {
13
13
  } from "./external-thread-queue-adapter";
14
14
 
15
15
  export type MessageQueueDriver = {
16
+ /**
17
+ * A synchronous throw is treated as a run that never started and restores
18
+ * the message. A driver that already started work must call `notifyBusy`
19
+ * before throwing.
20
+ */
16
21
  run: (message: AppendMessage, options: { steer: boolean }) => void;
17
22
  /** When omitted, steering degrades to "process next" instead of interrupting. */
18
23
  cancel?: (() => void) | undefined;
@@ -38,6 +43,12 @@ export type MessageQueueController = {
38
43
 
39
44
  type Lane = "queue" | "steer";
40
45
 
46
+ type DispatchItem = {
47
+ id: string;
48
+ item: QueueItemState;
49
+ message: AppendMessage;
50
+ };
51
+
41
52
  const getQueueItemParts = (
42
53
  message: AppendMessage,
43
54
  ): readonly (FileMessagePart | TextMessagePart)[] => {
@@ -83,6 +94,7 @@ export const createMessageQueue = (
83
94
  // settles from cancelled runs that must drop `running` without advancing
84
95
  let cancelSettles = 0;
85
96
  let interrupting = false;
97
+ let busyEdges = 0;
86
98
 
87
99
  const notify = () => {
88
100
  notifyEventListeners(subscribers, undefined, "Message queue");
@@ -101,6 +113,19 @@ export const createMessageQueue = (
101
113
  parts: getQueueItemParts(message),
102
114
  });
103
115
 
116
+ const restore = (lane: Lane, dispatch: DispatchItem, index = 0) => {
117
+ paused = true;
118
+ messages.set(dispatch.id, dispatch.message);
119
+ setLanes({
120
+ ...lanes,
121
+ [lane]: [
122
+ ...lanes[lane].slice(0, index),
123
+ dispatch.item,
124
+ ...lanes[lane].slice(index),
125
+ ],
126
+ });
127
+ };
128
+
104
129
  const laneOf = (queueItemId: string): Lane | undefined => {
105
130
  if (lanes.steer.some((item) => item.id === queueItemId)) return "steer";
106
131
  if (lanes.queue.some((item) => item.id === queueItemId)) return "queue";
@@ -116,27 +141,61 @@ export const createMessageQueue = (
116
141
  messages.delete(head.id);
117
142
  setLanes({ ...lanes, [lane]: lanes[lane].slice(1) });
118
143
  if (!message) return;
144
+ const dispatch = { id: head.id, item: head, message };
119
145
  running = true;
120
- driver.run(dispatchTransform(message), { steer: false });
146
+ const busyEdgesBeforeRun = busyEdges;
147
+ try {
148
+ driver.run(dispatchTransform(message), { steer: false });
149
+ } catch (error) {
150
+ if (busyEdges === busyEdgesBeforeRun) {
151
+ running = false;
152
+ restore(lane, dispatch);
153
+ }
154
+ throw error;
155
+ }
121
156
  };
122
157
 
123
- const interrupt = (message: AppendMessage) => {
158
+ const interrupt = (
159
+ dispatch: DispatchItem,
160
+ restoreLane: Lane = "steer",
161
+ restoreIndex = 0,
162
+ ) => {
124
163
  paused = false;
125
164
  // the interrupted run settles exactly once, whether or not it was
126
165
  // already cancel-notified
127
166
  suppressIdle += Math.max(cancelSettles, 1);
128
167
  cancelSettles = 0;
168
+ const restoreInterrupted = (replacementStarted = false) => {
169
+ if (replacementStarted) return;
170
+ // The live count distinguishes an outstanding cancellation settle
171
+ // from one delivered synchronously by cancel().
172
+ const pendingSettles = suppressIdle;
173
+ suppressIdle = Math.max(pendingSettles - 1, 0);
174
+ cancelSettles = pendingSettles > 0 ? 1 : 0;
175
+ running = pendingSettles > 0;
176
+ restore(restoreLane, dispatch, restoreIndex);
177
+ };
129
178
  // a driver whose cancel routes through the runtime notifies this queue
130
179
  // back; the interrupt already accounted for that settle and is dispatching
131
180
  // in its place
132
181
  interrupting = true;
133
182
  try {
134
183
  driver.cancel!();
184
+ } catch (error) {
185
+ restoreInterrupted();
186
+ throw error;
135
187
  } finally {
136
188
  interrupting = false;
137
189
  }
138
190
  running = true;
139
- driver.run(dispatchTransform(message), { steer: true });
191
+ const busyEdgesBeforeRun = busyEdges;
192
+ try {
193
+ driver.run(dispatchTransform(dispatch.message), { steer: true });
194
+ } catch (error) {
195
+ const replacementStarted = busyEdges !== busyEdgesBeforeRun;
196
+ restoreInterrupted(replacementStarted);
197
+ throw error;
198
+ }
140
199
  };
141
200
 
142
201
  const push = (lane: Lane, message: AppendMessage) => {
@@ -153,7 +212,8 @@ export const createMessageQueue = (
153
212
 
154
213
  const steer = (message: AppendMessage) => {
155
214
  if (running && driver.cancel) {
156
- interrupt(message);
215
+ const id = generateId();
216
+ interrupt({ id, item: toItem(id, message), message });
157
217
  return;
158
218
  }
159
219
  push("steer", message);
@@ -164,7 +224,8 @@ export const createMessageQueue = (
164
224
  if (!fromLane) throw new Error(`Unknown queue item "${queueItemId}".`);
165
225
  const toLane = placement.lane ?? fromLane;
166
226
 
167
- const item = lanes[fromLane].find((i) => i.id === queueItemId)!;
227
+ const fromIndex = lanes[fromLane].findIndex((i) => i.id === queueItemId);
228
+ const item = lanes[fromLane][fromIndex]!;
168
229
  const dest = (toLane === fromLane ? lanes[fromLane] : lanes[toLane]).filter(
169
230
  (i) => i.id !== queueItemId,
170
231
  );
@@ -217,7 +278,7 @@ export const createMessageQueue = (
217
278
  queue: lanes.queue.filter((i) => i.id !== queueItemId),
218
279
  steer: lanes.steer,
219
280
  });
220
- interrupt(message);
281
+ interrupt({ id: queueItemId, item, message }, fromLane, fromIndex);
221
282
  return;
222
283
  }
223
284
 
@@ -280,6 +341,7 @@ export const createMessageQueue = (
280
341
  suppressIdle += cancelSettles;
281
342
  cancelSettles = 0;
282
343
  running = true;
344
+ busyEdges++;
283
345
  },
284
346
  notifyIdle: () => {
285
347
  if (suppressIdle > 0) {
@@ -1,5 +1,33 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { getAutoStatus } from "./auto-status";
2
+ import type { MessageStatus, ThreadMessage } from "../../types/message";
3
+ import {
4
+ getAutoStatus,
5
+ getContentAutoStatus,
6
+ isBackgroundToolCall,
7
+ } from "./auto-status";
8
+
9
+ const nestedAssistantMessage = (status: MessageStatus): ThreadMessage => ({
10
+ id: "nested-assistant",
11
+ createdAt: new Date(0),
12
+ role: "assistant",
13
+ content: [],
14
+ status,
15
+ metadata: {
16
+ unstable_state: {},
17
+ unstable_annotations: [],
18
+ unstable_data: [],
19
+ steps: [],
20
+ custom: {},
21
+ },
22
+ });
23
+
24
+ const pendingToolCall = (messages?: readonly ThreadMessage[]) => ({
25
+ type: "tool-call" as const,
26
+ toolName: "delegate",
27
+ args: {},
28
+ argsText: "",
29
+ messages,
30
+ });
3
31
 
4
32
  describe("getAutoStatus", () => {
5
33
  it("reports a cancelled message as incomplete", () => {
@@ -54,4 +82,87 @@ describe("getAutoStatus", () => {
54
82
  ).toMatchObject(expected);
55
83
  },
56
84
  );
85
+
86
+ it.each([true, false])(
87
+ "keeps a background tool call running when isLast is %s",
88
+ (isLast) => {
89
+ expect(
90
+ getContentAutoStatus(
91
+ [pendingToolCall([nestedAssistantMessage({ type: "running" })])],
92
+ isLast,
93
+ false,
94
+ ),
95
+ ).toMatchObject({ type: "running" });
96
+ },
97
+ );
98
+
99
+ it("reads the trailing nested message, not an earlier running one", () => {
100
+ expect(
101
+ isBackgroundToolCall(
102
+ pendingToolCall([
103
+ nestedAssistantMessage({ type: "running" }),
104
+ nestedAssistantMessage({ type: "complete", reason: "unknown" }),
105
+ ]),
106
+ ),
107
+ ).toBe(false);
108
+ });
109
+
110
+ it("lets cancellation clear a background tool call", () => {
111
+ expect(
112
+ getAutoStatus(true, false, false, true, undefined, true, true),
113
+ ).toMatchObject({ type: "requires-action", reason: "tool-calls" });
114
+ });
115
+
116
+ it("keeps a settled nested tool call pending", () => {
117
+ expect(
118
+ getContentAutoStatus(
119
+ [
120
+ pendingToolCall([
121
+ nestedAssistantMessage({ type: "complete", reason: "unknown" }),
122
+ ]),
123
+ ],
124
+ true,
125
+ false,
126
+ ),
127
+ ).toMatchObject({ type: "requires-action", reason: "tool-calls" });
128
+ });
129
+
130
+ it("keeps an interrupted tool call ahead of a background tool call", () => {
131
+ expect(
132
+ getContentAutoStatus(
133
+ [
134
+ {
135
+ ...pendingToolCall(),
136
+ interrupt: { type: "human", payload: {} },
137
+ },
138
+ pendingToolCall([nestedAssistantMessage({ type: "running" })]),
139
+ ],
140
+ false,
141
+ false,
142
+ ),
143
+ ).toMatchObject({ type: "requires-action", reason: "interrupt" });
144
+ });
145
+
146
+ it.each([
147
+ ["a result", { ...pendingToolCall(), result: {} }],
148
+ ["no nested messages", pendingToolCall()],
149
+ [
150
+ "only a user nested message",
151
+ pendingToolCall([
152
+ {
153
+ id: "nested-user",
154
+ createdAt: new Date(0),
155
+ role: "user",
156
+ content: [],
157
+ attachments: [],
158
+ metadata: { custom: {} },
159
+ },
160
+ ]),
161
+ ],
162
+ ])(
163
+ "does not treat a tool call with %s as a background call",
164
+ (_label, call) => {
165
+ expect(isBackgroundToolCall(call)).toBe(false);
166
+ },
167
+ );
57
168
  });