@assistant-ui/core 0.3.17 → 0.3.18

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 (284) hide show
  1. package/dist/adapters/attachment.d.ts.map +1 -1
  2. package/dist/adapters/attachment.js +5 -4
  3. package/dist/adapters/attachment.js.map +1 -1
  4. package/dist/index.d.ts +2 -2
  5. package/dist/index.js.map +1 -1
  6. package/dist/internal.d.ts +3 -2
  7. package/dist/internal.js +3 -2
  8. package/dist/model-context/frame/host.d.ts.map +1 -1
  9. package/dist/model-context/frame/host.js +10 -4
  10. package/dist/model-context/frame/host.js.map +1 -1
  11. package/dist/model-context/frame/provider.d.ts +2 -0
  12. package/dist/model-context/frame/provider.d.ts.map +1 -1
  13. package/dist/model-context/frame/provider.js +30 -7
  14. package/dist/model-context/frame/provider.js.map +1 -1
  15. package/dist/model-context/interactable-composer-metadata.d.ts.map +1 -1
  16. package/dist/model-context/interactable-composer-metadata.js +16 -9
  17. package/dist/model-context/interactable-composer-metadata.js.map +1 -1
  18. package/dist/model-context/registry.d.ts.map +1 -1
  19. package/dist/model-context/registry.js +3 -5
  20. package/dist/model-context/registry.js.map +1 -1
  21. package/dist/model-context/types.d.ts.map +1 -1
  22. package/dist/model-context/types.js +5 -4
  23. package/dist/model-context/types.js.map +1 -1
  24. package/dist/react/client/InMemoryThreadList.d.ts.map +1 -1
  25. package/dist/react/client/InMemoryThreadList.js +62 -56
  26. package/dist/react/client/InMemoryThreadList.js.map +1 -1
  27. package/dist/react/client/Interactables.d.ts.map +1 -1
  28. package/dist/react/client/Interactables.js +24 -27
  29. package/dist/react/client/Interactables.js.map +1 -1
  30. package/dist/react/client/RemoteThreadList.d.ts +1 -0
  31. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  32. package/dist/react/client/RemoteThreadList.js +151 -141
  33. package/dist/react/client/RemoteThreadList.js.map +1 -1
  34. package/dist/react/client/Tools.d.ts.map +1 -1
  35. package/dist/react/client/Tools.js +18 -16
  36. package/dist/react/client/Tools.js.map +1 -1
  37. package/dist/react/interactables-legacy/Interactables.js +242 -121
  38. package/dist/react/interactables-legacy/Interactables.js.map +1 -1
  39. package/dist/react/interactables-shared/useInteractablePersistenceQueue.d.ts.map +1 -1
  40. package/dist/react/interactables-shared/useInteractablePersistenceQueue.js +11 -9
  41. package/dist/react/interactables-shared/useInteractablePersistenceQueue.js.map +1 -1
  42. package/dist/react/model-context/makeAssistantTool.d.ts +2 -2
  43. package/dist/react/model-context/makeAssistantTool.js +1 -1
  44. package/dist/react/model-context/makeAssistantTool.js.map +1 -1
  45. package/dist/react/model-context/useAssistantTool.d.ts +2 -2
  46. package/dist/react/model-context/useAssistantTool.js +1 -1
  47. package/dist/react/model-context/useAssistantTool.js.map +1 -1
  48. package/dist/react/model-context/useToolArgsStatus.d.ts.map +1 -1
  49. package/dist/react/model-context/useToolArgsStatus.js +2 -1
  50. package/dist/react/model-context/useToolArgsStatus.js.map +1 -1
  51. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  52. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +16 -8
  53. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  54. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +5 -1
  55. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  56. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +103 -103
  57. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  58. package/dist/react/runtimes/RemoteThreadResource.d.ts +1 -0
  59. package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
  60. package/dist/react/runtimes/RemoteThreadResource.js +13 -11
  61. package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
  62. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts +4 -1
  63. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts.map +1 -1
  64. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +401 -34
  65. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  66. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.d.ts.map +1 -1
  67. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.js +2 -1
  68. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.js.map +1 -1
  69. package/dist/react/runtimes/useExternalStoreRuntime.js +75 -46
  70. package/dist/react/runtimes/useExternalStoreRuntime.js.map +1 -1
  71. package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
  72. package/dist/react/runtimes/useRemoteThreadListRuntime.js +63 -23
  73. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  74. package/dist/runtime/api/thread-list-item-runtime.d.ts +7 -3
  75. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  76. package/dist/runtime/api/thread-list-item-runtime.js +2 -2
  77. package/dist/runtime/api/thread-list-item-runtime.js.map +1 -1
  78. package/dist/runtime/api/thread-list-runtime.d.ts +3 -0
  79. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  80. package/dist/runtime/api/thread-list-runtime.js +18 -14
  81. package/dist/runtime/api/thread-list-runtime.js.map +1 -1
  82. package/dist/runtime/api/thread-runtime.d.ts +4 -0
  83. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  84. package/dist/runtime/api/thread-runtime.js +1 -1
  85. package/dist/runtime/api/thread-runtime.js.map +1 -1
  86. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  87. package/dist/runtime/base/base-composer-runtime-core.js +16 -12
  88. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  89. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  90. package/dist/runtime/base/base-thread-runtime-core.js +6 -5
  91. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  92. package/dist/runtime/interfaces/composer-runtime-core.d.ts +12 -5
  93. package/dist/runtime/interfaces/composer-runtime-core.d.ts.map +1 -1
  94. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +4 -1
  95. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
  96. package/dist/runtime/utils/external-message-conversion.js +1 -1
  97. package/dist/runtime/utils/external-message-conversion.js.map +1 -1
  98. package/dist/runtime/utils/message-repository.d.ts +1 -0
  99. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  100. package/dist/runtime/utils/message-repository.js +15 -6
  101. package/dist/runtime/utils/message-repository.js.map +1 -1
  102. package/dist/runtime/utils/thread-message-like.js +1 -1
  103. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  104. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  105. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +21 -27
  106. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  107. package/dist/runtimes/internal.d.ts +2 -2
  108. package/dist/runtimes/internal.js +2 -2
  109. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts +18 -1
  110. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts.map +1 -1
  111. package/dist/runtimes/remote-thread-list/remote-thread-state.js +130 -42
  112. package/dist/runtimes/remote-thread-list/remote-thread-state.js.map +1 -1
  113. package/dist/runtimes/remote-thread-list/title-generation.d.ts +49 -0
  114. package/dist/runtimes/remote-thread-list/title-generation.d.ts.map +1 -0
  115. package/dist/runtimes/remote-thread-list/title-generation.js +197 -0
  116. package/dist/runtimes/remote-thread-list/title-generation.js.map +1 -0
  117. package/dist/runtimes/remote-thread-list/title.d.ts +3 -1
  118. package/dist/runtimes/remote-thread-list/title.d.ts.map +1 -1
  119. package/dist/runtimes/remote-thread-list/title.js +9 -1
  120. package/dist/runtimes/remote-thread-list/title.js.map +1 -1
  121. package/dist/runtimes/remote-thread-list/types.d.ts +10 -0
  122. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  123. package/dist/store/clients/external-thread.d.ts.map +1 -1
  124. package/dist/store/clients/external-thread.js +19 -3
  125. package/dist/store/clients/external-thread.js.map +1 -1
  126. package/dist/store/clients/model-context-client.d.ts.map +1 -1
  127. package/dist/store/clients/model-context-client.js +2 -1
  128. package/dist/store/clients/model-context-client.js.map +1 -1
  129. package/dist/store/clients/single-thread-list.d.ts.map +1 -1
  130. package/dist/store/clients/single-thread-list.js +1 -0
  131. package/dist/store/clients/single-thread-list.js.map +1 -1
  132. package/dist/store/clients/suggestions.d.ts.map +1 -1
  133. package/dist/store/clients/suggestions.js +5 -4
  134. package/dist/store/clients/suggestions.js.map +1 -1
  135. package/dist/store/runtime-clients/composer-runtime-client.d.ts +1 -0
  136. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  137. package/dist/store/runtime-clients/composer-runtime-client.js +97 -61
  138. package/dist/store/runtime-clients/composer-runtime-client.js.map +1 -1
  139. package/dist/store/runtime-clients/message-runtime-client.d.ts +1 -0
  140. package/dist/store/runtime-clients/message-runtime-client.d.ts.map +1 -1
  141. package/dist/store/runtime-clients/message-runtime-client.js +221 -144
  142. package/dist/store/runtime-clients/message-runtime-client.js.map +1 -1
  143. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  144. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +1 -1
  145. package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
  146. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  147. package/dist/store/runtime-clients/thread-list-runtime-client.js +54 -52
  148. package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
  149. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  150. package/dist/store/runtime-clients/thread-runtime-client.js +193 -121
  151. package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
  152. package/dist/store/scopes/composer.d.ts +10 -2
  153. package/dist/store/scopes/composer.d.ts.map +1 -1
  154. package/dist/store/scopes/message.d.ts +30 -1
  155. package/dist/store/scopes/message.d.ts.map +1 -1
  156. package/dist/store/scopes/thread-list-item.d.ts +2 -2
  157. package/dist/store/scopes/thread-list-item.d.ts.map +1 -1
  158. package/dist/store/scopes/thread.d.ts +8 -0
  159. package/dist/store/scopes/thread.d.ts.map +1 -1
  160. package/dist/store/scopes/threads.d.ts +1 -0
  161. package/dist/store/scopes/threads.d.ts.map +1 -1
  162. package/dist/types/attachment.d.ts +2 -1
  163. package/dist/types/attachment.d.ts.map +1 -1
  164. package/dist/types/attachment.js +2 -1
  165. package/dist/types/attachment.js.map +1 -1
  166. package/dist/utils/abortable-iterable.d.ts +19 -0
  167. package/dist/utils/abortable-iterable.d.ts.map +1 -0
  168. package/dist/utils/abortable-iterable.js +72 -0
  169. package/dist/utils/abortable-iterable.js.map +1 -0
  170. package/dist/utils/composite-context-provider.d.ts.map +1 -1
  171. package/dist/utils/composite-context-provider.js +20 -2
  172. package/dist/utils/composite-context-provider.js.map +1 -1
  173. package/dist/utils/record.d.ts +11 -0
  174. package/dist/utils/record.d.ts.map +1 -0
  175. package/dist/utils/record.js +12 -0
  176. package/dist/utils/record.js.map +1 -0
  177. package/package.json +10 -10
  178. package/src/adapters/attachment.test.ts +50 -1
  179. package/src/adapters/attachment.ts +7 -4
  180. package/src/index.ts +1 -0
  181. package/src/internal.ts +4 -0
  182. package/src/model-context/frame/host.test.ts +24 -0
  183. package/src/model-context/frame/host.ts +10 -4
  184. package/src/model-context/frame/provider.test.ts +71 -0
  185. package/src/model-context/frame/provider.ts +37 -7
  186. package/src/model-context/interactable-composer-metadata.test.ts +28 -0
  187. package/src/model-context/interactable-composer-metadata.ts +9 -3
  188. package/src/model-context/registry.test.ts +12 -0
  189. package/src/model-context/registry.ts +3 -2
  190. package/src/model-context/types.test.ts +25 -0
  191. package/src/model-context/types.ts +9 -4
  192. package/src/react/RuntimeAdapter.test.ts +34 -0
  193. package/src/react/client/InMemoryThreadList.ts +1 -0
  194. package/src/react/client/Interactables.test.ts +28 -0
  195. package/src/react/client/Interactables.ts +15 -13
  196. package/src/react/client/RemoteThreadList.background.test.ts +148 -6
  197. package/src/react/client/RemoteThreadList.concurrent.test.tsx +42 -1
  198. package/src/react/client/RemoteThreadList.test.ts +345 -3
  199. package/src/react/client/RemoteThreadList.ts +166 -110
  200. package/src/react/client/Tools.ts +22 -11
  201. package/src/react/interactables-legacy/Interactables.ts +16 -16
  202. package/src/react/interactables-shared/useInteractablePersistenceQueue.ts +9 -10
  203. package/src/react/model-context/makeAssistantTool.ts +2 -2
  204. package/src/react/model-context/useAssistantTool.ts +2 -2
  205. package/src/react/model-context/useToolArgsStatus.test.tsx +30 -0
  206. package/src/react/model-context/useToolArgsStatus.ts +2 -1
  207. package/src/react/runtimes/RemoteThreadListHookInstanceManager.destroy.test.tsx +172 -0
  208. package/src/react/runtimes/RemoteThreadListHookInstanceManager.running.test.tsx +33 -0
  209. package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +7 -1
  210. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +28 -5
  211. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.load-error.test.ts +132 -0
  212. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.switch-delete.test.ts +105 -0
  213. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +136 -111
  214. package/src/react/runtimes/RemoteThreadResource.test.ts +9 -0
  215. package/src/react/runtimes/RemoteThreadResource.ts +19 -12
  216. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +483 -6
  217. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +504 -68
  218. package/src/react/runtimes/cloud/createCloudThreadListAdapter.test.ts +15 -0
  219. package/src/react/runtimes/cloud/createCloudThreadListAdapter.ts +1 -0
  220. package/src/react/runtimes/useExternalStoreRuntime.lifecycle.test.tsx +41 -0
  221. package/src/react/runtimes/useExternalStoreRuntime.ts +10 -4
  222. package/src/react/runtimes/useLocalRuntime.test.tsx +20 -0
  223. package/src/react/runtimes/useRemoteThreadListRuntime.load-error.test.tsx +108 -0
  224. package/src/react/runtimes/useRemoteThreadListRuntime.ts +33 -0
  225. package/src/runtime/api/message-runtime.test.ts +75 -0
  226. package/src/runtime/api/thread-list-item-runtime.ts +10 -3
  227. package/src/runtime/api/thread-list-runtime.ts +21 -11
  228. package/src/runtime/api/thread-runtime.test.ts +78 -0
  229. package/src/runtime/api/thread-runtime.ts +8 -1
  230. package/src/runtime/base/base-composer-runtime-core.ts +36 -8
  231. package/src/runtime/base/base-thread-runtime-core.test.ts +55 -1
  232. package/src/runtime/base/base-thread-runtime-core.ts +6 -4
  233. package/src/runtime/interfaces/composer-runtime-core.ts +10 -5
  234. package/src/runtime/interfaces/thread-list-runtime-core.ts +5 -1
  235. package/src/runtime/utils/external-message-conversion.test.ts +68 -0
  236. package/src/runtime/utils/external-message-conversion.ts +1 -1
  237. package/src/runtime/utils/message-repository.ts +29 -17
  238. package/src/runtime/utils/thread-message-like.ts +1 -1
  239. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +18 -13
  240. package/src/runtimes/internal.ts +1 -0
  241. package/src/runtimes/remote-thread-list/remote-thread-state.test.ts +128 -2
  242. package/src/runtimes/remote-thread-list/remote-thread-state.ts +182 -42
  243. package/src/runtimes/remote-thread-list/title-generation.test.ts +602 -0
  244. package/src/runtimes/remote-thread-list/title-generation.ts +329 -0
  245. package/src/runtimes/remote-thread-list/title.test.ts +62 -0
  246. package/src/runtimes/remote-thread-list/title.ts +13 -0
  247. package/src/runtimes/remote-thread-list/types.ts +10 -0
  248. package/src/store/clients/external-thread.ts +48 -3
  249. package/src/store/clients/model-context-client.ts +5 -1
  250. package/src/store/clients/single-thread-list.ts +1 -0
  251. package/src/store/clients/suggestions.test.ts +40 -0
  252. package/src/store/clients/suggestions.ts +13 -10
  253. package/src/store/runtime-clients/composer-runtime-client.ts +42 -9
  254. package/src/store/runtime-clients/message-runtime-client.ts +54 -6
  255. package/src/store/runtime-clients/thread-list-item-runtime-client.ts +4 -2
  256. package/src/store/runtime-clients/thread-list-runtime-client.ts +1 -0
  257. package/src/store/runtime-clients/thread-runtime-client.ts +58 -10
  258. package/src/store/scopes/composer.ts +16 -4
  259. package/src/store/scopes/message.ts +14 -0
  260. package/src/store/scopes/thread-list-item.ts +5 -2
  261. package/src/store/scopes/thread.ts +4 -0
  262. package/src/store/scopes/threads.ts +1 -0
  263. package/src/tests/MessageRepository.test.ts +116 -0
  264. package/src/tests/OptimisticState-list-race.test.ts +1 -0
  265. package/src/tests/RemoteThreadListThreadListRuntimeCore-initialize-adapter-change.test.ts +38 -0
  266. package/src/tests/RemoteThreadListThreadListRuntimeCore-load-race.test.ts +47 -0
  267. package/src/tests/RemoteThreadListThreadListRuntimeCore-title-race.test.ts +89 -0
  268. package/src/tests/attachment-adapters.test.ts +14 -0
  269. package/src/tests/base-composer-runtime-core.test.ts +39 -0
  270. package/src/tests/external-store-thread-list-runtime-core.test.ts +83 -0
  271. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +27 -18
  272. package/src/tests/external-thread-attachments.test.tsx +139 -0
  273. package/src/tests/external-thread-parity.test.tsx +23 -0
  274. package/src/tests/remote-thread-list-isLoading.test.ts +1 -0
  275. package/src/tests/thread-list-runtime-getLoadThreadsPromise.test.ts +1 -0
  276. package/src/tests/thread-switch-events.test.tsx +78 -0
  277. package/src/tests/tools-scope-migration.test.tsx +28 -0
  278. package/src/types/attachment.ts +4 -0
  279. package/src/utils/abortable-iterable.test.ts +213 -0
  280. package/src/utils/abortable-iterable.ts +93 -0
  281. package/src/utils/composite-context-provider.test.ts +25 -0
  282. package/src/utils/composite-context-provider.ts +23 -2
  283. package/src/utils/record.test.ts +48 -0
  284. package/src/utils/record.ts +11 -0
