@assistant-ui/core 0.3.16 → 0.3.17

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 (305) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/internal.d.ts +2 -1
  5. package/dist/internal.js +2 -1
  6. package/dist/model-context/frame/provider.d.ts +3 -0
  7. package/dist/model-context/frame/provider.d.ts.map +1 -1
  8. package/dist/model-context/frame/provider.js +71 -21
  9. package/dist/model-context/frame/provider.js.map +1 -1
  10. package/dist/model-context/tool-call-text.d.ts +26 -0
  11. package/dist/model-context/tool-call-text.d.ts.map +1 -0
  12. package/dist/model-context/tool-call-text.js +18 -0
  13. package/dist/model-context/tool-call-text.js.map +1 -0
  14. package/dist/react/RuntimeAdapter.js +1 -1
  15. package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
  16. package/dist/react/adapters/LocalStorageThreadListAdapter.js +1 -1
  17. package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
  18. package/dist/react/client/AdaptedRemoteThread.js +1 -1
  19. package/dist/react/client/InMemoryThreadList.js +1 -1
  20. package/dist/react/client/Interactables.js +1 -1
  21. package/dist/react/client/RemoteThreadList.d.ts +15 -0
  22. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  23. package/dist/react/client/RemoteThreadList.js +240 -138
  24. package/dist/react/client/RemoteThreadList.js.map +1 -1
  25. package/dist/react/client/Tools.d.ts.map +1 -1
  26. package/dist/react/client/Tools.js +6 -2
  27. package/dist/react/client/Tools.js.map +1 -1
  28. package/dist/react/index.d.ts +2 -1
  29. package/dist/react/index.js +2 -1
  30. package/dist/react/interactables-legacy/Interactables.js +4 -4
  31. package/dist/react/interactables-legacy/Interactables.js.map +1 -1
  32. package/dist/react/model-context/toolbox.d.ts +2 -14
  33. package/dist/react/model-context/toolbox.d.ts.map +1 -1
  34. package/dist/react/model-context/toolbox.js +1 -13
  35. package/dist/react/model-context/toolbox.js.map +1 -1
  36. package/dist/react/model-context/useAssistantContext.js +37 -7
  37. package/dist/react/model-context/useAssistantContext.js.map +1 -1
  38. package/dist/react/model-context/useAuiToolOverrides.js +31 -5
  39. package/dist/react/model-context/useAuiToolOverrides.js.map +1 -1
  40. package/dist/react/model-context/useInlineRender.d.ts.map +1 -1
  41. package/dist/react/model-context/useInlineRender.js +10 -36
  42. package/dist/react/model-context/useInlineRender.js.map +1 -1
  43. package/dist/react/primitive-hooks/useMessageError.d.ts.map +1 -1
  44. package/dist/react/primitive-hooks/useMessageError.js +2 -8
  45. package/dist/react/primitive-hooks/useMessageError.js.map +1 -1
  46. package/dist/react/primitive-hooks/useThreadListLoadMore.d.ts.map +1 -1
  47. package/dist/react/primitive-hooks/useThreadListLoadMore.js +2 -4
  48. package/dist/react/primitive-hooks/useThreadListLoadMore.js.map +1 -1
  49. package/dist/react/primitives/composer/ComposerAttachments.d.ts.map +1 -1
  50. package/dist/react/primitives/composer/ComposerAttachments.js +4 -3
  51. package/dist/react/primitives/composer/ComposerAttachments.js.map +1 -1
  52. package/dist/react/primitives/composer/ComposerQueue.d.ts.map +1 -1
  53. package/dist/react/primitives/composer/ComposerQueue.js +4 -3
  54. package/dist/react/primitives/composer/ComposerQueue.js.map +1 -1
  55. package/dist/react/primitives/message/MessageAttachments.d.ts.map +1 -1
  56. package/dist/react/primitives/message/MessageAttachments.js +7 -6
  57. package/dist/react/primitives/message/MessageAttachments.js.map +1 -1
  58. package/dist/react/primitives/message/MessageGroupedParts.js +2 -2
  59. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  60. package/dist/react/primitives/message/MessageParts.js +3 -3
  61. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  62. package/dist/react/primitives/threadList/ThreadListItems.d.ts.map +1 -1
  63. package/dist/react/primitives/threadList/ThreadListItems.js +5 -4
  64. package/dist/react/primitives/threadList/ThreadListItems.js.map +1 -1
  65. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +6 -4
  66. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  67. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +44 -22
  68. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  69. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -5
  70. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  71. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +79 -62
  72. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  73. package/dist/react/runtimes/RemoteThreadResource.d.ts +1 -6
  74. package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
  75. package/dist/react/runtimes/RemoteThreadResource.js +3 -3
  76. package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
  77. package/dist/react/runtimes/assistant-transport/useLatestRef.js +2 -2
  78. package/dist/react/runtimes/assistant-transport/useLatestRef.js.map +1 -1
  79. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts.map +1 -1
  80. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +23 -18
  81. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  82. package/dist/react/runtimes/cloud/auiV0.d.ts +5 -1
  83. package/dist/react/runtimes/cloud/auiV0.d.ts.map +1 -1
  84. package/dist/react/runtimes/cloud/auiV0.js.map +1 -1
  85. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.d.ts +29 -0
  86. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.d.ts.map +1 -0
  87. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.js +159 -0
  88. package/dist/react/runtimes/cloud/createCloudThreadListAdapter.js.map +1 -0
  89. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts +1 -9
  90. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  91. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +16 -153
  92. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  93. package/dist/react/runtimes/external-message-converter.js +1 -1
  94. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  95. package/dist/react/runtimes/useRemoteThreadListRuntime.d.ts.map +1 -1
  96. package/dist/react/runtimes/useRemoteThreadListRuntime.js +14 -6
  97. package/dist/react/runtimes/useRemoteThreadListRuntime.js.map +1 -1
  98. package/dist/react/types/MessagePartComponentTypes.d.ts +7 -3
  99. package/dist/react/types/MessagePartComponentTypes.d.ts.map +1 -1
  100. package/dist/react/types/scopes/tools.d.ts +2 -0
  101. package/dist/react/types/scopes/tools.d.ts.map +1 -1
  102. package/dist/runtime/api/message-part-runtime.d.ts +2 -2
  103. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  104. package/dist/runtime/api/message-part-runtime.js +1 -1
  105. package/dist/runtime/api/message-part-runtime.js.map +1 -1
  106. package/dist/runtime/api/thread-list-runtime.d.ts +9 -1
  107. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  108. package/dist/runtime/api/thread-list-runtime.js +5 -0
  109. package/dist/runtime/api/thread-list-runtime.js.map +1 -1
  110. package/dist/runtime/api/thread-runtime.d.ts +1 -1
  111. package/dist/runtime/base/base-thread-runtime-core.d.ts +2 -2
  112. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  113. package/dist/runtime/base/base-thread-runtime-core.js +2 -2
  114. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  115. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts +14 -2
  116. package/dist/runtime/interfaces/thread-list-runtime-core.d.ts.map +1 -1
  117. package/dist/runtime/interfaces/thread-runtime-core.d.ts +15 -1
  118. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  119. package/dist/runtime/utils/auto-status.d.ts +1 -1
  120. package/dist/runtime/utils/auto-status.d.ts.map +1 -1
  121. package/dist/runtime/utils/auto-status.js +6 -2
  122. package/dist/runtime/utils/auto-status.js.map +1 -1
  123. package/dist/runtime/utils/external-message-conversion.d.ts +3 -2
  124. package/dist/runtime/utils/external-message-conversion.d.ts.map +1 -1
  125. package/dist/runtime/utils/external-message-conversion.js +4 -3
  126. package/dist/runtime/utils/external-message-conversion.js.map +1 -1
  127. package/dist/runtime/utils/resolveToolApprovalResponse.d.ts +6 -3
  128. package/dist/runtime/utils/resolveToolApprovalResponse.d.ts.map +1 -1
  129. package/dist/runtime/utils/resolveToolApprovalResponse.js +12 -3
  130. package/dist/runtime/utils/resolveToolApprovalResponse.js.map +1 -1
  131. package/dist/runtime/utils/thread-message-like.d.ts +2 -10
  132. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  133. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  134. package/dist/runtime/utils/tool-call-tree.d.ts +39 -0
  135. package/dist/runtime/utils/tool-call-tree.d.ts.map +1 -0
  136. package/dist/runtime/utils/tool-call-tree.js +85 -0
  137. package/dist/runtime/utils/tool-call-tree.js.map +1 -0
  138. package/dist/runtimes/external-store/external-store-adapter.d.ts +29 -2
  139. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  140. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +2 -1
  141. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  142. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +34 -28
  143. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  144. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +1 -1
  145. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -1
  146. package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -1
  147. package/dist/runtimes/local/local-thread-runtime-core.d.ts +1 -1
  148. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  149. package/dist/runtimes/local/local-thread-runtime-core.js +3 -1
  150. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  151. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts +1 -1
  152. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts.map +1 -1
  153. package/dist/runtimes/remote-thread-list/remote-thread-state.js +30 -13
  154. package/dist/runtimes/remote-thread-list/remote-thread-state.js.map +1 -1
  155. package/dist/runtimes/remote-thread-list/title.d.ts +9 -0
  156. package/dist/runtimes/remote-thread-list/title.d.ts.map +1 -0
  157. package/dist/runtimes/remote-thread-list/title.js +6 -0
  158. package/dist/runtimes/remote-thread-list/title.js.map +1 -0
  159. package/dist/runtimes/remote-thread-list/types.d.ts +6 -5
  160. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  161. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts +31 -8
  162. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  163. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +97 -60
  164. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  165. package/dist/store/clients/external-thread.d.ts +1 -1
  166. package/dist/store/clients/external-thread.d.ts.map +1 -1
  167. package/dist/store/clients/external-thread.js +11 -4
  168. package/dist/store/clients/external-thread.js.map +1 -1
  169. package/dist/store/clients/single-thread-list.js +1 -1
  170. package/dist/store/clients/suggestions.d.ts.map +1 -1
  171. package/dist/store/clients/suggestions.js +18 -6
  172. package/dist/store/clients/suggestions.js.map +1 -1
  173. package/dist/store/clients/thread-message-client.js +1 -1
  174. package/dist/store/clients/thread-selection-events.js +1 -1
  175. package/dist/store/index.d.ts +2 -1
  176. package/dist/store/index.js +2 -1
  177. package/dist/store/internal.d.ts +2 -2
  178. package/dist/store/internal.js +2 -2
  179. package/dist/store/primitive-predicates.d.ts +4 -1
  180. package/dist/store/primitive-predicates.d.ts.map +1 -1
  181. package/dist/store/primitive-predicates.js +9 -1
  182. package/dist/store/primitive-predicates.js.map +1 -1
  183. package/dist/store/runtime-clients/composer-runtime-client.js +1 -1
  184. package/dist/store/runtime-clients/message-runtime-client.js +1 -1
  185. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +1 -1
  186. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  187. package/dist/store/runtime-clients/thread-list-runtime-client.js +124 -105
  188. package/dist/store/runtime-clients/thread-list-runtime-client.js.map +1 -1
  189. package/dist/store/runtime-clients/thread-runtime-client.js +1 -1
  190. package/dist/store/runtime-clients/useSubscribable.d.ts +3 -1
  191. package/dist/store/runtime-clients/useSubscribable.d.ts.map +1 -1
  192. package/dist/store/runtime-clients/useSubscribable.js +1 -1
  193. package/dist/store/runtime-clients/useSubscribable.js.map +1 -1
  194. package/dist/store/scopes/part.d.ts +6 -3
  195. package/dist/store/scopes/part.d.ts.map +1 -1
  196. package/dist/subscribable/subscribable.d.ts +8 -1
  197. package/dist/subscribable/subscribable.d.ts.map +1 -1
  198. package/dist/subscribable/subscribable.js +24 -16
  199. package/dist/subscribable/subscribable.js.map +1 -1
  200. package/dist/types/message.d.ts +40 -1
  201. package/dist/types/message.d.ts.map +1 -1
  202. package/dist/types/message.js +7 -1
  203. package/dist/types/message.js.map +1 -1
  204. package/package.json +12 -17
  205. package/src/index.ts +6 -1
  206. package/src/internal.ts +8 -0
  207. package/src/model-context/frame/provider.test.ts +217 -1
  208. package/src/model-context/frame/provider.ts +111 -23
  209. package/src/model-context/tool-call-text.test.ts +67 -0
  210. package/src/model-context/tool-call-text.ts +67 -0
  211. package/src/react/AssistantRuntimeProvider.test.tsx +66 -2
  212. package/src/react/adapters/LocalStorageThreadListAdapter.tsx +1 -3
  213. package/src/react/client/RemoteThreadList.background.test.ts +384 -0
  214. package/src/react/client/RemoteThreadList.concurrent.test.tsx +274 -0
  215. package/src/react/client/RemoteThreadList.test.ts +273 -1
  216. package/src/react/client/RemoteThreadList.ts +293 -83
  217. package/src/react/client/Tools.ts +7 -1
  218. package/src/react/index.ts +4 -0
  219. package/src/react/model-context/modelContextHooks.test.tsx +131 -0
  220. package/src/react/model-context/toolbox.test.ts +9 -9
  221. package/src/react/model-context/toolbox.ts +9 -36
  222. package/src/react/model-context/useAssistantContext.ts +4 -2
  223. package/src/react/model-context/useAuiToolOverrides.ts +4 -2
  224. package/src/react/model-context/useInlineRender.test.tsx +48 -0
  225. package/src/react/model-context/useInlineRender.ts +14 -10
  226. package/src/react/primitive-hooks/useMessageError.ts +2 -19
  227. package/src/react/primitive-hooks/useThreadListLoadMore.ts +2 -3
  228. package/src/react/primitives/composer/ComposerAttachments.test.tsx +76 -0
  229. package/src/react/primitives/composer/ComposerAttachments.tsx +9 -4
  230. package/src/react/primitives/composer/ComposerQueue.test.tsx +72 -0
  231. package/src/react/primitives/composer/ComposerQueue.tsx +7 -4
  232. package/src/react/primitives/message/MessageAttachments.test.tsx +107 -37
  233. package/src/react/primitives/message/MessageAttachments.tsx +10 -7
  234. package/src/react/primitives/message/MessageGroupedParts.tsx +2 -2
  235. package/src/react/primitives/message/MessageParts.tsx +3 -3
  236. package/src/react/primitives/threadList/ThreadListItems.test.tsx +99 -0
  237. package/src/react/primitives/threadList/ThreadListItems.tsx +8 -5
  238. package/src/react/runtimes/RemoteThreadListHookInstanceManager.running.test.tsx +94 -1
  239. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +72 -31
  240. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.switch-delete.test.ts +169 -0
  241. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +114 -68
  242. package/src/react/runtimes/RemoteThreadResource.ts +1 -4
  243. package/src/react/runtimes/assistant-transport/transport-scheduling.test.ts +51 -2
  244. package/src/react/runtimes/assistant-transport/useLatestRef.ts +2 -2
  245. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.ts +45 -0
  246. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +65 -1
  247. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +42 -35
  248. package/src/react/runtimes/cloud/auiV0.ts +5 -0
  249. package/src/react/runtimes/cloud/createCloudThreadListAdapter.test.ts +88 -0
  250. package/src/react/runtimes/cloud/createCloudThreadListAdapter.ts +236 -0
  251. package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +90 -1
  252. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +33 -238
  253. package/src/react/runtimes/external-message-converter.ts +1 -0
  254. package/src/react/runtimes/useRemoteThreadListRuntime.concurrent.test.tsx +216 -0
  255. package/src/react/runtimes/useRemoteThreadListRuntime.ts +24 -6
  256. package/src/react/types/MessagePartComponentTypes.ts +7 -3
  257. package/src/react/types/scopes/tools.ts +2 -0
  258. package/src/runtime/api/message-part-runtime.ts +3 -3
  259. package/src/runtime/api/thread-list-runtime.ts +25 -1
  260. package/src/runtime/base/base-thread-runtime-core.test.ts +94 -3
  261. package/src/runtime/base/base-thread-runtime-core.ts +10 -4
  262. package/src/runtime/interfaces/thread-list-runtime-core.ts +20 -1
  263. package/src/runtime/interfaces/thread-runtime-core.ts +17 -1
  264. package/src/runtime/utils/auto-status.test.ts +57 -0
  265. package/src/runtime/utils/auto-status.ts +13 -1
  266. package/src/runtime/utils/external-message-conversion.test.ts +97 -0
  267. package/src/runtime/utils/external-message-conversion.ts +17 -1
  268. package/src/runtime/utils/resolveToolApprovalResponse.test.ts +73 -0
  269. package/src/runtime/utils/resolveToolApprovalResponse.ts +27 -6
  270. package/src/runtime/utils/thread-message-like.ts +2 -10
  271. package/src/runtime/utils/tool-call-tree.test.ts +191 -0
  272. package/src/runtime/utils/tool-call-tree.ts +97 -0
  273. package/src/runtimes/external-store/external-store-adapter.ts +38 -2
  274. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +56 -33
  275. package/src/runtimes/inert/InertThreadRuntimeCore.ts +1 -1
  276. package/src/runtimes/local/local-thread-runtime-core.test.ts +29 -3
  277. package/src/runtimes/local/local-thread-runtime-core.ts +5 -1
  278. package/src/runtimes/remote-thread-list/remote-thread-state.test.ts +112 -0
  279. package/src/runtimes/remote-thread-list/remote-thread-state.ts +55 -16
  280. package/src/runtimes/remote-thread-list/title.ts +3 -0
  281. package/src/runtimes/remote-thread-list/types.ts +6 -5
  282. package/src/runtimes/tool-invocations/EDGE_CASES.md +110 -5
  283. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +750 -136
  284. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +144 -91
  285. package/src/store/clients/external-thread.ts +14 -8
  286. package/src/store/clients/suggestions.test.ts +155 -1
  287. package/src/store/clients/suggestions.ts +42 -34
  288. package/src/store/index.ts +4 -0
  289. package/src/store/internal.ts +2 -0
  290. package/src/store/primitive-predicates.ts +35 -0
  291. package/src/store/runtime-clients/thread-list-runtime-client.ts +16 -2
  292. package/src/store/runtime-clients/useSubscribable.ts +8 -2
  293. package/src/store/scopes/part.ts +6 -3
  294. package/src/subscribable/subscribable.test.ts +50 -0
  295. package/src/subscribable/subscribable.ts +33 -21
  296. package/src/tests/RemoteThreadListThreadListRuntimeCore-load-race.test.ts +111 -0
  297. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +78 -0
  298. package/src/tests/RemoteThreadListThreadListRuntimeCore-reload.test.ts +52 -0
  299. package/src/tests/external-store-optimistic-id.test.ts +79 -0
  300. package/src/tests/external-store-repository-instance.test.ts +166 -0
  301. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +22 -16
  302. package/src/tests/external-thread-attachments.test.tsx +85 -1
  303. package/src/tests/remote-thread-list-adapter-switch.test.ts +66 -1
  304. package/src/tests/thread-switch-events.test.tsx +397 -2
  305. package/src/types/message.ts +50 -2
