@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
@@ -427,6 +427,27 @@ describe("LocalThreadRuntimeCore state", () => {
427
427
  });
428
428
 
429
429
  describe("LocalThreadRuntimeCore tool approvals", () => {
430
+ it("emits a decision with its message and tool call IDs", async () => {
431
+ const { thread } = createApprovalThread(
432
+ toolCallResult("send_email", { id: "a1" }),
433
+ );
434
+ const answered = vi.fn();
435
+ thread.unstable_on("toolApprovalAnswered", answered);
436
+
437
+ await thread.append(userMessage("send an email"));
438
+ await flush();
439
+ const messageId = thread.messages.at(-1)?.id;
440
+
441
+ await thread.respondToToolApproval({ approvalId: "a1", approved: false });
442
+
443
+ expect(answered).toHaveBeenCalledWith({
444
+ messageId,
445
+ toolCallId: "call-send_email",
446
+ toolName: "send_email",
447
+ approved: false,
448
+ });
449
+ });
450
+
430
451
  it("pauses the run while an approval is pending, even for unlisted tools", async () => {
431
452
  const { thread, runs } = createApprovalThread(
432
453
  toolCallResult("deploy", { id: "a1" }),
@@ -342,6 +342,8 @@ export class LocalThreadRuntimeCore
342
342
  }
343
343
 
344
344
  public async append(message: AppendMessage): Promise<void> {
345
+ if (this._isVoiceMessage(message.sourceId))
346
+ throw new Error("Voice transcript messages cannot be edited");
345
347
  const isTail = message.parentId === (this.messages.at(-1)?.id ?? null);
346
348
  const willRun = message.startRun ?? message.role === "user";
347
349
  if (this._queue && willRun && isTail) {
@@ -488,10 +490,12 @@ export class LocalThreadRuntimeCore
488
490
  }
489
491
 
490
492
  public async startRun(
491
- { parentId, runConfig }: StartRunConfig,
493
+ { parentId, sourceId, runConfig }: StartRunConfig,
492
494
  runCallback?: ChatModelAdapter["run"],
493
495
  ): Promise<void> {
494
496
  this.ensureInitialized();
497
+ if (this._isVoiceMessage(sourceId))
498
+ throw new Error("Voice transcript messages cannot be reloaded");
495
499
 
496
500
  // add assistant message
497
501
  const id = generateId();
@@ -961,6 +965,12 @@ export class LocalThreadRuntimeCore
961
965
  const { parentId } = this.repository.getMessage(message.id);
962
966
  this.repository.addOrUpdateMessage(parentId, message);
963
967
  this._notifySubscribers();
968
+ this._notifyToolApprovalAnswered(
969
+ message.id,
970
+ target.toolCallId,
971
+ target.toolName,
972
+ approved,
973
+ );
964
974
 
965
975
  if (
966
976
  this.repository.headId === message.id &&
@@ -72,9 +72,7 @@ If the tool also had a frontend `execute`, the executor is short-circuited
72
72
  via the entry's per-execution skip marker. Single fire.
73
73
 
74
74
  ### A.6. Previously-resolved tool's `result` is replaced
75
- Silently ignored — `entry.hasResult` short-circuits both the
76
- re-`setResponse` path and the downstream result-chunk handler. The host
77
- sees only the first result.
75
+ Silently ignored. A restored entry with a result remains historical even when a live snapshot reserializes its `argsText` or replaces its result. For active entries, `entry.hasResult` short-circuits both the re-`setResponse` path and the downstream result-chunk handler. The host sees only the first result.
78
76
 
79
77
  ### A.7. Previously-resolved tool loses its `result` (back to undefined)
80
78
  Silently ignored. The entry stays in the resolved phase internally.
@@ -213,9 +211,7 @@ Silently kept as restored. Recursion into `content.messages` still
213
211
  happens so any nested live tool calls are processed.
214
212
 
215
213
  ### C.3. Restored entry observed in a live snapshot, signature changed
216
- The restored entry is deleted and a new active entry starts via
217
- `_startActiveEntry`. This is PR #4057's promotion path. `streamCall`
218
- fires once — its first and only fire for this `toolCallId`.
214
+ A restored entry with a result remains historical regardless of later `argsText` or result changes. An unresolved restored entry is promoted only when a result lands or its `argsText` changes. Complete `argsText` values that parse to equivalent JSON are unchanged. Promotion deletes the restored entry and starts a new active entry via `_startActiveEntry`. `streamCall` fires once, its first and only fire for this `toolCallId`.
219
215
 
220
216
  ### C.4. `isLoading` transitions `true → false` while messages are stable
221
217
  The next `setState` call sees `isLoading === false` and processes
@@ -1465,6 +1465,139 @@ describe("ToolInvocationTracker", () => {
1465
1465
  expect(onResult).not.toHaveBeenCalled();
1466
1466
  });
1467
1467
 
1468
+ it("keeps a resolved restored tool call historical when its snapshot is reserialized", async () => {
1469
+ const execute = vi.fn(async () => ({ forecast: "ok" }));
1470
+ const streamCall = vi.fn();
1471
+ const getTools = () => ({
1472
+ weatherSearch: {
1473
+ parameters: { type: "object", properties: {} },
1474
+ execute,
1475
+ streamCall,
1476
+ } satisfies Tool,
1477
+ });
1478
+ const onResult = vi.fn();
1479
+ const tracker = new ToolInvocationTracker(getTools, {
1480
+ onResult,
1481
+ onStatusesChange: () => {},
1482
+ });
1483
+
1484
+ tracker.setState(
1485
+ createState([
1486
+ createAssistantMessage(
1487
+ '{"query":"London","page":1}',
1488
+ { query: "London", page: 1 },
1489
+ { result: { source: "history", revision: 1 } },
1490
+ ),
1491
+ ]),
1492
+ );
1493
+
1494
+ tracker.setState(
1495
+ createState([
1496
+ createAssistantMessage(
1497
+ '{ "page": 1, "query": "London" }',
1498
+ { query: "London", page: 1 },
1499
+ { result: { source: "history", revision: 2 } },
1500
+ ),
1501
+ ]),
1502
+ );
1503
+
1504
+ await new Promise((r) => setTimeout(r, 0));
1505
+
1506
+ expect(streamCall).not.toHaveBeenCalled();
1507
+ expect(execute).not.toHaveBeenCalled();
1508
+ expect(onResult).not.toHaveBeenCalled();
1509
+ });
1510
+
1511
+ it("promotes unresolved restored tool calls only for real args changes or a landed result", async () => {
1512
+ const equivalentStreamCall = vi.fn();
1513
+ const growingStreamCall = vi.fn();
1514
+ const resolvedStreamCall = vi.fn();
1515
+ const getTools = () => ({
1516
+ equivalentTool: {
1517
+ parameters: { type: "object", properties: {} },
1518
+ streamCall: equivalentStreamCall,
1519
+ } satisfies Tool,
1520
+ growingTool: {
1521
+ parameters: { type: "object", properties: {} },
1522
+ streamCall: growingStreamCall,
1523
+ } satisfies Tool,
1524
+ resolvedTool: {
1525
+ parameters: { type: "object", properties: {} },
1526
+ streamCall: resolvedStreamCall,
1527
+ } satisfies Tool,
1528
+ });
1529
+ const tracker = new ToolInvocationTracker(getTools, {
1530
+ onResult: vi.fn(),
1531
+ onStatusesChange: () => {},
1532
+ });
1533
+
1534
+ tracker.setState(
1535
+ createState([
1536
+ createAssistantMessage(
1537
+ '{"a":1,"b":2}',
1538
+ { a: 1, b: 2 },
1539
+ {
1540
+ toolCallId: "equivalent",
1541
+ toolName: "equivalentTool",
1542
+ },
1543
+ ),
1544
+ createAssistantMessage(
1545
+ '{"query":"Lon',
1546
+ { query: "Lon" },
1547
+ {
1548
+ toolCallId: "growing",
1549
+ toolName: "growingTool",
1550
+ },
1551
+ ),
1552
+ createAssistantMessage(
1553
+ '{"city":"London"}',
1554
+ { city: "London" },
1555
+ {
1556
+ toolCallId: "resolved",
1557
+ toolName: "resolvedTool",
1558
+ },
1559
+ ),
1560
+ ]),
1561
+ );
1562
+
1563
+ tracker.setState(
1564
+ createState([
1565
+ createAssistantMessage(
1566
+ '{ "b": 2, "a": 1 }',
1567
+ { a: 1, b: 2 },
1568
+ {
1569
+ toolCallId: "equivalent",
1570
+ toolName: "equivalentTool",
1571
+ },
1572
+ ),
1573
+ createAssistantMessage(
1574
+ '{"query":"London"}',
1575
+ { query: "London" },
1576
+ {
1577
+ toolCallId: "growing",
1578
+ toolName: "growingTool",
1579
+ },
1580
+ ),
1581
+ createAssistantMessage(
1582
+ '{"city":"London"}',
1583
+ { city: "London" },
1584
+ {
1585
+ toolCallId: "resolved",
1586
+ toolName: "resolvedTool",
1587
+ result: { source: "history" },
1588
+ },
1589
+ ),
1590
+ ]),
1591
+ );
1592
+
1593
+ await waitFor(() => {
1594
+ expect(growingStreamCall).toHaveBeenCalledTimes(1);
1595
+ expect(resolvedStreamCall).toHaveBeenCalledTimes(1);
1596
+ });
1597
+
1598
+ expect(equivalentStreamCall).not.toHaveBeenCalled();
1599
+ });
1600
+
1468
1601
  it("promotes an in-progress tool call from the initial snapshot when it changes", async () => {
1469
1602
  const execute = vi.fn(async () => ({ forecast: "ok" }));
1470
1603
  const streamCall = vi.fn();
@@ -825,12 +825,16 @@ export class ToolInvocationTracker {
825
825
  this._discardedToolCallIds.delete(content.toolCallId);
826
826
 
827
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;
828
+ // A restored entry with a result remains historical.
829
+ if (entry.hasResult) continue;
830
+ const argsChanged =
831
+ content.argsText !== entry.argsText &&
832
+ !(
833
+ isArgsTextComplete(entry.argsText) &&
834
+ isArgsTextComplete(content.argsText) &&
835
+ isEquivalentCompleteArgsText(entry.argsText, content.argsText)
836
+ );
837
+ if (!argsChanged && content.result === undefined) continue;
834
838
  this._entries.delete(content.toolCallId);
835
839
  entry = undefined;
836
840
  }
@@ -55,6 +55,7 @@ import { toMessagePartStatus } from "../../utils/normalizePartStatus";
55
55
  import { generateId } from "../../utils/id";
56
56
  import { ModelContext } from "./model-context-client";
57
57
  import { ThreadSuggestions } from "./suggestions";
58
+ import { createTaskDeriver, getTaskKey, TaskClient } from "./thread-tasks";
58
59
  import { Tools } from "../../react/client/Tools";
59
60
  import { DataRenderers } from "../../react/client/DataRenderers";
60
61
  import { SingleThreadList } from "./single-thread-list";
@@ -569,7 +570,7 @@ const useComposerClientResource = ({
569
570
  await Promise.all(
570
571
  removed
571
572
  .filter((a) => a.status.type !== "complete")
572
- .map((a) => attachmentAdapter.remove(a)),
573
+ .map(async (a) => attachmentAdapter.remove(a)),
573
574
  );
574
575
  };
575
576
 
@@ -1025,6 +1026,14 @@ const useExternalThread = ({
1025
1026
  }),
1026
1027
  );
1027
1028
 
1029
+ const taskDeriver = useMemo(() => createTaskDeriver(), []);
1030
+ const tasks = useMemo(() => taskDeriver(messages), [taskDeriver, messages]);
1031
+ const taskClients = useClientLookup(
1032
+ tasks.map((task) =>
1033
+ withKey(getTaskKey(task), TaskClient({ task }), [task]),
1034
+ ),
1035
+ );
1036
+
1028
1037
  const handleCancelRun = () => {
1029
1038
  // Nothing is aborted without a handler, so pausing the queue would hold
1030
1039
  // the pending items against a run that keeps going.
@@ -1109,6 +1118,7 @@ const useExternalThread = ({
1109
1118
  queue: hasQueue,
1110
1119
  },
1111
1120
  messages: messageStates,
1121
+ tasks,
1112
1122
  state: threadState ?? {},
1113
1123
  suggestions: EMPTY_SUGGESTIONS,
1114
1124
  extras,
@@ -1134,12 +1144,20 @@ const useExternalThread = ({
1134
1144
  speech,
1135
1145
  messageClients.state,
1136
1146
  composerClient.state,
1147
+ tasks,
1137
1148
  ]);
1138
1149
 
1139
1150
  return {
1140
1151
  getState: () => state,
1141
1152
  composer: () => composerClient.methods,
1142
1153
  suggestions: () => suggestionsClient.methods,
1154
+ task: (selector) => {
1155
+ if ("id" in selector) {
1156
+ const task = tasks.find((candidate) => candidate.id === selector.id);
1157
+ return taskClients.get({ key: task ? getTaskKey(task) : selector.id });
1158
+ }
1159
+ return taskClients.get(selector);
1160
+ },
1143
1161
  append: (message) => {
1144
1162
  const appendMessage: AppendMessage =
1145
1163
  typeof message === "string"
@@ -0,0 +1,274 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ThreadMessage, ToolCallMessagePart } from "../../types/message";
3
+ import { createTaskDeriver, getTaskKey } from "./thread-tasks";
4
+
5
+ const assistantMessage = (
6
+ id: string,
7
+ status:
8
+ | { type: "running" }
9
+ | { type: "complete"; reason: "stop" }
10
+ | { type: "incomplete"; reason: "error"; error: unknown },
11
+ content:
12
+ | readonly ToolCallMessagePart[]
13
+ | readonly { type: "text"; text: string }[],
14
+ ) =>
15
+ ({
16
+ id,
17
+ role: "assistant",
18
+ createdAt: new Date(0),
19
+ content,
20
+ status,
21
+ metadata: {
22
+ unstable_state: {},
23
+ unstable_annotations: [],
24
+ unstable_data: [],
25
+ steps: [],
26
+ custom: {},
27
+ },
28
+ }) as unknown as ThreadMessage;
29
+
30
+ const toolCall = (
31
+ id: string,
32
+ messages?: readonly ThreadMessage[],
33
+ result?: unknown,
34
+ ) =>
35
+ ({
36
+ type: "tool-call",
37
+ toolCallId: id,
38
+ toolName: "delegate",
39
+ args: { id },
40
+ argsText: JSON.stringify({ id }),
41
+ ...(messages !== undefined ? { messages } : undefined),
42
+ ...(result !== undefined ? { result } : undefined),
43
+ }) as ToolCallMessagePart;
44
+
45
+ describe("createTaskDeriver", () => {
46
+ it("derives top-level and nested tasks in document order", () => {
47
+ const nestedTask = toolCall("nested-1", []);
48
+ const nestedMessage = assistantMessage(
49
+ "nested-message",
50
+ { type: "running" },
51
+ [nestedTask],
52
+ );
53
+ const topLevelTask = toolCall("top-level-1", [nestedMessage]);
54
+ const tasks = createTaskDeriver()([
55
+ assistantMessage("outer-message", { type: "running" }, [topLevelTask]),
56
+ ]);
57
+
58
+ expect(tasks.map((task) => task.id)).toEqual(["top-level-1", "nested-1"]);
59
+ expect(tasks[0]).toMatchObject({
60
+ depth: 0,
61
+ parentTaskId: null,
62
+ messageId: "outer-message",
63
+ });
64
+ expect(tasks[1]).toMatchObject({
65
+ depth: 1,
66
+ parentTaskId: "top-level-1",
67
+ messageId: "nested-message",
68
+ });
69
+ });
70
+
71
+ it("uses normalized tool call statuses", () => {
72
+ const tasks = createTaskDeriver()([
73
+ assistantMessage("outer-message", { type: "running" }, [
74
+ toolCall("pending", []),
75
+ toolCall("complete", [], "done"),
76
+ ]),
77
+ ]);
78
+
79
+ expect(tasks.map((task) => task.status)).toEqual([
80
+ { type: "running" },
81
+ { type: "complete" },
82
+ ]);
83
+ });
84
+
85
+ it("excludes tool calls without nested messages", () => {
86
+ const tasks = createTaskDeriver()([
87
+ assistantMessage("outer-message", { type: "running" }, [
88
+ toolCall("not-a-task"),
89
+ toolCall("task", []),
90
+ ]),
91
+ ]);
92
+
93
+ expect(tasks.map((task) => task.id)).toEqual(["task"]);
94
+ });
95
+
96
+ it("preserves the array identity when an unrelated sibling changes", () => {
97
+ const task = toolCall("delegate-1", []);
98
+ const derive = createTaskDeriver();
99
+ const initial = derive([
100
+ assistantMessage("outer-message", { type: "running" }, [
101
+ { type: "text", text: "one" },
102
+ task,
103
+ ] as unknown as readonly ToolCallMessagePart[]),
104
+ ]);
105
+ const updated = derive([
106
+ assistantMessage("outer-message", { type: "running" }, [
107
+ { type: "text", text: "two" },
108
+ task,
109
+ ] as unknown as readonly ToolCallMessagePart[]),
110
+ ]);
111
+
112
+ expect(updated).toBe(initial);
113
+ });
114
+
115
+ it("creates a fresh entry and array when a nested status changes", () => {
116
+ const nestedTask = toolCall("nested-1", []);
117
+ const derive = createTaskDeriver();
118
+ const initial = derive([
119
+ assistantMessage("outer-message", { type: "running" }, [
120
+ toolCall("top-level-1", [
121
+ assistantMessage("nested-message", { type: "running" }, [nestedTask]),
122
+ ]),
123
+ ]),
124
+ ]);
125
+ const updated = derive([
126
+ assistantMessage("outer-message", { type: "running" }, [
127
+ toolCall("top-level-1", [
128
+ assistantMessage(
129
+ "nested-message",
130
+ { type: "complete", reason: "stop" },
131
+ [nestedTask],
132
+ ),
133
+ ]),
134
+ ]),
135
+ ]);
136
+
137
+ expect(updated).not.toBe(initial);
138
+ expect(updated[1]).not.toBe(initial[1]);
139
+ expect(updated[1]?.status).toMatchObject({ type: "complete" });
140
+ });
141
+
142
+ it("creates a fresh entry when an incomplete status changes its error", () => {
143
+ const task = toolCall("failing", []);
144
+ const derive = createTaskDeriver();
145
+ const initial = derive([
146
+ assistantMessage(
147
+ "outer-message",
148
+ { type: "incomplete", reason: "error", error: "first" },
149
+ [task],
150
+ ),
151
+ ]);
152
+ const updated = derive([
153
+ assistantMessage(
154
+ "outer-message",
155
+ { type: "incomplete", reason: "error", error: "second" },
156
+ [task],
157
+ ),
158
+ ]);
159
+
160
+ expect(updated).not.toBe(initial);
161
+ expect(updated[0]).not.toBe(initial[0]);
162
+ expect(updated[0]?.status).toMatchObject({
163
+ type: "incomplete",
164
+ reason: "error",
165
+ error: "second",
166
+ });
167
+ });
168
+
169
+ it("rebuilds an entry when an unchanged part moves to another message", () => {
170
+ const part = toolCall("moving", []);
171
+ const derive = createTaskDeriver();
172
+ const initial = derive([
173
+ assistantMessage("first-message", { type: "running" }, [part]),
174
+ ]);
175
+ const moved = derive([
176
+ assistantMessage("second-message", { type: "running" }, [part]),
177
+ ]);
178
+
179
+ expect(moved[0]).not.toBe(initial[0]);
180
+ expect(moved[0]?.messageId).toBe("second-message");
181
+ });
182
+
183
+ it("stops descending past the depth cap", () => {
184
+ let content: readonly ToolCallMessagePart[] = [toolCall("leaf", [])];
185
+ for (let level = 40; level > 0; level -= 1) {
186
+ content = [
187
+ toolCall(`level-${level}`, [
188
+ assistantMessage(`message-${level}`, { type: "running" }, content),
189
+ ]),
190
+ ];
191
+ }
192
+ const tasks = createTaskDeriver()([
193
+ assistantMessage("root", { type: "running" }, content),
194
+ ]);
195
+
196
+ expect(Math.max(...tasks.map((task) => task.depth))).toBe(32);
197
+ });
198
+
199
+ it("keeps the array identity when nested conversations repeat a toolCallId", () => {
200
+ const nested = (messageId: string) =>
201
+ assistantMessage(messageId, { type: "complete", reason: "stop" }, [
202
+ toolCall("call_1", [], "ok"),
203
+ ]);
204
+ const messages = [
205
+ assistantMessage("outer-message", { type: "complete", reason: "stop" }, [
206
+ toolCall("delegate-a", [nested("nested-a")], "ok"),
207
+ toolCall("delegate-b", [nested("nested-b")], "ok"),
208
+ ]),
209
+ ];
210
+ const derive = createTaskDeriver();
211
+ const first = derive(messages);
212
+ const second = derive(messages);
213
+
214
+ expect(first.map((task) => task.id)).toEqual([
215
+ "delegate-a",
216
+ "call_1",
217
+ "delegate-b",
218
+ "call_1",
219
+ ]);
220
+ expect(second).toBe(first);
221
+ });
222
+
223
+ it("keys tasks by document order when nested payloads repeat every id", () => {
224
+ const replay = () =>
225
+ assistantMessage("nested", { type: "complete", reason: "stop" }, [
226
+ toolCall("call_1", [], "ok"),
227
+ ]);
228
+ const messages = [
229
+ assistantMessage("outer-message", { type: "complete", reason: "stop" }, [
230
+ toolCall("delegate-a", [replay()], "ok"),
231
+ toolCall("delegate-b", [replay()], "ok"),
232
+ ]),
233
+ ];
234
+ const derive = createTaskDeriver();
235
+ const first = derive(messages);
236
+
237
+ expect(new Set(first.map(getTaskKey)).size).toBe(4);
238
+ expect(derive(messages)).toBe(first);
239
+ });
240
+
241
+ it("reads a nested message without a status as complete", () => {
242
+ const unnormalized = {
243
+ ...assistantMessage("nested", { type: "running" }, [
244
+ toolCall("call_1", []),
245
+ ]),
246
+ status: undefined,
247
+ } as unknown as ThreadMessage;
248
+ const [, nestedTask] = createTaskDeriver()([
249
+ assistantMessage("outer-message", { type: "running" }, [
250
+ toolCall("delegate", [unnormalized], "ok"),
251
+ ]),
252
+ ]);
253
+
254
+ expect(nestedTask?.status).toEqual({ type: "complete" });
255
+ });
256
+
257
+ it("preserves unchanged entries when a task is appended", () => {
258
+ const first = toolCall("first", []);
259
+ const derive = createTaskDeriver();
260
+ const initial = derive([
261
+ assistantMessage("outer-message", { type: "running" }, [first]),
262
+ ]);
263
+ const updated = derive([
264
+ assistantMessage("outer-message", { type: "running" }, [
265
+ first,
266
+ toolCall("second", []),
267
+ ]),
268
+ ]);
269
+
270
+ expect(updated).not.toBe(initial);
271
+ expect(updated[0]).toBe(initial[0]);
272
+ expect(updated.map((task) => task.id)).toEqual(["first", "second"]);
273
+ });
274
+ });