@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
@@ -1,4 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import type { FeedbackAdapter } from "../../adapters/feedback";
3
+ import type { SpeechSynthesisAdapter } from "../../adapters/speech";
2
4
  import type { RealtimeVoiceAdapter } from "../../adapters/voice";
3
5
  import type { ModelContextProvider } from "../../model-context/types";
4
6
  import type { AppendMessage } from "../../types/message";
@@ -13,6 +15,7 @@ import type {
13
15
  ThreadSuggestion,
14
16
  } from "../interfaces/thread-runtime-core";
15
17
  import { BaseThreadRuntimeCore } from "./base-thread-runtime-core";
18
+ import { LocalRuntimeCore } from "../../runtimes/local/local-runtime-core";
16
19
 
17
20
  const createVoiceAdapter = () => {
18
21
  let volumeCallback: ((volume: number) => void) | undefined;
@@ -57,17 +60,26 @@ const createVoiceAdapter = () => {
57
60
 
58
61
  class TestRuntime extends BaseThreadRuntimeCore {
59
62
  private readonly voiceAdapter: ReturnType<typeof createVoiceAdapter>;
63
+ private readonly actionAdapters: {
64
+ speech?: SpeechSynthesisAdapter;
65
+ feedback?: FeedbackAdapter;
66
+ };
60
67
 
61
68
  constructor(
62
69
  voiceAdapter: ReturnType<typeof createVoiceAdapter>,
63
70
  contextProvider: ModelContextProvider = { getModelContext: () => ({}) },
71
+ actionAdapters: {
72
+ speech?: SpeechSynthesisAdapter;
73
+ feedback?: FeedbackAdapter;
74
+ } = {},
64
75
  ) {
65
76
  super(contextProvider);
66
77
  this.voiceAdapter = voiceAdapter;
78
+ this.actionAdapters = actionAdapters;
67
79
  }
68
80
 
69
81
  get adapters() {
70
- return { voice: this.voiceAdapter.adapter };
82
+ return { voice: this.voiceAdapter.adapter, ...this.actionAdapters };
71
83
  }
72
84
 
73
85
  get isDisabled() {
@@ -128,6 +140,476 @@ afterEach(() => {
128
140
  vi.restoreAllMocks();
129
141
  });
130
142
 
143
+ describe("BaseThreadRuntimeCore speech lifecycle", () => {
144
+ const createUtterance = () => {
145
+ let notify = () => {};
146
+ const unsubscribe = vi.fn();
147
+ const utterance: SpeechSynthesisAdapter.Utterance = {
148
+ status: { type: "running" },
149
+ cancel: vi.fn(),
150
+ subscribe: (callback) => {
151
+ notify = callback;
152
+ return unsubscribe;
153
+ },
154
+ };
155
+ return { utterance, unsubscribe, notify: () => notify() };
156
+ };
157
+
158
+ const createThread = (speech: SpeechSynthesisAdapter) => {
159
+ const runtime = new LocalRuntimeCore(
160
+ {
161
+ adapters: {
162
+ chatModel: {
163
+ async run() {
164
+ return {};
165
+ },
166
+ },
167
+ speech,
168
+ },
169
+ },
170
+ undefined,
171
+ );
172
+ const thread = runtime.threads.getMainThreadRuntimeCore();
173
+ thread.reset([
174
+ {
175
+ id: "first",
176
+ role: "assistant",
177
+ content: [{ type: "text", text: "Hello" }],
178
+ },
179
+ {
180
+ id: "second",
181
+ role: "assistant",
182
+ content: [{ type: "text", text: "Again" }],
183
+ },
184
+ ]);
185
+ return thread;
186
+ };
187
+
188
+ it("releases a session that completes during subscription", () => {
189
+ const { utterance, unsubscribe } = createUtterance();
190
+ utterance.subscribe = (callback) => {
191
+ utterance.status = { type: "ended", reason: "finished" };
192
+ callback();
193
+ return unsubscribe;
194
+ };
195
+ const thread = createThread({ speak: () => utterance });
196
+
197
+ thread.speak("first");
198
+
199
+ expect(thread.speech).toBeUndefined();
200
+ expect(unsubscribe).toHaveBeenCalledOnce();
201
+ expect(utterance.cancel).not.toHaveBeenCalled();
202
+ expect(() => thread.stopSpeaking()).toThrow("No message is being spoken");
203
+ });
204
+
205
+ it("does not install an already-ended utterance", () => {
206
+ const { utterance, unsubscribe } = createUtterance();
207
+ utterance.status = { type: "ended", reason: "finished" };
208
+ const thread = createThread({ speak: () => utterance });
209
+
210
+ thread.speak("first");
211
+
212
+ expect(thread.speech).toBeUndefined();
213
+ expect(unsubscribe).toHaveBeenCalledOnce();
214
+ });
215
+
216
+ it.each(["finished", "error", "cancelled"] as const)(
217
+ "releases the listener after %s completion",
218
+ (reason) => {
219
+ const { utterance, unsubscribe, notify } = createUtterance();
220
+ const thread = createThread({ speak: () => utterance });
221
+ thread.speak("first");
222
+ const subscriber = vi.fn();
223
+ thread.subscribe(subscriber);
224
+
225
+ utterance.status = { type: "ended", reason };
226
+ notify();
227
+ notify();
228
+
229
+ expect(thread.speech).toBeUndefined();
230
+ expect(unsubscribe).toHaveBeenCalledOnce();
231
+ expect(subscriber).toHaveBeenCalledOnce();
232
+ expect(utterance.cancel).not.toHaveBeenCalled();
233
+ },
234
+ );
235
+
236
+ it("ignores old running and ended callbacks after replacement", () => {
237
+ const first = createUtterance();
238
+ const second = createUtterance();
239
+ const speak = vi
240
+ .fn()
241
+ .mockReturnValueOnce(first.utterance)
242
+ .mockReturnValueOnce(second.utterance);
243
+ const thread = createThread({ speak });
244
+ thread.speak("first");
245
+ thread.speak("second");
246
+ const subscriber = vi.fn();
247
+ thread.subscribe(subscriber);
248
+
249
+ first.notify();
250
+ first.utterance.status = { type: "ended", reason: "cancelled" };
251
+ first.notify();
252
+
253
+ expect(thread.speech).toEqual({
254
+ messageId: "second",
255
+ status: { type: "running" },
256
+ });
257
+ expect(subscriber).not.toHaveBeenCalled();
258
+ expect(first.utterance.cancel).toHaveBeenCalledOnce();
259
+ expect(first.unsubscribe).toHaveBeenCalledOnce();
260
+ thread.stopSpeaking();
261
+ expect(second.utterance.cancel).toHaveBeenCalledOnce();
262
+ expect(second.unsubscribe).toHaveBeenCalledOnce();
263
+ });
264
+
265
+ it("clears state before synchronous cancellation callbacks", () => {
266
+ const { utterance, unsubscribe, notify } = createUtterance();
267
+ utterance.cancel = vi.fn(() => {
268
+ utterance.status = { type: "ended", reason: "cancelled" };
269
+ notify();
270
+ });
271
+ const thread = createThread({ speak: () => utterance });
272
+ thread.speak("first");
273
+ const subscriber = vi.fn();
274
+ thread.subscribe(subscriber);
275
+
276
+ thread.stopSpeaking();
277
+
278
+ expect(thread.speech).toBeUndefined();
279
+ expect(utterance.cancel).toHaveBeenCalledOnce();
280
+ expect(unsubscribe).toHaveBeenCalledOnce();
281
+ expect(subscriber).toHaveBeenCalledOnce();
282
+ });
283
+
284
+ it.each(["cancellation", "unsubscribe"] as const)(
285
+ "notifies stopped state when %s throws",
286
+ (failure) => {
287
+ const { utterance, unsubscribe } = createUtterance();
288
+ const error = new Error("cleanup failed");
289
+ const thread = createThread({ speak: () => utterance });
290
+ thread.speak("first");
291
+ let observedSpeech = thread.speech;
292
+ const subscriber = vi.fn(() => {
293
+ observedSpeech = thread.speech;
294
+ });
295
+ thread.subscribe(subscriber);
296
+ if (failure === "cancellation") {
297
+ utterance.cancel = vi.fn(() => {
298
+ throw error;
299
+ });
300
+ } else {
301
+ unsubscribe.mockImplementation(() => {
302
+ throw error;
303
+ });
304
+ }
305
+
306
+ expect(() => thread.stopSpeaking()).toThrow(error);
307
+
308
+ expect(observedSpeech).toBeUndefined();
309
+ expect(thread.speech).toBeUndefined();
310
+ expect(subscriber).toHaveBeenCalledOnce();
311
+ expect(unsubscribe).toHaveBeenCalledOnce();
312
+ expect(utterance.cancel).toHaveBeenCalledOnce();
313
+ expect(() => thread.stopSpeaking()).toThrow("No message is being spoken");
314
+ expect(subscriber).toHaveBeenCalledOnce();
315
+ },
316
+ );
317
+
318
+ it("reports stop and notification failures after notifying later subscribers", () => {
319
+ vi.spyOn(console, "error").mockImplementation(() => {});
320
+ const { utterance } = createUtterance();
321
+ const cleanupError = new Error("cleanup failed");
322
+ const notificationError = new Error("notification failed");
323
+ utterance.cancel = vi.fn(() => {
324
+ throw cleanupError;
325
+ });
326
+ const thread = createThread({ speak: () => utterance });
327
+ thread.speak("first");
328
+ thread.subscribe(() => {
329
+ throw notificationError;
330
+ });
331
+ const laterSubscriber = vi.fn();
332
+ thread.subscribe(laterSubscriber);
333
+
334
+ expect(() => thread.stopSpeaking()).toThrow(
335
+ expect.objectContaining({
336
+ errors: [cleanupError, notificationError],
337
+ }),
338
+ );
339
+
340
+ expect(thread.speech).toBeUndefined();
341
+ expect(laterSubscriber).toHaveBeenCalledOnce();
342
+ });
343
+
344
+ it("notifies completed state even when terminal unsubscribe throws", () => {
345
+ const { utterance, unsubscribe, notify } = createUtterance();
346
+ const error = new Error("unsubscribe failed");
347
+ unsubscribe.mockImplementation(() => {
348
+ throw error;
349
+ });
350
+ const thread = createThread({ speak: () => utterance });
351
+ thread.speak("first");
352
+ let observedSpeech = thread.speech;
353
+ const subscriber = vi.fn(() => {
354
+ observedSpeech = thread.speech;
355
+ });
356
+ thread.subscribe(subscriber);
357
+ utterance.status = { type: "ended", reason: "finished" };
358
+
359
+ expect(notify).toThrow(error);
360
+
361
+ expect(observedSpeech).toBeUndefined();
362
+ expect(subscriber).toHaveBeenCalledOnce();
363
+ expect(unsubscribe).toHaveBeenCalledOnce();
364
+ expect(utterance.cancel).not.toHaveBeenCalled();
365
+ expect(notify).not.toThrow();
366
+ expect(subscriber).toHaveBeenCalledOnce();
367
+ });
368
+
369
+ it("cancels playback if a subscriber throws during initial state publication", () => {
370
+ vi.spyOn(console, "error").mockImplementation(() => {});
371
+ const { utterance, unsubscribe } = createUtterance();
372
+ const error = new Error("subscriber failed");
373
+ const thread = createThread({ speak: () => utterance });
374
+ thread.subscribe(() => {
375
+ throw error;
376
+ });
377
+
378
+ expect(() => thread.speak("first")).toThrow(error);
379
+
380
+ expect(thread.speech).toBeUndefined();
381
+ expect(unsubscribe).toHaveBeenCalledOnce();
382
+ expect(utterance.cancel).toHaveBeenCalledOnce();
383
+ expect(() => thread.stopSpeaking()).toThrow("No message is being spoken");
384
+ });
385
+
386
+ it("cancels a session when subscribing throws", () => {
387
+ const { utterance } = createUtterance();
388
+ const error = new Error("subscription failed");
389
+ utterance.subscribe = () => {
390
+ throw error;
391
+ };
392
+ const thread = createThread({ speak: () => utterance });
393
+
394
+ expect(() => thread.speak("first")).toThrow(error);
395
+ expect(thread.speech).toBeUndefined();
396
+ expect(utterance.cancel).toHaveBeenCalledOnce();
397
+ expect(() => thread.stopSpeaking()).toThrow("No message is being spoken");
398
+ });
399
+
400
+ it("notifies observers when replacement subscription fails", () => {
401
+ const first = createUtterance();
402
+ const second = createUtterance();
403
+ const error = new Error("subscription failed");
404
+ second.utterance.subscribe = () => {
405
+ throw error;
406
+ };
407
+ const thread = createThread({
408
+ speak: vi
409
+ .fn()
410
+ .mockReturnValueOnce(first.utterance)
411
+ .mockReturnValueOnce(second.utterance),
412
+ });
413
+ thread.speak("first");
414
+ let observedSpeech = thread.speech;
415
+ const subscriber = vi.fn(() => {
416
+ observedSpeech = thread.speech;
417
+ });
418
+ thread.subscribe(subscriber);
419
+
420
+ expect(() => thread.speak("second")).toThrow(error);
421
+
422
+ expect(observedSpeech).toBeUndefined();
423
+ expect(subscriber).toHaveBeenCalledOnce();
424
+ expect(second.utterance.cancel).toHaveBeenCalledOnce();
425
+ });
426
+
427
+ it.each(["creation", "cancellation", "unsubscribe"] as const)(
428
+ "notifies observers when replacement fails during %s",
429
+ (failure) => {
430
+ const first = createUtterance();
431
+ const error = new Error("replacement failed");
432
+ const speak = vi
433
+ .fn()
434
+ .mockReturnValueOnce(first.utterance)
435
+ .mockImplementationOnce(() => {
436
+ throw error;
437
+ });
438
+ const thread = createThread({ speak });
439
+ thread.speak("first");
440
+ let observedSpeech = thread.speech;
441
+ const subscriber = vi.fn(() => {
442
+ observedSpeech = thread.speech;
443
+ });
444
+ thread.subscribe(subscriber);
445
+ if (failure === "cancellation") {
446
+ first.utterance.cancel = vi.fn(() => {
447
+ throw error;
448
+ });
449
+ } else if (failure === "unsubscribe") {
450
+ first.unsubscribe.mockImplementation(() => {
451
+ throw error;
452
+ });
453
+ }
454
+
455
+ expect(() => thread.speak("second")).toThrow(error);
456
+
457
+ expect(observedSpeech).toBeUndefined();
458
+ expect(thread.speech).toBeUndefined();
459
+ expect(subscriber).toHaveBeenCalledOnce();
460
+ expect(first.unsubscribe).toHaveBeenCalledOnce();
461
+ expect(first.utterance.cancel).toHaveBeenCalledOnce();
462
+ expect(speak).toHaveBeenCalledTimes(failure === "creation" ? 2 : 1);
463
+ },
464
+ );
465
+
466
+ it("preserves a creation error when rollback notification throws", () => {
467
+ const consoleError = vi
468
+ .spyOn(console, "error")
469
+ .mockImplementation(() => {});
470
+ const first = createUtterance();
471
+ const creationError = new Error("creation failed");
472
+ const notificationError = new Error("notification failed");
473
+ const thread = createThread({
474
+ speak: vi
475
+ .fn()
476
+ .mockReturnValueOnce(first.utterance)
477
+ .mockImplementationOnce(() => {
478
+ throw creationError;
479
+ }),
480
+ });
481
+ thread.speak("first");
482
+ thread.subscribe(() => {
483
+ throw notificationError;
484
+ });
485
+ const laterSubscriber = vi.fn();
486
+ thread.subscribe(laterSubscriber);
487
+
488
+ expect(() => thread.speak("second")).toThrow(creationError);
489
+
490
+ expect(thread.speech).toBeUndefined();
491
+ expect(laterSubscriber).toHaveBeenCalledOnce();
492
+ expect(consoleError).toHaveBeenCalledWith(
493
+ "[assistant-ui] Speech rollback notification threw",
494
+ notificationError,
495
+ );
496
+ });
497
+
498
+ it("does not notify when initial utterance creation fails", () => {
499
+ const error = new Error("creation failed");
500
+ const thread = createThread({
501
+ speak: () => {
502
+ throw error;
503
+ },
504
+ });
505
+ const subscriber = vi.fn();
506
+ thread.subscribe(subscriber);
507
+
508
+ expect(() => thread.speak("first")).toThrow(error);
509
+
510
+ expect(thread.speech).toBeUndefined();
511
+ expect(subscriber).not.toHaveBeenCalled();
512
+ });
513
+
514
+ it("preserves reentrant speech when the outer creation throws", () => {
515
+ const first = createUtterance();
516
+ const replacement = createUtterance();
517
+ const error = new Error("outer creation failed");
518
+ const speak = vi.fn().mockReturnValue(first.utterance);
519
+ const thread = createThread({ speak });
520
+ thread.speak("first");
521
+ speak
522
+ .mockImplementationOnce(() => {
523
+ thread.speak("second");
524
+ throw error;
525
+ })
526
+ .mockReturnValueOnce(replacement.utterance);
527
+ const subscriber = vi.fn();
528
+ thread.subscribe(subscriber);
529
+
530
+ expect(() => thread.speak("second")).toThrow(error);
531
+
532
+ expect(thread.speech?.messageId).toBe("second");
533
+ expect(replacement.utterance.cancel).not.toHaveBeenCalled();
534
+ expect(subscriber).toHaveBeenCalledOnce();
535
+ thread.stopSpeaking();
536
+ expect(replacement.unsubscribe).toHaveBeenCalledOnce();
537
+ });
538
+
539
+ it("preserves the setup error when rollback cancellation throws", () => {
540
+ const consoleError = vi
541
+ .spyOn(console, "error")
542
+ .mockImplementation(() => {});
543
+ const { utterance } = createUtterance();
544
+ const setupError = new Error("subscription failed");
545
+ const cleanupError = new Error("cancellation failed");
546
+ utterance.subscribe = () => {
547
+ throw setupError;
548
+ };
549
+ utterance.cancel = vi.fn(() => {
550
+ throw cleanupError;
551
+ });
552
+ const thread = createThread({ speak: () => utterance });
553
+ const subscriber = vi.fn();
554
+ thread.subscribe(subscriber);
555
+
556
+ expect(() => thread.speak("first")).toThrow(setupError);
557
+
558
+ expect(thread.speech).toBeUndefined();
559
+ expect(subscriber).toHaveBeenCalledOnce();
560
+ expect(consoleError).toHaveBeenCalledWith(
561
+ "[assistant-ui] Speech rollback cleanup threw",
562
+ cleanupError,
563
+ );
564
+ });
565
+
566
+ it("does not notify twice when subscription completes before throwing", () => {
567
+ const { utterance } = createUtterance();
568
+ const error = new Error("subscription failed");
569
+ utterance.subscribe = (callback) => {
570
+ utterance.status = { type: "ended", reason: "finished" };
571
+ callback();
572
+ throw error;
573
+ };
574
+ const thread = createThread({ speak: () => utterance });
575
+ const subscriber = vi.fn();
576
+ thread.subscribe(subscriber);
577
+
578
+ expect(() => thread.speak("first")).toThrow(error);
579
+
580
+ expect(thread.speech).toBeUndefined();
581
+ expect(subscriber).toHaveBeenCalledOnce();
582
+ expect(utterance.cancel).not.toHaveBeenCalled();
583
+ });
584
+
585
+ it("leaves a reentrant replacement active when the old subscription throws", () => {
586
+ const first = createUtterance();
587
+ const second = createUtterance();
588
+ const thread = createThread({
589
+ speak: vi
590
+ .fn()
591
+ .mockReturnValueOnce(first.utterance)
592
+ .mockReturnValueOnce(second.utterance),
593
+ });
594
+ const error = new Error("old subscription failed");
595
+ first.utterance.subscribe = () => {
596
+ thread.speak("second");
597
+ throw error;
598
+ };
599
+ const subscriber = vi.fn();
600
+ thread.subscribe(subscriber);
601
+
602
+ expect(() => thread.speak("first")).toThrow(error);
603
+
604
+ expect(thread.speech?.messageId).toBe("second");
605
+ expect(subscriber).toHaveBeenCalledOnce();
606
+ expect(first.utterance.cancel).toHaveBeenCalledOnce();
607
+ expect(second.utterance.cancel).not.toHaveBeenCalled();
608
+ thread.stopSpeaking();
609
+ expect(second.unsubscribe).toHaveBeenCalledOnce();
610
+ });
611
+ });
612
+
131
613
  describe("BaseThreadRuntimeCore subscriptions", () => {
132
614
  it("notifies later subscribers when an earlier subscriber throws", () => {
133
615
  const runtime = new TestRuntime(createVoiceAdapter());
@@ -231,6 +713,292 @@ describe("BaseThreadRuntimeCore subscriptions", () => {
231
713
  });
232
714
 
233
715
  describe("BaseThreadRuntimeCore voice volume subscriptions", () => {
716
+ it("finishes disconnecting when a session cleanup throws", () => {
717
+ const cleanupError = new Error("cleanup failed");
718
+ const laterCleanup = vi.fn();
719
+ const voice = createVoiceAdapter();
720
+ voice.session.onStatusChange = () => () => {
721
+ throw cleanupError;
722
+ };
723
+ voice.session.onModeChange = () => laterCleanup;
724
+ const runtime = new TestRuntime(voice);
725
+ runtime.connectVoice();
726
+
727
+ expect(() => runtime.disconnectVoice()).toThrow(cleanupError);
728
+ expect(laterCleanup).toHaveBeenCalledOnce();
729
+ expect(voice.session.disconnect).toHaveBeenCalledOnce();
730
+ expect(runtime.voice).toBeUndefined();
731
+ expect(runtime.getVoiceVolume()).toBe(0);
732
+
733
+ expect(() => runtime.disconnectVoice()).not.toThrow();
734
+ expect(laterCleanup).toHaveBeenCalledOnce();
735
+ expect(voice.session.disconnect).toHaveBeenCalledOnce();
736
+ });
737
+
738
+ it("reconnects after cleanup from the previous session throws", () => {
739
+ const consoleError = vi
740
+ .spyOn(console, "error")
741
+ .mockImplementation(() => {});
742
+ const cleanupError = new Error("cleanup failed");
743
+ const voice = createVoiceAdapter();
744
+ voice.adapter.connect = vi.fn(voice.adapter.connect);
745
+ voice.session.onStatusChange = () => () => {
746
+ throw cleanupError;
747
+ };
748
+ const runtime = new TestRuntime(voice);
749
+ runtime.connectVoice();
750
+
751
+ expect(() => runtime.connectVoice()).not.toThrow();
752
+ expect(voice.adapter.connect).toHaveBeenCalledTimes(2);
753
+ expect(consoleError).toHaveBeenCalledWith(
754
+ "[assistant-ui] Voice cleanup threw before reconnect",
755
+ cleanupError,
756
+ );
757
+ });
758
+
759
+ it("rolls back a new session when initialization throws", () => {
760
+ vi.spyOn(console, "error").mockImplementation(() => {});
761
+ const voice = createVoiceAdapter();
762
+ const runtime = new TestRuntime(voice);
763
+ const listenerError = new Error("subscriber failed");
764
+ runtime.subscribe(() => {
765
+ throw listenerError;
766
+ });
767
+
768
+ expect(() => runtime.connectVoice()).toThrow(listenerError);
769
+ expect(voice.session.disconnect).toHaveBeenCalledOnce();
770
+ expect(runtime.voice).toBeUndefined();
771
+ expect(runtime.getVoiceVolume()).toBe(0);
772
+ });
773
+
774
+ it("releases handlers registered before voice setup throws", () => {
775
+ const setupError = new Error("registration failed");
776
+ const statusCleanup = vi.fn();
777
+ const voice = createVoiceAdapter();
778
+ voice.session.onStatusChange = () => statusCleanup;
779
+ voice.session.onModeChange = () => {
780
+ throw setupError;
781
+ };
782
+ const runtime = new TestRuntime(voice);
783
+
784
+ expect(() => runtime.connectVoice()).toThrow(setupError);
785
+ expect(statusCleanup).toHaveBeenCalledOnce();
786
+ expect(voice.session.disconnect).toHaveBeenCalledOnce();
787
+ expect(runtime.voice).toBeUndefined();
788
+ });
789
+
790
+ it("stops setup when a subscriber disconnects the new session", () => {
791
+ const voice = createVoiceAdapter();
792
+ const statusRegistration = vi.spyOn(voice.session, "onStatusChange");
793
+ const runtime = new TestRuntime(voice);
794
+ runtime.subscribe(() => {
795
+ if (runtime.voice) runtime.disconnectVoice();
796
+ });
797
+
798
+ expect(() => runtime.connectVoice()).not.toThrow();
799
+
800
+ expect(voice.session.disconnect).toHaveBeenCalledOnce();
801
+ expect(statusRegistration).not.toHaveBeenCalled();
802
+ expect(runtime.voice).toBeUndefined();
803
+ });
804
+
805
+ it("releases a handler returned after reentrant disconnect", () => {
806
+ const statusCleanup = vi.fn();
807
+ const voice = createVoiceAdapter();
808
+ let registeringStatus = false;
809
+ voice.session.onStatusChange = (callback) => {
810
+ registeringStatus = true;
811
+ callback({ type: "running" });
812
+ registeringStatus = false;
813
+ return statusCleanup;
814
+ };
815
+ const modeRegistration = vi.spyOn(voice.session, "onModeChange");
816
+ const runtime = new TestRuntime(voice);
817
+ runtime.subscribe(() => {
818
+ if (registeringStatus) {
819
+ registeringStatus = false;
820
+ runtime.disconnectVoice();
821
+ }
822
+ });
823
+
824
+ expect(() => runtime.connectVoice()).not.toThrow();
825
+
826
+ expect(statusCleanup).toHaveBeenCalledOnce();
827
+ expect(modeRegistration).not.toHaveBeenCalled();
828
+ expect(voice.session.disconnect).toHaveBeenCalledOnce();
829
+ expect(runtime.voice).toBeUndefined();
830
+ });
831
+
832
+ it("does not release earlier handlers twice after reentrant disconnect", () => {
833
+ const statusCleanup = vi.fn();
834
+ const modeCleanup = vi.fn();
835
+ const voice = createVoiceAdapter();
836
+ voice.session.onStatusChange = () => statusCleanup;
837
+ let registeringMode = false;
838
+ voice.session.onModeChange = (callback) => {
839
+ registeringMode = true;
840
+ callback("speaking");
841
+ registeringMode = false;
842
+ return modeCleanup;
843
+ };
844
+ const volumeRegistration = vi.spyOn(voice.session, "onVolumeChange");
845
+ const runtime = new TestRuntime(voice);
846
+ runtime.subscribe(() => {
847
+ if (registeringMode) {
848
+ registeringMode = false;
849
+ runtime.disconnectVoice();
850
+ }
851
+ });
852
+
853
+ expect(() => runtime.connectVoice()).not.toThrow();
854
+
855
+ expect(statusCleanup).toHaveBeenCalledOnce();
856
+ expect(modeCleanup).toHaveBeenCalledOnce();
857
+ expect(volumeRegistration).not.toHaveBeenCalled();
858
+ expect(voice.session.disconnect).toHaveBeenCalledOnce();
859
+ expect(runtime.voice).toBeUndefined();
860
+ });
861
+
862
+ it("does not disconnect a replacement session after setup throws", () => {
863
+ const setupError = new Error("registration failed");
864
+ const firstVoice = createVoiceAdapter();
865
+ const replacementVoice = createVoiceAdapter();
866
+ firstVoice.adapter.connect = vi
867
+ .fn()
868
+ .mockReturnValueOnce(firstVoice.session)
869
+ .mockReturnValueOnce(replacementVoice.session);
870
+ let replacing = false;
871
+ firstVoice.session.onModeChange = (callback) => {
872
+ replacing = true;
873
+ callback("listening");
874
+ throw setupError;
875
+ };
876
+ const runtime = new TestRuntime(firstVoice);
877
+ runtime.subscribe(() => {
878
+ if (replacing) {
879
+ replacing = false;
880
+ runtime.disconnectVoice();
881
+ runtime.connectVoice();
882
+ }
883
+ });
884
+
885
+ expect(() => runtime.connectVoice()).toThrow(setupError);
886
+
887
+ expect(firstVoice.session.disconnect).toHaveBeenCalledOnce();
888
+ expect(replacementVoice.session.disconnect).not.toHaveBeenCalled();
889
+ expect(runtime.voice).toMatchObject({
890
+ status: replacementVoice.session.status,
891
+ isMuted: replacementVoice.session.isMuted,
892
+ mode: "listening",
893
+ });
894
+ });
895
+
896
+ it("releases setup handlers without disconnecting a self-ended session", () => {
897
+ const voice = createVoiceAdapter();
898
+ const statusCleanup = vi.fn();
899
+ voice.session.onStatusChange = (callback) => {
900
+ voice.session.status = { type: "ended", reason: "finished" };
901
+ callback(voice.session.status);
902
+ return statusCleanup;
903
+ };
904
+ const modeRegistration = vi.spyOn(voice.session, "onModeChange");
905
+ const runtime = new TestRuntime(voice);
906
+
907
+ runtime.connectVoice();
908
+
909
+ expect(statusCleanup).toHaveBeenCalledOnce();
910
+ expect(voice.session.disconnect).not.toHaveBeenCalled();
911
+ expect(modeRegistration).not.toHaveBeenCalled();
912
+ expect(runtime.voice).toBeUndefined();
913
+
914
+ runtime.disconnectVoice();
915
+ expect(statusCleanup).toHaveBeenCalledOnce();
916
+ expect(voice.session.disconnect).not.toHaveBeenCalled();
917
+ });
918
+
919
+ it("releases ended-session handlers when setup notification throws", () => {
920
+ const listenerError = new Error("ended notification failed");
921
+ const cleanupError = new Error("status cleanup failed");
922
+ const consoleError = vi
923
+ .spyOn(console, "error")
924
+ .mockImplementation(() => {});
925
+ const voice = createVoiceAdapter();
926
+ const statusCleanup = vi.fn(() => {
927
+ throw cleanupError;
928
+ });
929
+ let endSession!: () => void;
930
+ voice.session.onStatusChange = (callback) => {
931
+ endSession = () => {
932
+ voice.session.status = { type: "ended", reason: "finished" };
933
+ callback(voice.session.status);
934
+ };
935
+ return statusCleanup;
936
+ };
937
+ voice.session.onModeChange = () => {
938
+ endSession();
939
+ return () => {};
940
+ };
941
+ const runtime = new TestRuntime(voice);
942
+ let wasConnected = false;
943
+ runtime.subscribe(() => {
944
+ if (runtime.voice) wasConnected = true;
945
+ else if (wasConnected) throw listenerError;
946
+ });
947
+
948
+ expect(() => runtime.connectVoice()).toThrow(listenerError);
949
+
950
+ expect(statusCleanup).toHaveBeenCalledOnce();
951
+ expect(voice.session.disconnect).not.toHaveBeenCalled();
952
+ expect(runtime.voice).toBeUndefined();
953
+ expect(consoleError).toHaveBeenCalledWith(
954
+ "[assistant-ui] Detached voice setup cleanup threw",
955
+ cleanupError,
956
+ );
957
+ });
958
+
959
+ it("does not disconnect a session that ends after setup", () => {
960
+ const voice = createVoiceAdapter();
961
+ const statusCleanup = vi.fn();
962
+ let endSession!: () => void;
963
+ voice.session.onStatusChange = (callback) => {
964
+ endSession = () => {
965
+ voice.session.status = { type: "ended", reason: "finished" };
966
+ callback(voice.session.status);
967
+ };
968
+ return statusCleanup;
969
+ };
970
+ const runtime = new TestRuntime(voice);
971
+ runtime.connectVoice();
972
+ endSession();
973
+ expect(runtime.voice).toBeUndefined();
974
+
975
+ runtime.disconnectVoice();
976
+
977
+ expect(statusCleanup).toHaveBeenCalledOnce();
978
+ expect(voice.session.disconnect).not.toHaveBeenCalled();
979
+ });
980
+
981
+ it("rethrows one subscriber error once while disconnecting", () => {
982
+ const voice = createVoiceAdapter();
983
+ const runtime = new TestRuntime(voice);
984
+ runtime.connectVoice();
985
+ voice.emitTranscript({ role: "assistant", text: "Partial" });
986
+ const listenerError = new Error("subscriber failed");
987
+ runtime.subscribe(() => {
988
+ throw listenerError;
989
+ });
990
+
991
+ let thrown: unknown;
992
+ try {
993
+ runtime.disconnectVoice();
994
+ } catch (error) {
995
+ thrown = error;
996
+ }
997
+
998
+ expect(thrown).toBe(listenerError);
999
+ expect(voice.session.disconnect).toHaveBeenCalledOnce();
1000
+ });
1001
+
234
1002
  it("continues notifying subscribers when one throws", () => {
235
1003
  const consoleError = vi
236
1004
  .spyOn(console, "error")
@@ -309,6 +1077,245 @@ describe("BaseThreadRuntimeCore voice volume subscriptions", () => {
309
1077
  });
310
1078
 
311
1079
  describe("BaseThreadRuntimeCore voice transcripts", () => {
1080
+ it("speaks an assistant transcript", () => {
1081
+ const voiceAdapter = createVoiceAdapter();
1082
+ const speech = {
1083
+ speak: vi.fn(() => ({
1084
+ status: { type: "running" as const },
1085
+ cancel: vi.fn(),
1086
+ subscribe: () => () => {},
1087
+ })),
1088
+ } satisfies SpeechSynthesisAdapter;
1089
+ const runtime = new TestRuntime(voiceAdapter, undefined, { speech });
1090
+ runtime.connectVoice();
1091
+
1092
+ try {
1093
+ voiceAdapter.emitTranscript({
1094
+ role: "assistant",
1095
+ text: "Hello",
1096
+ isFinal: true,
1097
+ });
1098
+ const message = runtime.messages[0]!;
1099
+
1100
+ expect(() => runtime.speak(message.id)).not.toThrow();
1101
+ expect(speech.speak).toHaveBeenCalledExactlyOnceWith("Hello");
1102
+ } finally {
1103
+ runtime.disconnectVoice();
1104
+ }
1105
+ });
1106
+
1107
+ it("stops speaking a transcript when the session disconnects", () => {
1108
+ const voiceAdapter = createVoiceAdapter();
1109
+ const utterance = {
1110
+ status: { type: "running" as const },
1111
+ cancel: vi.fn(),
1112
+ subscribe: () => () => {},
1113
+ };
1114
+ const speech = {
1115
+ speak: vi.fn(() => utterance),
1116
+ } satisfies SpeechSynthesisAdapter;
1117
+ const runtime = new TestRuntime(voiceAdapter, undefined, { speech });
1118
+ runtime.connectVoice();
1119
+
1120
+ voiceAdapter.emitTranscript({
1121
+ role: "assistant",
1122
+ text: "Hello",
1123
+ isFinal: true,
1124
+ });
1125
+ runtime.speak(runtime.messages[0]!.id);
1126
+ expect(runtime.speech?.messageId).toBe(runtime.messages[0]!.id);
1127
+
1128
+ runtime.disconnectVoice();
1129
+
1130
+ expect(utterance.cancel).toHaveBeenCalledOnce();
1131
+ expect(runtime.speech).toBeUndefined();
1132
+ });
1133
+
1134
+ it("still disconnects the session when stopping a spoken transcript throws", () => {
1135
+ const voiceAdapter = createVoiceAdapter();
1136
+ const utterance = {
1137
+ status: { type: "running" as const },
1138
+ cancel: vi.fn(() => {
1139
+ throw new Error("cancel failed");
1140
+ }),
1141
+ subscribe: () => () => {},
1142
+ };
1143
+ const speech = {
1144
+ speak: vi.fn(() => utterance),
1145
+ } satisfies SpeechSynthesisAdapter;
1146
+ const runtime = new TestRuntime(voiceAdapter, undefined, { speech });
1147
+ runtime.connectVoice();
1148
+
1149
+ voiceAdapter.emitTranscript({
1150
+ role: "assistant",
1151
+ text: "Hello",
1152
+ isFinal: true,
1153
+ });
1154
+ runtime.speak(runtime.messages[0]!.id);
1155
+
1156
+ expect(() => runtime.disconnectVoice()).toThrow("cancel failed");
1157
+ expect(utterance.cancel).toHaveBeenCalledOnce();
1158
+ expect(voiceAdapter.session.disconnect).toHaveBeenCalledOnce();
1159
+ expect(runtime.messages).toHaveLength(0);
1160
+ expect(runtime.speech).toBeUndefined();
1161
+ expect(runtime.voice).toBeUndefined();
1162
+ });
1163
+
1164
+ it("submits feedback for a transcript", () => {
1165
+ const voiceAdapter = createVoiceAdapter();
1166
+ const feedback = { submit: vi.fn() } satisfies FeedbackAdapter;
1167
+ const runtime = new TestRuntime(voiceAdapter, undefined, { feedback });
1168
+ runtime.connectVoice();
1169
+
1170
+ try {
1171
+ voiceAdapter.emitTranscript({
1172
+ role: "assistant",
1173
+ text: "Hello",
1174
+ isFinal: true,
1175
+ });
1176
+ const message = runtime.messages[0]!;
1177
+
1178
+ expect(() =>
1179
+ runtime.submitFeedback({ messageId: message.id, type: "positive" }),
1180
+ ).not.toThrow();
1181
+ expect(feedback.submit).toHaveBeenCalledExactlyOnceWith({
1182
+ message,
1183
+ type: "positive",
1184
+ });
1185
+ expect(runtime.messages[0]?.metadata.submittedFeedback).toEqual({
1186
+ type: "positive",
1187
+ });
1188
+ } finally {
1189
+ runtime.disconnectVoice();
1190
+ }
1191
+ });
1192
+
1193
+ it("rejects an edit resubmission of a transcript before any side effect", async () => {
1194
+ const voiceAdapter = createVoiceAdapter();
1195
+ const run = vi.fn(async () => ({}));
1196
+ const runtime = new LocalRuntimeCore(
1197
+ { adapters: { chatModel: { run }, voice: voiceAdapter.adapter } },
1198
+ undefined,
1199
+ );
1200
+ const thread = runtime.threads.getMainThreadRuntimeCore();
1201
+ thread.connectVoice();
1202
+
1203
+ try {
1204
+ voiceAdapter.emitTranscript({
1205
+ role: "assistant",
1206
+ text: "Hello",
1207
+ isFinal: true,
1208
+ });
1209
+ const edit = {
1210
+ parentId: null,
1211
+ sourceId: thread.messages[0]!.id,
1212
+ role: "user" as const,
1213
+ content: [{ type: "text" as const, text: "again" }],
1214
+ attachments: [],
1215
+ metadata: { custom: {} },
1216
+ createdAt: new Date(),
1217
+ runConfig: {},
1218
+ };
1219
+
1220
+ await expect(thread.append(edit)).rejects.toThrow(
1221
+ "Voice transcript messages cannot be edited",
1222
+ );
1223
+ await expect(thread.append({ ...edit, startRun: false })).rejects.toThrow(
1224
+ "Voice transcript messages cannot be edited",
1225
+ );
1226
+ expect(run).not.toHaveBeenCalled();
1227
+ expect(thread.messages).toHaveLength(1);
1228
+ } finally {
1229
+ thread.disconnectVoice();
1230
+ }
1231
+ });
1232
+
1233
+ it("rejects reloading a transcript", async () => {
1234
+ const voiceAdapter = createVoiceAdapter();
1235
+ const runtime = new LocalRuntimeCore(
1236
+ {
1237
+ adapters: {
1238
+ chatModel: {
1239
+ async run() {
1240
+ return {};
1241
+ },
1242
+ },
1243
+ voice: voiceAdapter.adapter,
1244
+ },
1245
+ },
1246
+ undefined,
1247
+ );
1248
+ const thread = runtime.threads.getMainThreadRuntimeCore();
1249
+ thread.connectVoice();
1250
+
1251
+ try {
1252
+ voiceAdapter.emitTranscript({
1253
+ role: "assistant",
1254
+ text: "Hello",
1255
+ isFinal: true,
1256
+ });
1257
+ const message = thread.messages[0]!;
1258
+
1259
+ await expect(
1260
+ thread.startRun({
1261
+ parentId: null,
1262
+ sourceId: message.id,
1263
+ runConfig: {},
1264
+ }),
1265
+ ).rejects.toThrow("Voice transcript messages cannot be reloaded");
1266
+ } finally {
1267
+ thread.disconnectVoice();
1268
+ }
1269
+ });
1270
+
1271
+ it("rejects editing a transcript", () => {
1272
+ const voiceAdapter = createVoiceAdapter();
1273
+ const runtime = new TestRuntime(voiceAdapter);
1274
+ runtime.connectVoice();
1275
+
1276
+ try {
1277
+ voiceAdapter.emitTranscript({
1278
+ role: "assistant",
1279
+ text: "Hello",
1280
+ isFinal: true,
1281
+ });
1282
+
1283
+ expect(() => runtime.beginEdit(runtime.messages[0]!.id)).toThrow(
1284
+ "Voice transcript messages cannot be edited",
1285
+ );
1286
+ } finally {
1287
+ runtime.disconnectVoice();
1288
+ }
1289
+ });
1290
+
1291
+ it("marks user and assistant transcripts as voice messages", () => {
1292
+ const voiceAdapter = createVoiceAdapter();
1293
+ const runtime = new TestRuntime(voiceAdapter);
1294
+ runtime.connectVoice();
1295
+
1296
+ try {
1297
+ voiceAdapter.emitTranscript({
1298
+ role: "user",
1299
+ text: "Hello",
1300
+ isFinal: true,
1301
+ });
1302
+ voiceAdapter.emitTranscript({ role: "assistant", text: "Hi" });
1303
+
1304
+ expect(runtime.messages[0]?.metadata.modality).toBe("voice");
1305
+ expect(runtime.messages[1]?.metadata.modality).toBe("voice");
1306
+
1307
+ voiceAdapter.emitTranscript({
1308
+ role: "assistant",
1309
+ text: "Hello there",
1310
+ isFinal: true,
1311
+ });
1312
+
1313
+ expect(runtime.messages[1]?.metadata.modality).toBe("voice");
1314
+ } finally {
1315
+ runtime.disconnectVoice();
1316
+ }
1317
+ });
1318
+
312
1319
  it("completes a final-only reply before the next streamed reply", () => {
313
1320
  const voiceAdapter = createVoiceAdapter();
314
1321
  const runtime = new TestRuntime(voiceAdapter);