@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,9 +1,94 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { convertExternalMessages } from "./external-message-converter";
2
+ import {
3
+ convertExternalMessages,
4
+ createExternalMessageConversionCache,
5
+ } from "./external-message-converter";
3
6
  import type { useExternalMessageConverter } from "./external-message-converter";
4
7
  import { isErrorMessageId } from "../../utils/id";
5
8
 
6
9
  describe("convertExternalMessages", () => {
10
+ it("keeps unchanged messages and rebuilds a replaced one with an opt-in cache", () => {
11
+ type Input = { id: string; role: "user" | "assistant"; text: string };
12
+ const metadata = {};
13
+ const cache = createExternalMessageConversionCache();
14
+ const callback: useExternalMessageConverter.Callback<Input> = (
15
+ message,
16
+ ) => ({
17
+ id: message.id,
18
+ role: message.role,
19
+ content: message.text,
20
+ });
21
+ const question: Input = { id: "question", role: "user", text: "hi" };
22
+ const answer: Input = { id: "answer", role: "assistant", text: "hel" };
23
+
24
+ const first = convertExternalMessages(
25
+ [question, answer],
26
+ callback,
27
+ true,
28
+ metadata,
29
+ cache,
30
+ );
31
+ const second = convertExternalMessages(
32
+ [question, { ...answer, text: "hello" }],
33
+ callback,
34
+ true,
35
+ metadata,
36
+ cache,
37
+ );
38
+
39
+ expect(second[0]).toBe(first[0]);
40
+ expect(second[1]).not.toBe(first[1]);
41
+ expect(second[1]?.content).toMatchObject([{ type: "text", text: "hello" }]);
42
+ });
43
+
44
+ it("invalidates cached messages when the callback or metadata changes", () => {
45
+ const input = { text: "nested" };
46
+ const cache = createExternalMessageConversionCache();
47
+ const withFormat =
48
+ (
49
+ format: (text: string) => string,
50
+ ): useExternalMessageConverter.Callback<typeof input> =>
51
+ (message, metadata) => ({
52
+ id: "nested",
53
+ role: "assistant",
54
+ content: format(message.text),
55
+ metadata: { timing: metadata.messageTiming?.["nested"] },
56
+ });
57
+ const callback = withFormat((text) => text);
58
+ const uppercaseCallback = withFormat((text) => text.toUpperCase());
59
+ const metadata = {};
60
+ const timing = { streamStartTime: 1, totalChunks: 1, toolCallCount: 0 };
61
+
62
+ const first = convertExternalMessages(
63
+ [input],
64
+ callback,
65
+ false,
66
+ metadata,
67
+ cache,
68
+ );
69
+ const second = convertExternalMessages(
70
+ [input],
71
+ uppercaseCallback,
72
+ false,
73
+ metadata,
74
+ cache,
75
+ );
76
+ const third = convertExternalMessages(
77
+ [input],
78
+ uppercaseCallback,
79
+ false,
80
+ { messageTiming: { nested: timing } },
81
+ cache,
82
+ );
83
+
84
+ expect(second[0]).not.toBe(first[0]);
85
+ expect(second[0]?.content).toMatchObject([
86
+ { type: "text", text: "NESTED" },
87
+ ]);
88
+ expect(third[0]).not.toBe(second[0]);
89
+ expect(third[0]?.metadata).toMatchObject({ timing });
90
+ });
91
+
7
92
  describe("reasoning part merging", () => {
8
93
  it("should merge reasoning parts with the same parentId", () => {
9
94
  const messages = [
@@ -205,6 +290,56 @@ describe("convertExternalMessages", () => {
205
290
  expect((toolCallParts[0] as any).argsText).toBe('{"query":"new"}');
206
291
  });
207
292
 
293
+ it("keeps a merged tool call in its original content position", () => {
294
+ const messages = [
295
+ {
296
+ role: "assistant" as const,
297
+ content: [
298
+ {
299
+ type: "tool-call" as const,
300
+ toolCallId: "tc1",
301
+ toolName: "search",
302
+ args: { query: "old" },
303
+ },
304
+ { type: "text" as const, text: "after tool" },
305
+ ],
306
+ },
307
+ {
308
+ role: "assistant" as const,
309
+ content: [
310
+ {
311
+ type: "tool-call" as const,
312
+ toolCallId: "tc1",
313
+ toolName: "search",
314
+ args: { query: "new" },
315
+ },
316
+ ],
317
+ },
318
+ {
319
+ role: "tool" as const,
320
+ toolCallId: "tc1",
321
+ result: "found",
322
+ },
323
+ ];
324
+
325
+ const result = convertExternalMessages(
326
+ messages,
327
+ (message) => message,
328
+ false,
329
+ {},
330
+ );
331
+
332
+ expect(result[0]!.content).toMatchObject([
333
+ {
334
+ type: "tool-call",
335
+ toolCallId: "tc1",
336
+ args: { query: "new" },
337
+ result: "found",
338
+ },
339
+ { type: "text", text: "after tool" },
340
+ ]);
341
+ });
342
+
208
343
  it("should ignore orphaned tool results without throwing", () => {
209
344
  const messages = [
210
345
  {
@@ -2,28 +2,29 @@
2
2
 
3
3
  import { useMemo, useState } from "react";
4
4
  import {
5
- chunkExternalMessages,
6
- completeExternalMessageConversion,
7
- convertExternalMessageCallback,
8
- convertExternalMessageChunk,
9
5
  convertExternalMessages as convertExternalMessagesInternal,
10
- shallowArrayEqual,
6
+ createExternalMessageConversionCache as createExternalMessageConversionCacheInternal,
11
7
  type ExternalMessageConverterCallback,
12
- type ExternalMessageConverterCallbackResult,
13
- type ExternalMessageConverterChunk,
14
8
  type ExternalMessageConverterMessage,
15
9
  type ExternalMessageConverterMetadata,
10
+ type InternalExternalMessageConversionCache,
16
11
  type JoinStrategy,
17
12
  } from "../../runtime/utils/external-message-conversion";
18
13
  import { bindExternalStoreMessage } from "../../runtime/utils/external-store-message";
19
- import { ThreadMessageConverter } from "../../runtimes/external-store/thread-message-converter";
20
- import type { ThreadMessage } from "../../types/message";
21
-
22
- // Generatedness is tracked by identity, not by id shape: a caller-supplied id
23
- // that happens to match the generated pattern must never be rewritten.
24
- const generatedFallbackMessages = new WeakSet<object>();
25
14
 
26
15
  export type { JoinStrategy };
16
+ export type ExternalMessageConversionCache = {
17
+ readonly __brand: unique symbol;
18
+ };
19
+
20
+ /**
21
+ * Creates a cache for plain external message conversion. Pass the same cache on every call for one message list, and a source message that has not changed since the previous call converts to the same `ThreadMessage` object.
22
+ *
23
+ * Entries are keyed by source message identity and rebuilt whenever `callback` or `metadata` is a different object than on the previous call, so keep both referentially stable.
24
+ */
25
+ export const createExternalMessageConversionCache =
26
+ (): ExternalMessageConversionCache =>
27
+ createExternalMessageConversionCacheInternal() as unknown as ExternalMessageConversionCache;
27
28
 
28
29
  export namespace useExternalMessageConverter {
29
30
  export type Message = ExternalMessageConverterMessage;
@@ -31,17 +32,21 @@ export namespace useExternalMessageConverter {
31
32
  export type Callback<T> = ExternalMessageConverterCallback<T>;
32
33
  }
33
34
 
34
- export const convertExternalMessages: <T extends WeakKey>(
35
+ export const convertExternalMessages = <T extends WeakKey>(
35
36
  messages: T[],
36
37
  callback: useExternalMessageConverter.Callback<T>,
37
38
  isRunning: boolean,
38
39
  metadata: useExternalMessageConverter.Metadata,
39
- ) => ThreadMessage[] = convertExternalMessagesInternal;
40
-
41
- type CallbackCacheEntry<T> = ExternalMessageConverterCallbackResult<T> & {
42
- metadata: useExternalMessageConverter.Metadata;
43
- callback: useExternalMessageConverter.Callback<T>;
44
- };
40
+ cache?: ExternalMessageConversionCache,
41
+ ) =>
42
+ convertExternalMessagesInternal(
43
+ messages,
44
+ callback,
45
+ isRunning,
46
+ metadata,
47
+ undefined,
48
+ cache as unknown as InternalExternalMessageConversionCache<T> | undefined,
49
+ );
45
50
 
46
51
  export const useExternalMessageConverter = <T extends WeakKey>({
47
52
  callback,
@@ -56,89 +61,30 @@ export const useExternalMessageConverter = <T extends WeakKey>({
56
61
  joinStrategy?: JoinStrategy | undefined;
57
62
  metadata?: useExternalMessageConverter.Metadata | undefined;
58
63
  }) => {
59
- // The caches live for the component lifetime; React Compiler hoists
60
- // allocations without reactive dependencies out of useMemo, so re-creating
61
- // them on dependency change would not survive compilation. Staleness is
62
- // instead tracked per entry via the metadata/callback that produced it,
63
- // keeping correctness independent of memoization (which React treats as
64
- // droppable). A "use no memo" opt-out would restore the useMemo semantics
65
- // but leave cache flushing coupled to memo firing.
66
- const [caches] = useState(() => ({
67
- callbackCache: new WeakMap<T, CallbackCacheEntry<T>>(),
68
- chunkCache: new WeakMap<
69
- ExternalMessageConverterMessage,
70
- ExternalMessageConverterChunk<T>
71
- >(),
72
- converterCache: new ThreadMessageConverter(),
73
- }));
64
+ // The cache lives for the component lifetime: React Compiler hoists allocations without reactive dependencies out of useMemo, so entries carry the callback and metadata that produced them instead of being flushed when those change.
65
+ const [cache] = useState(() =>
66
+ createExternalMessageConversionCacheInternal<T>(),
67
+ );
74
68
 
75
69
  const state = useMemo(
76
70
  () => ({
77
71
  metadata: metadata ?? {},
78
72
  callback,
79
- ...caches,
80
73
  }),
81
- [callback, metadata, caches],
74
+ [callback, metadata],
82
75
  );
83
76
 
84
77
  return useMemo(() => {
85
- const callbackResults: ExternalMessageConverterCallbackResult<T>[] = [];
86
- for (const message of messages) {
87
- let result = state.callbackCache.get(message);
88
- if (
89
- !result ||
90
- result.metadata !== state.metadata ||
91
- result.callback !== state.callback
92
- ) {
93
- result = {
94
- ...convertExternalMessageCallback(
95
- message,
96
- state.callback,
97
- state.metadata,
98
- ),
99
- metadata: state.metadata,
100
- callback: state.callback,
101
- };
102
- state.callbackCache.set(message, result);
103
- }
104
- callbackResults.push(result);
105
- }
106
-
107
- const chunks = chunkExternalMessages(callbackResults, joinStrategy).map(
108
- (message) => {
109
- const key = message.outputs[0];
110
- if (!key) return message;
111
-
112
- const cached = state.chunkCache.get(key);
113
- if (cached && shallowArrayEqual(cached.outputs, message.outputs)) {
114
- return cached;
115
- }
116
- state.chunkCache.set(key, message);
117
- return message;
118
- },
119
- );
120
-
121
- const threadMessages = state.converterCache.convertMessages(
122
- chunks,
123
- (cache, message, idx) =>
124
- convertExternalMessageChunk(
125
- message,
126
- idx,
127
- chunks.length,
128
- isRunning,
129
- state.metadata.error,
130
- {
131
- message: cache,
132
- generatedFallbackMessages,
133
- },
134
- state.metadata.cancelledMessageIds,
135
- ),
78
+ const threadMessages = convertExternalMessagesInternal(
79
+ messages,
80
+ state.callback,
81
+ isRunning,
82
+ state.metadata,
83
+ joinStrategy,
84
+ cache,
136
85
  );
137
86
 
138
87
  bindExternalStoreMessage(threadMessages, messages);
139
- return completeExternalMessageConversion(
140
- threadMessages,
141
- state.metadata.error,
142
- );
143
- }, [state, messages, isRunning, joinStrategy]);
88
+ return threadMessages;
89
+ }, [state, messages, isRunning, joinStrategy, cache]);
144
90
  };
@@ -109,7 +109,7 @@ export const useRemoteThreadListRuntime = (
109
109
  );
110
110
 
111
111
  const onThreadIdChange = useEffectEvent((threadId: string | undefined) => {
112
- options.onThreadIdChange?.(threadId);
112
+ return options.onThreadIdChange?.(threadId);
113
113
  });
114
114
 
115
115
  const stableOptions = useMemo<RemoteThreadListOptions>(
@@ -47,8 +47,13 @@ export const GROUPBY_MEMO_KEY: unique symbol = Symbol.for(
47
47
  * `display: "standalone"`). Resolving the registry-driven cases reads the
48
48
  * {@link GroupByContext} passed to the `groupBy` function. Takes precedence
49
49
  * over the `"tool-call"` entry.
50
+ *
51
+ * A `"tool-call:<name>"` entry matches tool calls by tool name and takes precedence over the plain `"tool-call"` entry, while `"standalone-tool-call"` still wins first.
50
52
  */
51
- type GroupPartType = PartState["type"] | "standalone-tool-call";
53
+ type GroupPartType =
54
+ | PartState["type"]
55
+ | "standalone-tool-call"
56
+ | `tool-call:${string}`;
52
57
 
53
58
  /**
54
59
  * Build a `groupBy` from a `part.type → group-key path` lookup.
@@ -63,11 +68,14 @@ type GroupPartType = PartState["type"] | "standalone-tool-call";
63
68
  * {@link GroupByContext} that `<MessagePrimitive.GroupedParts>` passes to the
64
69
  * `groupBy` function — the helper needs nothing threaded into it.
65
70
  *
71
+ * A `"tool-call:<name>"` entry matches tool calls by tool name and takes precedence over the plain `"tool-call"` entry, while `"standalone-tool-call"` still wins first.
72
+ *
66
73
  * @example
67
74
  * ```tsx
68
75
  * <MessagePrimitive.GroupedParts
69
76
  * groupBy={groupPartByType({
70
77
  * reasoning: ["group-thought", "group-reasoning"],
78
+ * "tool-call:task": ["group-subagents"],
71
79
  * "tool-call": ["group-thought", "group-tool"],
72
80
  * "standalone-tool-call": [],
73
81
  * })}
@@ -91,6 +99,9 @@ export const groupPartByType = <TKey extends `group-${string}`>(
91
99
  if (isStandalone && lookup["standalone-tool-call"] !== undefined) {
92
100
  return lookup["standalone-tool-call"]!;
93
101
  }
102
+ const toolCallPath = lookup[`tool-call:${part.toolName}`];
103
+ if (toolCallPath !== undefined) return toolCallPath;
104
+ return lookup["tool-call"] ?? [];
94
105
  }
95
106
  return lookup[part.type] ?? [];
96
107
  }) as ((part: PartState, context?: GroupByContext) => readonly TKey[]) & {
@@ -1,6 +1,15 @@
1
1
  import { useRef } from "react";
2
+ import type { ReadonlyJSONValue } from "assistant-stream/utils";
2
3
  import { isJSONValueEqual } from "../../utils/json/is-json-equal";
3
4
 
5
+ export function useJSONEqualValue<T extends ReadonlyJSONValue>(value: T): T {
6
+ const prev = useRef(value);
7
+ if (prev.current !== value && !isJSONValueEqual(prev.current, value)) {
8
+ prev.current = value;
9
+ }
10
+ return prev.current;
11
+ }
12
+
4
13
  /**
5
14
  * Like `useShallow`, but with JSON deep-equality. Use when a selector derives an
6
15
  * equal-but-fresh value on every store update — e.g. folding over
@@ -0,0 +1,22 @@
1
+ import { useMemo } from "react";
2
+ import { toJSONSchema } from "assistant-stream";
3
+ import type { ReadonlyJSONValue } from "assistant-stream/utils";
4
+ import { isJSONValue } from "../../utils/json/is-json";
5
+ import type { Unstable_InteractableStateSchema } from "../types/scopes/interactables";
6
+ import { useJSONEqualValue } from "./useJSONEqual";
7
+
8
+ export function useJSONSchemaDependency(
9
+ schema: Unstable_InteractableStateSchema,
10
+ ): ReadonlyJSONValue {
11
+ const normalizedSchema = useMemo(() => {
12
+ try {
13
+ const jsonSchema = toJSONSchema(schema);
14
+ return isJSONValue(jsonSchema) ? jsonSchema : null;
15
+ } catch {
16
+ // Opaque schemas share one fallback so rebuilt instances do not churn registration.
17
+ return null;
18
+ }
19
+ }, [schema]);
20
+
21
+ return useJSONEqualValue(normalizedSchema);
22
+ }
@@ -1,7 +1,13 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { LocalRuntimeCore } from "../../runtimes/local/local-runtime-core";
3
3
  import { ExternalStoreRuntimeCore } from "../../runtimes/external-store/external-store-runtime-core";
4
+ import { ReadonlyThreadRuntimeCore } from "../../runtimes/readonly/ReadonlyThreadRuntimeCore";
4
5
  import { AssistantRuntimeImpl } from "./assistant-runtime";
6
+ import {
7
+ ThreadRuntimeImpl,
8
+ type ThreadListItemRuntimeBinding,
9
+ type ThreadRuntimeCoreBinding,
10
+ } from "./thread-runtime";
5
11
 
6
12
  describe("ThreadRuntime.append", () => {
7
13
  it.each([
@@ -76,3 +82,55 @@ describe("ThreadRuntime.append with an external store", () => {
76
82
  expect(onNew).not.toHaveBeenCalled();
77
83
  });
78
84
  });
85
+
86
+ describe("ThreadRuntime state subscriptions", () => {
87
+ it("tears down every source before reconnecting after an error", () => {
88
+ const core = new ReadonlyThreadRuntimeCore();
89
+ const cleanupError = new Error("thread cleanup failed");
90
+ const threadCleanup = vi.fn(() => {
91
+ throw cleanupError;
92
+ });
93
+ const itemCleanup = vi.fn();
94
+ let threadSubscriptions = 0;
95
+ let itemSubscriptions = 0;
96
+ const path = {
97
+ ref: "test.thread",
98
+ threadSelector: { type: "main" as const },
99
+ };
100
+ const runtime = new ThreadRuntimeImpl(
101
+ {
102
+ path,
103
+ getState: () => core,
104
+ subscribe: () => {
105
+ threadSubscriptions += 1;
106
+ return threadCleanup;
107
+ },
108
+ outerSubscribe: () => () => {},
109
+ } satisfies ThreadRuntimeCoreBinding,
110
+ {
111
+ path,
112
+ getState: () => ({
113
+ id: "test",
114
+ remoteId: undefined,
115
+ externalId: undefined,
116
+ isMain: true,
117
+ isRunning: false,
118
+ status: "regular",
119
+ title: undefined,
120
+ }),
121
+ subscribe: () => {
122
+ itemSubscriptions += 1;
123
+ return itemCleanup;
124
+ },
125
+ } satisfies ThreadListItemRuntimeBinding,
126
+ );
127
+
128
+ const unsubscribe = runtime.subscribe(() => {});
129
+ expect(() => unsubscribe()).toThrow(cleanupError);
130
+ expect(itemCleanup).toHaveBeenCalledOnce();
131
+
132
+ runtime.subscribe(() => {});
133
+ expect(threadSubscriptions).toBe(2);
134
+ expect(itemSubscriptions).toBe(2);
135
+ });
136
+ });
@@ -18,6 +18,7 @@ import {
18
18
  } from "./message-runtime";
19
19
  import { NestedSubscriptionSubject } from "../../subscribable/subscribable";
20
20
  import {
21
+ runCleanups,
21
22
  ShallowMemoizeSubject,
22
23
  SKIP_UPDATE,
23
24
  } from "../../subscribable/subscribable";
@@ -372,10 +373,7 @@ export class ThreadRuntimeImpl implements ThreadRuntime {
372
373
  subscribe: (callback) => {
373
374
  const sub1 = threadBinding.subscribe(callback);
374
375
  const sub2 = threadListItemBinding.subscribe(callback);
375
- return () => {
376
- sub1();
377
- sub2();
378
- };
376
+ return () => runCleanups([sub1, sub2]);
379
377
  },
380
378
  });
381
379
 
@@ -167,7 +167,7 @@ export abstract class BaseComposerRuntimeCore
167
167
  const adapter = this.getAttachmentAdapter();
168
168
  if (adapter) {
169
169
  const pending = this._attachments.filter((a) => !isAttachmentComplete(a));
170
- await Promise.all(pending.map((a) => adapter.remove(a)));
170
+ await Promise.all(pending.map(async (a) => adapter.remove(a)));
171
171
  }
172
172
  }
173
173
 
@@ -203,6 +203,10 @@ export abstract class BaseComposerRuntimeCore
203
203
 
204
204
  public async clearAttachments() {
205
205
  this._cancelAllAttachmentAdds();
206
+ if (this._isSending) {
207
+ for (const attachment of this._attachments)
208
+ this._removedDuringSend.add(attachment.id);
209
+ }
206
210
  const task = this._onClearAttachments();
207
211
  this.setAttachments([]);
208
212
 
@@ -213,8 +217,13 @@ export abstract class BaseComposerRuntimeCore
213
217
  if (!this.canSend || this._isSending) return;
214
218
 
215
219
  if (this._dictationSession) {
216
- this._dictationSession.cancel();
217
- this._cleanupDictation();
220
+ try {
221
+ this._dictationSession.cancel();
222
+ } catch (error) {
223
+ console.error("[assistant-ui] Dictation session cancel threw", error);
224
+ } finally {
225
+ this._cleanupDictation();
226
+ }
218
227
  }
219
228
 
220
229
  const adapter = this.getAttachmentAdapter();
@@ -636,14 +645,11 @@ export abstract class BaseComposerRuntimeCore
636
645
  throw new Error("Dictation adapter not configured");
637
646
  }
638
647
 
648
+ const isReplacing = this._dictationSession !== undefined;
639
649
  if (this._dictationSession) {
640
- for (const unsub of this._dictationUnsubscribes) {
641
- unsub();
642
- }
643
- this._dictationUnsubscribes = [];
644
650
  const oldSession = this._dictationSession;
645
- oldSession.stop().catch(() => {});
646
- this._dictationSession = undefined;
651
+ this._cleanupDictation({ notify: false });
652
+ this._stopDictationSession(oldSession);
647
653
  }
648
654
 
649
655
  const inputDisabled = adapter.disableInputDuringDictation ?? false;
@@ -651,7 +657,22 @@ export abstract class BaseComposerRuntimeCore
651
657
  this._dictationBaseText = this._text;
652
658
  this._currentInterimText = "";
653
659
 
654
- const session = adapter.listen();
660
+ let session: DictationAdapter.Session;
661
+ try {
662
+ session = adapter.listen();
663
+ } catch (error) {
664
+ if (isReplacing) {
665
+ try {
666
+ this._notifySubscribers();
667
+ } catch (notifyError) {
668
+ console.error(
669
+ "[assistant-ui] Dictation replacement rollback notification threw",
670
+ notifyError,
671
+ );
672
+ }
673
+ }
674
+ throw error;
675
+ }
655
676
  this._dictationSession = session;
656
677
  const sessionId = ++this._dictationSessionIdCounter;
657
678
  this._activeDictationSessionId = sessionId;
@@ -729,27 +750,59 @@ export abstract class BaseComposerRuntimeCore
729
750
  const session = this._dictationSession;
730
751
  const sessionId = this._activeDictationSessionId;
731
752
  const cleanup = () => this._cleanupDictation({ sessionId });
732
- void session.stop().then(cleanup, cleanup);
753
+ this._stopDictationSession(session, cleanup);
733
754
  }
734
755
 
735
- private _cleanupDictation(options?: { sessionId: number | undefined }): void {
756
+ private _stopDictationSession(
757
+ session: DictationAdapter.Session,
758
+ onSettled: () => void = () => {},
759
+ ): void {
760
+ let task: Promise<void>;
761
+ try {
762
+ task = session.stop();
763
+ } catch (error) {
764
+ console.error("[assistant-ui] Dictation session stop threw", error);
765
+ onSettled();
766
+ return;
767
+ }
768
+
769
+ void task.then(onSettled, (error) => {
770
+ console.error("[assistant-ui] Dictation session stop rejected", error);
771
+ onSettled();
772
+ });
773
+ }
774
+
775
+ private _cleanupDictation(options?: {
776
+ sessionId?: number | undefined;
777
+ notify?: boolean | undefined;
778
+ }): void {
736
779
  const isStaleSession =
737
780
  options?.sessionId !== undefined &&
738
781
  options.sessionId !== this._activeDictationSessionId;
739
782
  if (isStaleSession || this._isCleaningDictation) return;
740
783
 
741
784
  this._isCleaningDictation = true;
742
- try {
743
- for (const unsub of this._dictationUnsubscribes) {
744
- unsub();
785
+ const runCleanup = (cleanup: () => void) => {
786
+ try {
787
+ cleanup();
788
+ } catch (error) {
789
+ console.error("[assistant-ui] Dictation cleanup threw", error);
745
790
  }
791
+ };
792
+
793
+ try {
794
+ const unsubscribes = this._dictationUnsubscribes;
746
795
  this._dictationUnsubscribes = [];
747
796
  this._dictationSession = undefined;
748
797
  this._activeDictationSessionId = undefined;
749
798
  this._dictation = undefined;
750
799
  this._dictationBaseText = "";
751
800
  this._currentInterimText = "";
752
- this._notifySubscribers();
801
+
802
+ for (const unsubscribe of unsubscribes) runCleanup(unsubscribe);
803
+ if (options?.notify !== false) {
804
+ runCleanup(() => this._notifySubscribers());
805
+ }
753
806
  } finally {
754
807
  this._isCleaningDictation = false;
755
808
  }