@@ -14,7 +14,8 @@ import {
14
14
  type ReadonlyJSONValue,
15
15
  } from "assistant-stream/utils";
16
16
  import { isJSONValueEqual } from "../../utils/json/is-json-equal";
17
- import type { ThreadMessage } from "../../types/message";
17
+ import type { ThreadMessage, ToolCallMessagePart } from "../../types/message";
18
+ import { walkToolCallTree } from "../../runtime/utils/tool-call-tree";
18
19
 
19
20
  const TOOL_EXECUTION_ID = Symbol.for("assistant-stream.tool-execution-id");
20
21
 
@@ -54,6 +55,7 @@ type ToolCallEntry = {
54
55
  /** Active phase — chunks are flowing through `controller`. */
55
56
  controller: ToolCallStreamController;
56
57
  argsComplete: boolean;
58
+ clientOwned: boolean;
57
59
  }
58
60
  );
59
61
 
@@ -116,6 +118,9 @@ const getToolExecutionId = (value: object): symbol | undefined =>
116
118
  export class ToolInvocationTracker {
117
119
  private readonly _getTools: () => Record<string, Tool> | undefined;
118
120
  private readonly _callbacks: ToolInvocationTracker.Callbacks;
121
+ private readonly _isClientToolCall:
122
+ | ((toolCall: ToolCallMessagePart) => boolean | undefined)
123
+ | undefined;
119
124
 
120
125
  private readonly _entries = new Map<string, ToolCallEntry>();
121
126
  private readonly _humanInput = new Map<
@@ -127,6 +132,13 @@ export class ToolInvocationTracker {
127
132
  }
128
133
  >();
129
134
  private readonly _executing = new Set<symbol>();
135
+ /**
136
+ * Tool calls whose turn ended before they reached the executor. Held here
137
+ * rather than on the entry because an entry is rebuilt whenever a snapshot
138
+ * re-creates the call, and this is the one reason to skip that no later
139
+ * snapshot carries.
140
+ */
141
+ private readonly _discardedToolCallIds = new Set<string>();
130
142
  private readonly _settledResolvers: SettledResolver[] = [];
131
143
 
132
144
  private _statuses = new Map<string, ToolExecutionStatus>();
@@ -143,10 +155,12 @@ export class ToolInvocationTracker {
143
155
  /**
144
156
  * Set when the assistant-stream pipeline has died (errored out via
145
157
  * `.pipeTo(...).catch(...)`). The next `setState` re-initializes the
146
- * pipeline and demotes all active entries to restored so they survive
147
- * across the restart without re-firing `streamCall` (preserves the
148
- * "exactly once" contract). Capped at a single auto-restart per session
149
- * — repeated failures keep the tracker dead with a more visible error.
158
+ * pipeline and demotes each active entry that reached the executor to
159
+ * restored, so it survives the restart without re-firing `streamCall`.
160
+ * A restart is an execution boundary like `reset()`: an entry that had
161
+ * not reached it starts over and fires once there (F.4). Capped at a
162
+ * single auto-restart per session — repeated failures keep the tracker
163
+ * dead with a more visible error.
150
164
  */
151
165
  private _pipelineDead = false;
152
166
  private _pipelineRestartUsed = false;
@@ -154,9 +168,11 @@ export class ToolInvocationTracker {
154
168
  constructor(
155
169
  getTools: () => Record<string, Tool> | undefined,
156
170
  callbacks: ToolInvocationTracker.Callbacks,
171
+ isClientToolCall?: (toolCall: ToolCallMessagePart) => boolean | undefined,
157
172
  ) {
158
173
  this._getTools = getTools;
159
174
  this._callbacks = callbacks;
175
+ this._isClientToolCall = isClientToolCall;
160
176
 
161
177
  this._initPipeline();
162
178
  }
@@ -222,10 +238,10 @@ export class ToolInvocationTracker {
222
238
  */
223
239
  public setState(snapshot: ToolInvocationTracker.Snapshot): void {
224
240
  try {
225
- // Recover from a dead pipeline before processing anything. We demote
226
- // all active entries to "restored" so the rebuilt pipeline does not
227
- // re-fire `streamCall` for tool calls that already fired pre-death;
228
- // preserves the "exactly once per toolCallId" contract.
241
+ // Recover from a dead pipeline before processing anything. Entries
242
+ // that reached the executor are demoted to "restored" so the rebuilt
243
+ // pipeline does not re-fire `streamCall` for them; the rest start over
244
+ // across the boundary the restart opens (F.4).
229
245
  if (this._pipelineDead) {
230
246
  if (this._pipelineRestartUsed) {
231
247
  // Already retried once and failed again. Stay dead.
@@ -291,6 +307,7 @@ export class ToolInvocationTracker {
291
307
  try {
292
308
  this._pendingRestore = true;
293
309
  this._entries.clear();
310
+ this._discardedToolCallIds.clear();
294
311
  this._lastSnapshot = null;
295
312
  void this.abort();
296
313
  // Statuses are cleared synchronously: discarded executions may never
@@ -310,8 +327,13 @@ export class ToolInvocationTracker {
310
327
  /**
311
328
  * Abort any in-flight `execute()` invocations. Resolves once all of them
312
329
  * have settled (or immediately if none are running).
330
+ *
331
+ * `discardPending` additionally kills the calls that never reached the
332
+ * executor, for a caller ending the turn rather than interrupting it. The
333
+ * signal cannot reach those: they are waiting on the run to settle (A.10),
334
+ * and the settled snapshot arrives after this installs a fresh controller.
313
335
  */
314
- public abort(): Promise<void> {
336
+ public abort(options?: { discardPending?: boolean }): Promise<void> {
315
337
  try {
316
338
  this._humanInput.forEach(({ reject }) => {
317
339
  try {
@@ -323,6 +345,15 @@ export class ToolInvocationTracker {
323
345
  });
324
346
  this._humanInput.clear();
325
347
 
348
+ if (options?.discardPending) {
349
+ for (const [toolCallId, entry] of this._entries) {
350
+ if (!entry.controller) continue;
351
+ if (entry.argsComplete || entry.hasResult) continue;
352
+ this._discardedToolCallIds.add(toolCallId);
353
+ entry.skipExecute = true;
354
+ }
355
+ }
356
+
326
357
  this._ac.abort();
327
358
  this._ac = new AbortController();
328
359
 
@@ -473,6 +504,8 @@ export class ToolInvocationTracker {
473
504
  if (entry.skipExecute) return;
474
505
 
475
506
  this._executing.add(executionId!);
507
+ // execute can park human() before onExecutionStart; preserve this execution's interrupt.
508
+ if (this._humanInput.get(toolCallId)?.executionId === executionId) return;
476
509
  this._setStatus(toolCallId, { type: "executing" });
477
510
  }
478
511
 
@@ -577,31 +610,40 @@ export class ToolInvocationTracker {
577
610
 
578
611
  // ──────────────── internal: snapshot processing ────────────────
579
612
 
580
- private _hasExecutableTool(toolName: string): boolean {
581
- const tool = this._getTools()?.[toolName];
582
- return tool?.execute !== undefined || tool?.streamCall !== undefined;
613
+ private _warnProviderOwnedSkip(toolName: string, toolCallId: string): void {
614
+ if (process.env.NODE_ENV === "production") return;
615
+ if (this._getTools()?.[toolName]?.execute === undefined) return;
616
+ console.warn(
617
+ "[ToolInvocationTracker] the runtime reports this tool call as provider-owned, so the registered execute is skipped; the provider has to hand the call to the client for it to run (see EDGE_CASES.md A.9)",
618
+ { toolCallId, toolName },
619
+ );
583
620
  }
584
621
 
622
+ /**
623
+ * Closing the args stream hands the call to the client executor, so it may
624
+ * only happen once the provider can no longer speak about that call. The run
625
+ * ending is the only universal signal for that; an adapter that reports the
626
+ * call as client-owned has said it earlier, per call.
627
+ */
585
628
  private _shouldCloseArgsStream({
586
- toolName,
587
629
  argsText,
588
630
  hasResult,
631
+ clientOwned,
589
632
  }: {
590
- toolName: string;
591
633
  argsText: string;
592
634
  hasResult: boolean;
635
+ clientOwned: boolean;
593
636
  }): boolean {
594
637
  if (hasResult) return true;
595
- if (!this._hasExecutableTool(toolName)) {
596
- return !this._isRunning && isArgsTextComplete(argsText);
597
- }
598
- return isArgsTextComplete(argsText);
638
+ if (!isArgsTextComplete(argsText)) return false;
639
+ return clientOwned || !this._isRunning;
599
640
  }
600
641
 
601
642
  private _startActiveEntry(
602
643
  toolCallId: string,
603
644
  toolName: string,
604
645
  skipExecute: boolean,
646
+ clientOwned: boolean,
605
647
  ): ToolCallEntry {
606
648
  const toolCallController = this._controller.addToolCallPart({
607
649
  toolName,
@@ -614,6 +656,7 @@ export class ToolInvocationTracker {
614
656
  hasResult: false,
615
657
  skipExecute,
616
658
  argsComplete: false,
659
+ clientOwned,
617
660
  };
618
661
  this._entries.set(toolCallId, entry);
619
662
  return entry;
@@ -629,6 +672,16 @@ export class ToolInvocationTracker {
629
672
  private _demoteEntriesToRestored(): void {
630
673
  for (const [toolCallId, entry] of this._entries) {
631
674
  if (!entry.controller) continue;
675
+ if (!entry.argsComplete && !entry.hasResult) {
676
+ // The call never reached the executor. A restored entry is promoted
677
+ // only when its signature changes, and a call waiting on the run to
678
+ // settle already holds its final args, so demoting it would strand it
679
+ // unexecuted. Dropping it lets the next snapshot start it over; a call
680
+ // whose turn was discarded is held by `_discardedToolCallIds`, not by
681
+ // the entry, so starting over does not revive it.
682
+ this._entries.delete(toolCallId);
683
+ continue;
684
+ }
632
685
  this._entries.set(toolCallId, {
633
686
  toolName: entry.toolName,
634
687
  argsText: entry.argsText,
@@ -683,9 +736,9 @@ export class ToolInvocationTracker {
683
736
  isEquivalentCompleteArgsText(entry.argsText, content.argsText)
684
737
  ) {
685
738
  const shouldClose = this._shouldCloseArgsStream({
686
- toolName: content.toolName,
687
739
  argsText: content.argsText,
688
740
  hasResult,
741
+ clientOwned: entry.clientOwned,
689
742
  });
690
743
  if (shouldClose) entry.controller.argsText.close();
691
744
  entry.argsText = content.argsText;
@@ -717,9 +770,9 @@ export class ToolInvocationTracker {
717
770
  const delta = content.argsText.slice(entry.argsText.length);
718
771
  entry.controller.argsText.append(delta);
719
772
  const shouldClose = this._shouldCloseArgsStream({
720
- toolName: content.toolName,
721
773
  argsText: content.argsText,
722
774
  hasResult,
775
+ clientOwned: entry.clientOwned,
723
776
  });
724
777
  if (shouldClose) entry.controller.argsText.close();
725
778
  entry.argsText = content.argsText;
@@ -732,9 +785,9 @@ export class ToolInvocationTracker {
732
785
  // gates on the streamed content; a divergent snapshot (A.2) can be
733
786
  // complete while the controller still holds an incomplete stale prefix.
734
787
  const shouldClose = this._shouldCloseArgsStream({
735
- toolName: content.toolName,
736
788
  argsText: entry.argsText,
737
789
  hasResult,
790
+ clientOwned: entry.clientOwned,
738
791
  });
739
792
  if (shouldClose) {
740
793
  entry.controller.argsText.close();
@@ -746,81 +799,81 @@ export class ToolInvocationTracker {
746
799
  private _processMessages(messages: readonly ThreadMessage[]): void {
747
800
  const isRestore = this._pendingRestore;
748
801
 
749
- for (const message of messages) {
750
- if (!message || !Array.isArray((message as ThreadMessage).content)) {
802
+ for (const { part: content } of walkToolCallTree(messages)) {
803
+ const existing = this._entries.get(content.toolCallId);
804
+
805
+ if (isRestore) {
806
+ // Don't overwrite an already-active entry (e.g. live tool-call
807
+ // observed before this restore snapshot landed). Restore can
808
+ // only seed entries the runtime has never seen.
809
+ if (!existing?.controller) {
810
+ this._entries.set(content.toolCallId, {
811
+ toolName: content.toolName,
812
+ argsText: content.argsText,
813
+ hasResult: content.result !== undefined,
814
+ });
815
+ }
751
816
  continue;
752
817
  }
753
- for (const content of message.content as readonly ThreadMessage["content"][number][]) {
754
- if (!content || content.type !== "tool-call") continue;
755
-
756
- const existing = this._entries.get(content.toolCallId);
757
-
758
- if (isRestore) {
759
- // Don't overwrite an already-active entry (e.g. live tool-call
760
- // observed before this restore snapshot landed). Restore can
761
- // only seed entries the runtime has never seen.
762
- if (!existing?.controller) {
763
- this._entries.set(content.toolCallId, {
764
- toolName: content.toolName,
765
- argsText: content.argsText,
766
- hasResult: content.result !== undefined,
767
- });
768
- }
769
- if (content.messages) this._processMessages(content.messages);
770
- continue;
771
- }
772
818
 
773
- // Live snapshot.
774
- let entry = existing;
775
-
776
- if (entry && !entry.controller) {
777
- // Restored entry observed in a live snapshot. Promote if its
778
- // signature has changed; otherwise treat as still-historical.
779
- const signatureChanged =
780
- content.argsText !== entry.argsText ||
781
- (content.result !== undefined) !== entry.hasResult;
782
- if (!signatureChanged) {
783
- if (content.messages) this._processMessages(content.messages);
784
- continue;
785
- }
786
- this._entries.delete(content.toolCallId);
787
- entry = undefined;
788
- }
819
+ // Live snapshot.
820
+ let entry = existing;
821
+
822
+ // A discarded id is remembered only until the call is answered, which
823
+ // bounds the set to the open calls of a discarded turn.
824
+ if (content.result !== undefined)
825
+ this._discardedToolCallIds.delete(content.toolCallId);
826
+
827
+ if (entry && !entry.controller) {
828
+ // Restored entry observed in a live snapshot. Promote if its
829
+ // signature has changed; otherwise treat as still-historical.
830
+ const signatureChanged =
831
+ content.argsText !== entry.argsText ||
832
+ (content.result !== undefined) !== entry.hasResult;
833
+ if (!signatureChanged) continue;
834
+ this._entries.delete(content.toolCallId);
835
+ entry = undefined;
836
+ }
789
837
 
790
- if (!entry) {
791
- entry = this._startActiveEntry(
792
- content.toolCallId,
793
- content.toolName,
794
- content.result !== undefined,
795
- );
796
- }
838
+ if (!entry) {
839
+ const ownership = this._isClientToolCall?.(content);
840
+ const providerOwned =
841
+ content.result === undefined && ownership === false;
842
+ if (providerOwned)
843
+ this._warnProviderOwnedSkip(content.toolName, content.toolCallId);
844
+ entry = this._startActiveEntry(
845
+ content.toolCallId,
846
+ content.toolName,
847
+ content.result !== undefined ||
848
+ providerOwned ||
849
+ this._discardedToolCallIds.has(content.toolCallId),
850
+ ownership === true,
851
+ );
852
+ }
797
853
 
798
- if (content.approval !== undefined) entry.skipExecute = true;
799
-
800
- this._processArgsText(entry, content);
801
-
802
- if (content.result !== undefined && !entry.hasResult) {
803
- // `entry` is in active phase from this point — either just
804
- // created by `_startActiveEntry`, or pre-existing with a live
805
- // controller. Narrow once instead of asserting at every use.
806
- const { controller: activeController } = entry;
807
- if (!activeController) continue;
808
- entry.hasResult = true;
809
- entry.argsComplete = true;
810
- activeController.setResponse(
811
- new ToolResponse({
812
- result: content.result as ReadonlyJSONValue,
813
- artifact: content.artifact as ReadonlyJSONValue | undefined,
814
- isError: content.isError,
815
- ...(content.modelContent !== undefined
816
- ? { modelContent: content.modelContent }
817
- : {}),
818
- }),
819
- );
820
- activeController.close();
821
- }
854
+ if (content.approval !== undefined) entry.skipExecute = true;
822
855
 
823
- if (content.messages) this._processMessages(content.messages);
856
+ this._processArgsText(entry, content);
857
+
858
+ if (content.result !== undefined && !entry.hasResult) {
859
+ // `entry` is in active phase from this point — either just
860
+ // created by `_startActiveEntry`, or pre-existing with a live
861
+ // controller. Narrow once instead of asserting at every use.
862
+ const { controller: activeController } = entry;
863
+ if (!activeController) continue;
864
+ entry.hasResult = true;
865
+ entry.argsComplete = true;
866
+ activeController.setResponse(
867
+ new ToolResponse({
868
+ result: content.result as ReadonlyJSONValue,
869
+ artifact: content.artifact as ReadonlyJSONValue | undefined,
870
+ isError: content.isError,
871
+ ...(content.modelContent !== undefined
872
+ ? { modelContent: content.modelContent }
873
+ : {}),
874
+ }),
875
+ );
876
+ activeController.close();
824
877
  }
825
878
  }
826
879
  }
@@ -49,6 +49,7 @@ import {
49
49
  drainAttachmentAdd,
50
50
  } from "../../runtime/utils/attachment-add-operations";
51
51
  import { toMessagePartStatus } from "../../utils/normalizePartStatus";
52
+ import { generateId } from "../../utils/id";
52
53
  import { ModelContext } from "./model-context-client";
53
54
  import { ThreadSuggestions } from "./suggestions";
54
55
  import { Tools } from "../../react/client/Tools";
@@ -118,7 +119,9 @@ export type ExternalThreadProps = {
118
119
  /** Branch adapter for runtimes that track sibling variants of messages. */
119
120
  branches?: ExternalThreadBranchAdapter;
120
121
  /** Callback for tool approval decisions. Absent: responding to an approval throws a capability error. */
121
- onRespondToToolApproval?: (options: RespondToToolApprovalOptions) => void;
122
+ onRespondToToolApproval?: (
123
+ options: RespondToToolApprovalOptions,
124
+ ) => void | Promise<void>;
122
125
  };
123
126
 
124
127
  type MessageClientProps = {
@@ -130,7 +133,7 @@ type MessageClientProps = {
130
133
  queue?: ExternalThreadQueueAdapter | undefined;
131
134
  branches?: ExternalThreadBranchAdapter | undefined;
132
135
  onRespondToToolApproval?:
133
- | ((options: RespondToToolApprovalOptions) => void)
136
+ | ((options: RespondToToolApprovalOptions) => void | Promise<void>)
134
137
  | undefined;
135
138
  onAddToolResult?: ((options: AddToolResultOptions) => void) | undefined;
136
139
  onResumeToolCall?: ((options: ResumeToolCallOptions) => void) | undefined;
@@ -313,7 +316,7 @@ type PartResourceProps = {
313
316
  status: ToolCallMessagePartStatus;
314
317
  messageId: string;
315
318
  onRespondToToolApproval?:
316
- | ((options: RespondToToolApprovalOptions) => void)
319
+ | ((options: RespondToToolApprovalOptions) => void | Promise<void>)
317
320
  | undefined;
318
321
  onAddToolResult?: ((options: AddToolResultOptions) => void) | undefined;
319
322
  onResumeToolCall?: ((options: ResumeToolCallOptions) => void) | undefined;
@@ -382,9 +385,12 @@ const usePartResource = ({
382
385
  )
383
386
  throw new Error("Tool call has no pending approval");
384
387
 
385
- onRespondToToolApproval(
386
- resolveToolApprovalResponse(part.approval, response),
387
- );
388
+ const options = resolveToolApprovalResponse(part.approval, response);
389
+ try {
390
+ return Promise.resolve(onRespondToToolApproval(options));
391
+ } catch (error) {
392
+ return Promise.reject(error);
393
+ }
388
394
  },
389
395
  };
390
396
  };
@@ -632,7 +638,7 @@ const useComposerClientResource = ({
632
638
  }
633
639
  } else if (!isCreateAttachment(fileOrAttachment)) {
634
640
  const newAttachment: Attachment = {
635
- id: Math.random().toString(36).substring(7),
641
+ id: generateId(),
636
642
  type: "file",
637
643
  name: fileOrAttachment.name,
638
644
  contentType: fileOrAttachment.type,
@@ -643,7 +649,7 @@ const useComposerClientResource = ({
643
649
  setAttachments((prev) => [...prev, newAttachment]);
644
650
  } else {
645
651
  const newAttachment: Attachment = {
646
- id: fileOrAttachment.id ?? Math.random().toString(36).substring(7),
652
+ id: fileOrAttachment.id ?? generateId(),
647
653
  type: fileOrAttachment.type ?? "document",
648
654
  name: fileOrAttachment.name,
649
655
  contentType: fileOrAttachment.contentType,
@@ -1,9 +1,68 @@
1
1
  import { useState } from "react";
2
2
  import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
3
3
  import { describe, expect, it } from "vitest";
4
- import { Suggestions, type SuggestionConfig } from "./suggestions";
4
+ import type { ThreadSuggestion } from "../../runtime/interfaces/thread-runtime-core";
5
+ import {
6
+ Suggestions,
7
+ ThreadSuggestions,
8
+ type SuggestionConfig,
9
+ } from "./suggestions";
5
10
 
6
11
  describe("Suggestions", () => {
12
+ it("keeps equal inline string suggestions stable across owner renders", () => {
13
+ let rerender!: () => void;
14
+ const root = createTapRoot(function SuggestionsRoot() {
15
+ const [, setTick] = useState(0);
16
+ rerender = () => setTick((tick) => tick + 1);
17
+ return useResource(Suggestions(["account-a", "account-b"]));
18
+ });
19
+
20
+ try {
21
+ const firstState = root.getValue().getState();
22
+ const firstSuggestion = root
23
+ .getValue()
24
+ .suggestion({ index: 0 })
25
+ .getState();
26
+
27
+ flushTapSync(rerender);
28
+
29
+ expect(root.getValue().getState()).toBe(firstState);
30
+ expect(root.getValue().suggestion({ index: 0 }).getState()).toBe(
31
+ firstSuggestion,
32
+ );
33
+ } finally {
34
+ root.unmount();
35
+ }
36
+ });
37
+
38
+ it("keeps equal inline object suggestions stable across owner renders", () => {
39
+ let rerender!: () => void;
40
+ const root = createTapRoot(function SuggestionsRoot() {
41
+ const [, setTick] = useState(0);
42
+ rerender = () => setTick((tick) => tick + 1);
43
+ return useResource(
44
+ Suggestions([{ title: "Title", label: "Label", prompt: "Prompt" }]),
45
+ );
46
+ });
47
+
48
+ try {
49
+ const firstState = root.getValue().getState();
50
+ const firstSuggestion = root
51
+ .getValue()
52
+ .suggestion({ index: 0 })
53
+ .getState();
54
+
55
+ flushTapSync(rerender);
56
+
57
+ expect(root.getValue().getState()).toBe(firstState);
58
+ expect(root.getValue().suggestion({ index: 0 }).getState()).toBe(
59
+ firstSuggestion,
60
+ );
61
+ } finally {
62
+ root.unmount();
63
+ }
64
+ });
65
+
7
66
  it("updates when the configured suggestions change", () => {
8
67
  let setSuggestions!: (suggestions: SuggestionConfig[]) => void;
9
68
  const root = createTapRoot(function SuggestionsRoot() {
@@ -28,4 +87,99 @@ describe("Suggestions", () => {
28
87
  root.unmount();
29
88
  }
30
89
  });
90
+
91
+ it("reuses unchanged suggestion state when one entry changes", () => {
92
+ let setSuggestions!: (suggestions: SuggestionConfig[]) => void;
93
+ const root = createTapRoot(function SuggestionsRoot() {
94
+ const [suggestions, setValue] = useState<SuggestionConfig[]>([
95
+ { title: "Title A", label: "Label A", prompt: "Prompt A" },
96
+ { title: "Title B", label: "Label B", prompt: "Prompt B" },
97
+ ]);
98
+ setSuggestions = setValue;
99
+ return useResource(Suggestions(suggestions));
100
+ });
101
+
102
+ try {
103
+ const firstState = root.getValue().getState();
104
+ const unchangedSuggestion = root
105
+ .getValue()
106
+ .suggestion({ index: 1 })
107
+ .getState();
108
+
109
+ flushTapSync(() =>
110
+ setSuggestions([
111
+ { title: "Changed", label: "Label A", prompt: "Prompt A" },
112
+ { title: "Title B", label: "Label B", prompt: "Prompt B" },
113
+ ]),
114
+ );
115
+
116
+ expect(root.getValue().getState()).not.toBe(firstState);
117
+ expect(root.getValue().getState().suggestions[0]?.title).toBe("Changed");
118
+ expect(root.getValue().suggestion({ index: 1 }).getState()).toBe(
119
+ unchangedSuggestion,
120
+ );
121
+ } finally {
122
+ root.unmount();
123
+ }
124
+ });
125
+ });
126
+
127
+ describe("ThreadSuggestions", () => {
128
+ it("keeps equal runtime suggestions stable across owner renders", () => {
129
+ let rerender!: () => void;
130
+ const root = createTapRoot(function ThreadSuggestionsRoot() {
131
+ const [, setTick] = useState(0);
132
+ rerender = () => setTick((tick) => tick + 1);
133
+ return useResource(ThreadSuggestions([{ prompt: "Prompt" }]));
134
+ });
135
+
136
+ try {
137
+ const firstState = root.getValue().getState();
138
+ const firstSuggestion = root
139
+ .getValue()
140
+ .suggestion({ index: 0 })
141
+ .getState();
142
+
143
+ flushTapSync(rerender);
144
+
145
+ expect(root.getValue().getState()).toBe(firstState);
146
+ expect(root.getValue().suggestion({ index: 0 }).getState()).toBe(
147
+ firstSuggestion,
148
+ );
149
+ } finally {
150
+ root.unmount();
151
+ }
152
+ });
153
+
154
+ it("reuses unchanged suggestion state when one entry changes", () => {
155
+ let setSuggestions!: (suggestions: ThreadSuggestion[]) => void;
156
+ const root = createTapRoot(function ThreadSuggestionsRoot() {
157
+ const [suggestions, setValue] = useState<ThreadSuggestion[]>([
158
+ { prompt: "Prompt A" },
159
+ { prompt: "Prompt B" },
160
+ ]);
161
+ setSuggestions = setValue;
162
+ return useResource(ThreadSuggestions(suggestions));
163
+ });
164
+
165
+ try {
166
+ const firstState = root.getValue().getState();
167
+ const unchangedSuggestion = root
168
+ .getValue()
169
+ .suggestion({ index: 1 })
170
+ .getState();
171
+
172
+ flushTapSync(() =>
173
+ setSuggestions([{ prompt: "Changed" }, { prompt: "Prompt B" }]),
174
+ );
175
+
176
+ expect(root.getValue().getState()).not.toBe(firstState);
177
+ expect(root.getValue().getState().suggestions[0]?.prompt).toBe("Changed");
178
+ expect(root.getValue().suggestion({ index: 1 }).getState()).toBe(
179
+ unchangedSuggestion,
180
+ );
181
+ } finally {
182
+ root.unmount();
183
+ }
184
+ });
31
185
  });