@@ -3,6 +3,9 @@ import {
3
3
  classifyThreads,
4
4
  createEmptyRemoteThreadState,
5
5
  createThreadMappingId,
6
+ getThreadData,
7
+ promoteNewThreadReducer,
8
+ reconcileInitializedThread,
6
9
  seedNewThread,
7
10
  updateStatusReducer,
8
11
  } from "./remote-thread-state";
@@ -13,12 +16,16 @@ import type {
13
16
 
14
17
  const initializedDraft = () => {
15
18
  const seeded = seedNewThread(createEmptyRemoteThreadState());
16
- const regular = updateStatusReducer(seeded.state, seeded.id, "regular");
17
- const mappingId = regular.threadIdMap[seeded.id]!;
18
19
  const initializeTask = Promise.resolve({
19
20
  remoteId: "remote-1",
20
21
  externalId: "remote-1",
21
22
  });
23
+ const regular = promoteNewThreadReducer(
24
+ seeded.state,
25
+ seeded.id,
26
+ initializeTask,
27
+ );
28
+ const mappingId = regular.threadIdMap[seeded.id]!;
22
29
  return {
23
30
  id: seeded.id,
24
31
  mappingId,
@@ -54,6 +61,7 @@ describe("remote thread state", () => {
54
61
  it("creates an empty state", () => {
55
62
  expect(createEmptyRemoteThreadState()).toEqual({
56
63
  isLoading: true,
64
+ loadError: undefined,
57
65
  isLoadingMore: false,
58
66
  cursor: undefined,
59
67
  newThreadId: undefined,
@@ -80,6 +88,39 @@ describe("remote thread state", () => {
80
88
  expect(Object.keys(second.state.threadData)).toEqual([first.id, second.id]);
81
89
  });
82
90
 
91
+ it("carries the initialization task onto the promoted slot", () => {
92
+ const seeded = seedNewThread(createEmptyRemoteThreadState());
93
+ const initializeTask = Promise.resolve({
94
+ remoteId: "remote-1",
95
+ externalId: "external-1",
96
+ });
97
+
98
+ const promoted = promoteNewThreadReducer(
99
+ seeded.state,
100
+ seeded.id,
101
+ initializeTask,
102
+ );
103
+ const data = getThreadData(promoted, seeded.id);
104
+
105
+ expect(data?.status).toBe("regular");
106
+ expect(data?.status === "new" ? undefined : data?.initializeTask).toBe(
107
+ initializeTask,
108
+ );
109
+ expect(promoted.newThreadId).toBeUndefined();
110
+ expect(promoted.threadIds).toEqual([seeded.id]);
111
+ });
112
+
113
+ it.each(["regular", "archived"] as const)(
114
+ "leaves a new thread untouched when moved to %s without an initialization task",
115
+ (newStatus) => {
116
+ const seeded = seedNewThread(createEmptyRemoteThreadState());
117
+
118
+ expect(updateStatusReducer(seeded.state, seeded.id, newStatus)).toBe(
119
+ seeded.state,
120
+ );
121
+ },
122
+ );
123
+
83
124
  it("refreshes the local slot when a listed thread already has a mapping", () => {
84
125
  const draft = initializedDraft();
85
126
 
@@ -131,6 +172,91 @@ describe("remote thread state", () => {
131
172
  expect(listed.threadData[createThreadMappingId("a")]?.title).toBe("second");
132
173
  });
133
174
 
175
+ it("retains a listed external id when that slot survives reconciliation", async () => {
176
+ const seeded = seedNewThread(createEmptyRemoteThreadState());
177
+ const regular = promoteNewThreadReducer(
178
+ seeded.state,
179
+ seeded.id,
180
+ Promise.resolve({ remoteId: "remote-1", externalId: "external-1" }),
181
+ );
182
+ const classified = classifyThreads(
183
+ [
184
+ {
185
+ status: "regular",
186
+ remoteId: "remote-1",
187
+ externalId: "external-1",
188
+ },
189
+ ],
190
+ {
191
+ threadIds: [...regular.threadIds],
192
+ archivedThreadIds: [...regular.archivedThreadIds],
193
+ threadIdMap: { ...regular.threadIdMap },
194
+ threadData: { ...regular.threadData },
195
+ },
196
+ );
197
+ const state = { ...regular, ...classified };
198
+
199
+ const reconciled = reconcileInitializedThread(
200
+ state,
201
+ seeded.id,
202
+ "remote-1",
203
+ undefined,
204
+ "remote-1",
205
+ );
206
+ const survivor = reconciled.state.threadData[reconciled.survivorMappingId]!;
207
+
208
+ expect(survivor.externalId).toBe("external-1");
209
+ if (survivor.status === "new") throw new Error("Expected initialized slot");
210
+ await expect(survivor.initializeTask).resolves.toEqual({
211
+ remoteId: "remote-1",
212
+ externalId: "external-1",
213
+ });
214
+ });
215
+
216
+ it.each(["__proto__", "constructor", "toString"])(
217
+ "handles a prototype-named remote id %s",
218
+ (remoteId) => {
219
+ const classified = classifyThreads(
220
+ [
221
+ {
222
+ status: "regular",
223
+ remoteId,
224
+ externalId: undefined,
225
+ title: `title-${remoteId}`,
226
+ },
227
+ {
228
+ status: "regular",
229
+ remoteId: "ok",
230
+ externalId: undefined,
231
+ title: "title-ok",
232
+ },
233
+ ],
234
+ {
235
+ threadIds: [],
236
+ archivedThreadIds: [],
237
+ threadIdMap: {},
238
+ threadData: {},
239
+ },
240
+ );
241
+ const listed: RemoteThreadState = {
242
+ ...createEmptyRemoteThreadState(),
243
+ threadIds: classified.threadIds,
244
+ archivedThreadIds: classified.archivedThreadIds,
245
+ threadIdMap: classified.threadIdMap,
246
+ threadData: classified.threadData,
247
+ };
248
+
249
+ expect(Object.keys(listed.threadIdMap)).toEqual([remoteId, "ok"]);
250
+ expect(typeof listed.threadIdMap[remoteId]).toBe("string");
251
+ expect(Object.keys(listed.threadData)).toEqual([remoteId, "ok"]);
252
+
253
+ const deleted = updateStatusReducer(listed, remoteId, "deleted");
254
+ expect(Object.keys(deleted.threadIdMap)).toEqual(["ok"]);
255
+ expect(Object.keys(deleted.threadData)).toEqual(["ok"]);
256
+ expect(deleted.threadIds).toEqual(["ok"]);
257
+ },
258
+ );
259
+
134
260
  it("deletes every alias of an identity, by either id", () => {
135
261
  for (const target of ["remote-1", "local"] as const) {
136
262
  const draft = initializedDraft();
@@ -3,6 +3,7 @@ import type {
3
3
  RemoteThreadMetadata,
4
4
  } from "./types";
5
5
  import { generateId } from "../../utils/id";
6
+ import { nullProtoRecord } from "../../utils/record";
6
7
 
7
8
  export type RemoteThreadData =
8
9
  | {
@@ -64,7 +65,11 @@ export const classifyThreads = (
64
65
  threads: readonly RemoteThreadMetadata[],
65
66
  acc: ClassifyAccumulator,
66
67
  ): ClassifyAccumulator => {
67
- const listed = new Set([...acc.threadIds, ...acc.archivedThreadIds]);
68
+ let threadIds = [...acc.threadIds];
69
+ let archivedThreadIds = [...acc.archivedThreadIds];
70
+ const threadIdMap = nullProtoRecord(acc.threadIdMap);
71
+ const threadData = nullProtoRecord(acc.threadData);
72
+ const listed = new Set([...threadIds, ...archivedThreadIds]);
68
73
 
69
74
  for (const thread of threads) {
70
75
  switch (thread.status) {
@@ -77,10 +82,10 @@ export const classifyThreads = (
77
82
  }
78
83
  }
79
84
 
80
- const existingMappingId = acc.threadIdMap[thread.remoteId];
85
+ const existingMappingId = threadIdMap[thread.remoteId];
81
86
  const existing =
82
87
  existingMappingId !== undefined
83
- ? acc.threadData[existingMappingId]
88
+ ? threadData[existingMappingId]
84
89
  : undefined;
85
90
  const id = existing?.id ?? thread.remoteId;
86
91
  const mappingId = existingMappingId ?? createThreadMappingId(id);
@@ -92,23 +97,23 @@ export const classifyThreads = (
92
97
  if (!listed.has(id)) {
93
98
  listed.add(id);
94
99
  if (thread.status === "regular") {
95
- acc.threadIds.push(id);
100
+ threadIds.push(id);
96
101
  } else {
97
- acc.archivedThreadIds.push(id);
102
+ archivedThreadIds.push(id);
98
103
  }
99
104
  } else if (existing !== undefined && existing.status !== thread.status) {
100
105
  if (thread.status === "regular") {
101
- acc.archivedThreadIds = acc.archivedThreadIds.filter((t) => t !== id);
102
- acc.threadIds.push(id);
106
+ archivedThreadIds = archivedThreadIds.filter((t) => t !== id);
107
+ threadIds.push(id);
103
108
  } else {
104
- acc.threadIds = acc.threadIds.filter((t) => t !== id);
105
- acc.archivedThreadIds.push(id);
109
+ threadIds = threadIds.filter((t) => t !== id);
110
+ archivedThreadIds.push(id);
106
111
  }
107
112
  }
108
113
 
109
- acc.threadIdMap[id] = mappingId;
110
- acc.threadIdMap[thread.remoteId] = mappingId;
111
- acc.threadData[mappingId] = {
114
+ threadIdMap[id] = mappingId;
115
+ threadIdMap[thread.remoteId] = mappingId;
116
+ threadData[mappingId] = {
112
117
  ...(existing?.localOrigin === true ? { localOrigin: true as const } : {}),
113
118
  id,
114
119
  remoteId: thread.remoteId,
@@ -125,11 +130,12 @@ export const classifyThreads = (
125
130
  }),
126
131
  };
127
132
  }
128
- return acc;
133
+ return { threadIds, archivedThreadIds, threadIdMap, threadData };
129
134
  };
130
135
 
131
136
  export type RemoteThreadState = {
132
137
  readonly isLoading: boolean;
138
+ readonly loadError: unknown;
133
139
  readonly isLoadingMore: boolean;
134
140
  readonly cursor: string | undefined;
135
141
  readonly newThreadId: string | undefined;
@@ -141,13 +147,14 @@ export type RemoteThreadState = {
141
147
 
142
148
  export const createEmptyRemoteThreadState = (): RemoteThreadState => ({
143
149
  isLoading: true,
150
+ loadError: undefined,
144
151
  isLoadingMore: false,
145
152
  cursor: undefined,
146
153
  newThreadId: undefined,
147
154
  threadIds: [],
148
155
  archivedThreadIds: [],
149
- threadIdMap: {},
150
- threadData: {},
156
+ threadIdMap: nullProtoRecord(),
157
+ threadData: nullProtoRecord(),
151
158
  });
152
159
 
153
160
  export const seedNewThread = (
@@ -163,12 +170,10 @@ export const seedNewThread = (
163
170
  state: {
164
171
  ...state,
165
172
  newThreadId: id,
166
- threadIdMap: {
167
- ...state.threadIdMap,
173
+ threadIdMap: nullProtoRecord(state.threadIdMap, {
168
174
  [id]: mappingId,
169
- },
170
- threadData: {
171
- ...state.threadData,
175
+ }),
176
+ threadData: nullProtoRecord(state.threadData, {
172
177
  [mappingId]: {
173
178
  status: "new",
174
179
  id,
@@ -178,7 +183,7 @@ export const seedNewThread = (
178
183
  custom: undefined,
179
184
  localOrigin: true,
180
185
  } satisfies RemoteThreadData,
181
- },
186
+ }),
182
187
  },
183
188
  };
184
189
  };
@@ -260,15 +265,124 @@ export const getThreadData = (
260
265
  state: RemoteThreadState,
261
266
  threadIdOrRemoteId: string,
262
267
  ) => {
263
- const idx = state.threadIdMap[threadIdOrRemoteId];
268
+ const idx = Object.hasOwn(state.threadIdMap, threadIdOrRemoteId)
269
+ ? state.threadIdMap[threadIdOrRemoteId]
270
+ : undefined;
264
271
  if (idx === undefined) return undefined;
265
- return state.threadData[idx];
272
+ return Object.hasOwn(state.threadData, idx)
273
+ ? state.threadData[idx]
274
+ : undefined;
266
275
  };
267
276
 
268
- export const updateStatusReducer = (
277
+ export const reconcileInitializedThread = (
278
+ state: RemoteThreadState,
279
+ threadId: string,
280
+ remoteId: string,
281
+ externalId: string | undefined,
282
+ retainedThreadId?: string,
283
+ ): {
284
+ state: RemoteThreadState;
285
+ removedMappingId: THREAD_MAPPING_ID | undefined;
286
+ survivorMappingId: THREAD_MAPPING_ID;
287
+ } => {
288
+ const mappingId = createThreadMappingId(threadId);
289
+ const data = Object.hasOwn(state.threadData, mappingId)
290
+ ? state.threadData[mappingId]
291
+ : undefined;
292
+ if (!data) {
293
+ return { state, removedMappingId: undefined, survivorMappingId: mappingId };
294
+ }
295
+
296
+ // A concurrent list cannot associate the remote ID with this initializing
297
+ // slot and may mint a duplicate. Retain the slot whose mounted runtime owns
298
+ // local state; initialized metadata remains authoritative over list metadata.
299
+ const listedMappingId = Object.hasOwn(state.threadIdMap, remoteId)
300
+ ? state.threadIdMap[remoteId]
301
+ : undefined;
302
+ const listedData =
303
+ listedMappingId !== undefined &&
304
+ listedMappingId !== mappingId &&
305
+ Object.hasOwn(state.threadData, listedMappingId)
306
+ ? state.threadData[listedMappingId]
307
+ : undefined;
308
+ const listedSlot =
309
+ listedMappingId !== undefined && listedData !== undefined
310
+ ? { mappingId: listedMappingId, data: listedData }
311
+ : undefined;
312
+ const retainedMappingId =
313
+ retainedThreadId !== undefined &&
314
+ Object.hasOwn(state.threadIdMap, retainedThreadId)
315
+ ? state.threadIdMap[retainedThreadId]
316
+ : undefined;
317
+ const survivorMappingId =
318
+ listedSlot !== undefined && retainedMappingId === listedSlot.mappingId
319
+ ? listedSlot.mappingId
320
+ : mappingId;
321
+ const removedMappingId =
322
+ listedSlot === undefined
323
+ ? undefined
324
+ : survivorMappingId === mappingId
325
+ ? listedSlot.mappingId
326
+ : mappingId;
327
+ const resolvedExternalId = externalId ?? listedSlot?.data.externalId;
328
+ const threadData = nullProtoRecord(state.threadData);
329
+ if (removedMappingId !== undefined) delete threadData[removedMappingId];
330
+ const initializedData = {
331
+ ...data,
332
+ id: survivorMappingId,
333
+ initializeTask: Promise.resolve({
334
+ remoteId,
335
+ externalId: resolvedExternalId,
336
+ }),
337
+ remoteId,
338
+ externalId: resolvedExternalId,
339
+ } as RemoteThreadData;
340
+ threadData[survivorMappingId] = {
341
+ ...listedSlot?.data,
342
+ ...initializedData,
343
+ title: data.title ?? listedSlot?.data.title,
344
+ lastMessageAt:
345
+ ("lastMessageAt" in data ? data.lastMessageAt : undefined) ??
346
+ (listedSlot && "lastMessageAt" in listedSlot.data
347
+ ? listedSlot.data.lastMessageAt
348
+ : undefined),
349
+ custom: data.custom ?? listedSlot?.data.custom,
350
+ } as RemoteThreadData;
351
+
352
+ const threadIdMap = nullProtoRecord(state.threadIdMap);
353
+ if (removedMappingId !== undefined) {
354
+ for (const [id, target] of Object.entries(threadIdMap)) {
355
+ if (target === removedMappingId) threadIdMap[id] = survivorMappingId;
356
+ }
357
+ }
358
+ threadIdMap[threadId] = survivorMappingId;
359
+ threadIdMap[remoteId] = survivorMappingId;
360
+
361
+ const rewire = (ids: readonly string[]) =>
362
+ listedSlot === undefined
363
+ ? ids
364
+ : ids
365
+ .filter((id) => id !== listedSlot.data.id)
366
+ .map((id) => (id === data.id ? survivorMappingId : id));
367
+
368
+ return {
369
+ state: {
370
+ ...state,
371
+ threadIds: rewire(state.threadIds),
372
+ archivedThreadIds: rewire(state.archivedThreadIds),
373
+ threadIdMap,
374
+ threadData,
375
+ },
376
+ removedMappingId,
377
+ survivorMappingId,
378
+ };
379
+ };
380
+
381
+ const transitionReducer = (
269
382
  state: RemoteThreadState,
270
383
  threadIdOrRemoteId: string,
271
384
  newStatus: "regular" | "archived" | "deleted",
385
+ initializeTask: Promise<RemoteThreadInitializeResponse> | undefined,
272
386
  ) => {
273
387
  const data = getThreadData(state, threadIdOrRemoteId);
274
388
  if (!data) return state;
@@ -276,6 +390,18 @@ export const updateStatusReducer = (
276
390
  const { id, status: lastStatus } = data;
277
391
  if (lastStatus === newStatus) return state;
278
392
 
393
+ let nextData: RemoteThreadData | undefined;
394
+ if (newStatus !== "deleted") {
395
+ if (data.status === "new") {
396
+ // The new variant holds no initialization promise, so the destination
397
+ // union member cannot be built unless the caller supplies one.
398
+ if (initializeTask === undefined) return state;
399
+ nextData = { ...data, initializeTask, status: newStatus };
400
+ } else {
401
+ nextData = { ...data, status: newStatus };
402
+ }
403
+ }
404
+
279
405
  const newState = { ...state };
280
406
 
281
407
  // lastStatus
@@ -310,16 +436,14 @@ export const updateStatusReducer = (
310
436
 
311
437
  case "deleted": {
312
438
  const mappingId = state.threadIdMap[threadIdOrRemoteId]!;
313
- newState.threadData = Object.fromEntries(
314
- Object.entries(newState.threadData).filter(
315
- ([key]) => key !== mappingId,
316
- ),
317
- );
318
- newState.threadIdMap = Object.fromEntries(
319
- Object.entries(newState.threadIdMap).filter(
320
- ([, value]) => value !== mappingId,
321
- ),
322
- );
439
+ const threadData = nullProtoRecord(newState.threadData);
440
+ delete threadData[mappingId];
441
+ newState.threadData = threadData;
442
+ const threadIdMap = nullProtoRecord(newState.threadIdMap);
443
+ for (const [key, value] of Object.entries(threadIdMap)) {
444
+ if (value === mappingId) delete threadIdMap[key];
445
+ }
446
+ newState.threadIdMap = threadIdMap;
323
447
  break;
324
448
  }
325
449
 
@@ -329,15 +453,31 @@ export const updateStatusReducer = (
329
453
  }
330
454
  }
331
455
 
332
- if (newStatus !== "deleted") {
333
- newState.threadData = {
334
- ...newState.threadData,
335
- [id]: {
336
- ...data,
337
- status: newStatus,
338
- },
339
- };
456
+ if (nextData !== undefined) {
457
+ newState.threadData = { ...newState.threadData, [id]: nextData };
340
458
  }
341
459
 
342
460
  return newState;
343
461
  };
462
+
463
+ /**
464
+ * Transitions a thread that already exists remotely. Promoting the local draft
465
+ * off `new` needs the initialization promise its destination variant requires,
466
+ * so that goes through `promoteNewThreadReducer`; asking for it here leaves the
467
+ * state unchanged.
468
+ */
469
+ export const updateStatusReducer = (
470
+ state: RemoteThreadState,
471
+ threadIdOrRemoteId: string,
472
+ newStatus: "regular" | "archived" | "deleted",
473
+ ) => transitionReducer(state, threadIdOrRemoteId, newStatus, undefined);
474
+
475
+ /**
476
+ * Promotes the local draft to `regular`, carrying the initialization promise
477
+ * onto the slot.
478
+ */
479
+ export const promoteNewThreadReducer = (
480
+ state: RemoteThreadState,
481
+ threadIdOrRemoteId: string,
482
+ initializeTask: Promise<RemoteThreadInitializeResponse>,
483
+ ) => transitionReducer(state, threadIdOrRemoteId, "regular", initializeTask);