@assistant-ui/core 0.3.15 → 0.3.16

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 (251) hide show
  1. package/dist/internal.d.ts +7 -2
  2. package/dist/internal.js +8 -3
  3. package/dist/model-context/frame/host.d.ts.map +1 -1
  4. package/dist/model-context/frame/host.js +3 -2
  5. package/dist/model-context/frame/host.js.map +1 -1
  6. package/dist/model-context/frame/provider.d.ts +2 -0
  7. package/dist/model-context/frame/provider.d.ts.map +1 -1
  8. package/dist/model-context/frame/provider.js +88 -25
  9. package/dist/model-context/frame/provider.js.map +1 -1
  10. package/dist/model-context/interactable-composer-metadata.d.ts +1 -1
  11. package/dist/model-context/interactable-composer-metadata.js +1 -1
  12. package/dist/model-context/interactable-composer-metadata.js.map +1 -1
  13. package/dist/model-context/registry.d.ts +2 -2
  14. package/dist/model-context/registry.d.ts.map +1 -1
  15. package/dist/model-context/registry.js +9 -28
  16. package/dist/model-context/registry.js.map +1 -1
  17. package/dist/react/client/InMemoryThreadList.d.ts.map +1 -1
  18. package/dist/react/client/InMemoryThreadList.js +24 -20
  19. package/dist/react/client/InMemoryThreadList.js.map +1 -1
  20. package/dist/react/client/Interactables.d.ts.map +1 -1
  21. package/dist/react/client/Interactables.js +59 -177
  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 +23 -56
  25. package/dist/react/client/RemoteThreadList.js.map +1 -1
  26. package/dist/react/index.d.ts +2 -1
  27. package/dist/react/interactables-legacy/Interactables.d.ts.map +1 -1
  28. package/dist/react/interactables-legacy/Interactables.js +56 -174
  29. package/dist/react/interactables-legacy/Interactables.js.map +1 -1
  30. package/dist/react/interactables-shared/useInteractablePersistenceQueue.d.ts +24 -0
  31. package/dist/react/interactables-shared/useInteractablePersistenceQueue.d.ts.map +1 -0
  32. package/dist/react/interactables-shared/useInteractablePersistenceQueue.js +138 -0
  33. package/dist/react/interactables-shared/useInteractablePersistenceQueue.js.map +1 -0
  34. package/dist/react/primitives/composer/ComposerIf.js.map +1 -1
  35. package/dist/react/primitives/composer/ComposerQueue.d.ts +2 -1
  36. package/dist/react/primitives/composer/ComposerQueue.d.ts.map +1 -1
  37. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  38. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +1 -1
  39. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +1 -1
  40. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  41. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +23 -55
  42. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  43. package/dist/react/runtimes/assistant-transport/runManager.d.ts.map +1 -1
  44. package/dist/react/runtimes/assistant-transport/runManager.js +2 -12
  45. package/dist/react/runtimes/assistant-transport/runManager.js.map +1 -1
  46. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts +3 -10
  47. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.d.ts.map +1 -1
  48. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +20 -67
  49. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  50. package/dist/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.d.ts +12 -0
  51. package/dist/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.d.ts.map +1 -0
  52. package/dist/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.js +10 -0
  53. package/dist/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.js.map +1 -0
  54. package/dist/react/runtimes/cloud/useCloudThreadListRuntime.js.map +1 -1
  55. package/dist/react/runtimes/createMessageConverter.d.ts +2 -1
  56. package/dist/react/runtimes/createMessageConverter.d.ts.map +1 -1
  57. package/dist/react/runtimes/external-message-converter.d.ts +6 -25
  58. package/dist/react/runtimes/external-message-converter.d.ts.map +1 -1
  59. package/dist/react/runtimes/external-message-converter.js +33 -233
  60. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  61. package/dist/runtime/api/composer-runtime.d.ts +1 -1
  62. package/dist/runtime/api/composer-runtime.js.map +1 -1
  63. package/dist/runtime/api/thread-runtime.d.ts +1 -2
  64. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  65. package/dist/runtime/base/base-composer-runtime-core.d.ts +1 -1
  66. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  67. package/dist/runtime/base/base-composer-runtime-core.js +14 -29
  68. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  69. package/dist/runtime/base/base-thread-runtime-core.d.ts +2 -4
  70. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  71. package/dist/runtime/base/base-thread-runtime-core.js +3 -9
  72. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  73. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts +1 -1
  74. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  75. package/dist/runtime/base/default-thread-composer-runtime-core.js +1 -1
  76. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  77. package/dist/runtime/interfaces/composer-runtime-core.d.ts +1 -1
  78. package/dist/runtime/interfaces/thread-runtime-core.d.ts +1 -1
  79. package/dist/runtime/internal.d.ts +2 -1
  80. package/dist/runtime/internal.js +2 -1
  81. package/dist/runtime/queue/external-thread-queue-adapter.d.ts +1 -1
  82. package/dist/runtime/queue/message-queue.d.ts.map +1 -1
  83. package/dist/runtime/queue/message-queue.js +1 -1
  84. package/dist/runtime/queue/message-queue.js.map +1 -1
  85. package/dist/runtime/queue/queue-item.d.ts +12 -0
  86. package/dist/runtime/queue/queue-item.d.ts.map +1 -0
  87. package/dist/runtime/queue/queue-item.js +6 -0
  88. package/dist/runtime/queue/queue-item.js.map +1 -0
  89. package/dist/runtime/utils/abortable-thread-load.d.ts +15 -0
  90. package/dist/runtime/utils/abortable-thread-load.d.ts.map +1 -0
  91. package/dist/runtime/utils/abortable-thread-load.js +35 -0
  92. package/dist/runtime/utils/abortable-thread-load.js.map +1 -0
  93. package/dist/runtime/utils/attachment-add-operations.d.ts +18 -0
  94. package/dist/runtime/utils/attachment-add-operations.d.ts.map +1 -0
  95. package/dist/runtime/utils/attachment-add-operations.js +43 -0
  96. package/dist/runtime/utils/attachment-add-operations.js.map +1 -0
  97. package/dist/runtime/utils/auto-status.d.ts +6 -1
  98. package/dist/runtime/utils/auto-status.d.ts.map +1 -1
  99. package/dist/runtime/utils/auto-status.js +7 -1
  100. package/dist/runtime/utils/auto-status.js.map +1 -1
  101. package/dist/runtime/utils/external-message-conversion.d.ts +47 -0
  102. package/dist/runtime/utils/external-message-conversion.d.ts.map +1 -0
  103. package/dist/runtime/utils/external-message-conversion.js +221 -0
  104. package/dist/runtime/utils/external-message-conversion.js.map +1 -0
  105. package/dist/runtime/utils/external-store-message.d.ts +2 -1
  106. package/dist/runtime/utils/external-store-message.d.ts.map +1 -1
  107. package/dist/runtime/utils/external-store-message.js +2 -1
  108. package/dist/runtime/utils/external-store-message.js.map +1 -1
  109. package/dist/runtime/utils/message-repository-session.d.ts +30 -0
  110. package/dist/runtime/utils/message-repository-session.d.ts.map +1 -0
  111. package/dist/runtime/utils/message-repository-session.js +130 -0
  112. package/dist/runtime/utils/message-repository-session.js.map +1 -0
  113. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  114. package/dist/runtime/utils/message-repository.js +3 -4
  115. package/dist/runtime/utils/message-repository.js.map +1 -1
  116. package/dist/runtime/utils/pending-tool-calls.d.ts +20 -0
  117. package/dist/runtime/utils/pending-tool-calls.d.ts.map +1 -0
  118. package/dist/runtime/utils/pending-tool-calls.js +22 -0
  119. package/dist/runtime/utils/pending-tool-calls.js.map +1 -0
  120. package/dist/runtime/utils/thread-message-like.d.ts +2 -1
  121. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  122. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  123. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts +2 -5
  124. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  125. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js +3 -10
  126. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.js.map +1 -1
  127. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +19 -1
  128. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  129. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +122 -20
  130. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  131. package/dist/runtimes/local/local-thread-runtime-core.d.ts +3 -1
  132. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  133. package/dist/runtimes/local/local-thread-runtime-core.js +20 -2
  134. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  135. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts +12 -1
  136. package/dist/runtimes/remote-thread-list/remote-thread-state.d.ts.map +1 -1
  137. package/dist/runtimes/remote-thread-list/remote-thread-state.js +77 -1
  138. package/dist/runtimes/remote-thread-list/remote-thread-state.js.map +1 -1
  139. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  140. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +2 -0
  141. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  142. package/dist/store/clients/external-thread.d.ts.map +1 -1
  143. package/dist/store/clients/external-thread.js +74 -99
  144. package/dist/store/clients/external-thread.js.map +1 -1
  145. package/dist/store/clients/single-thread-list.d.ts.map +1 -1
  146. package/dist/store/clients/single-thread-list.js +1 -1
  147. package/dist/store/clients/single-thread-list.js.map +1 -1
  148. package/dist/store/clients/suggestions.d.ts.map +1 -1
  149. package/dist/store/clients/suggestions.js +16 -7
  150. package/dist/store/clients/suggestions.js.map +1 -1
  151. package/dist/store/index.d.ts +4 -2
  152. package/dist/store/scope-registration.d.ts +1 -1
  153. package/dist/store/scopes/composer.d.ts +2 -1
  154. package/dist/store/scopes/composer.d.ts.map +1 -1
  155. package/dist/store/scopes/message.d.ts +1 -1
  156. package/dist/store/scopes/queue-item.d.ts +2 -9
  157. package/dist/store/scopes/queue-item.d.ts.map +1 -1
  158. package/dist/store/scopes/queue-item.js +0 -6
  159. package/dist/types/message.d.ts +7 -3
  160. package/dist/types/message.d.ts.map +1 -1
  161. package/dist/types/message.js.map +1 -1
  162. package/dist/utils/data-url.d.ts +14 -1
  163. package/dist/utils/data-url.d.ts.map +1 -1
  164. package/dist/utils/data-url.js +13 -1
  165. package/dist/utils/data-url.js.map +1 -1
  166. package/dist/utils/invoke-user-callback.d.ts +5 -0
  167. package/dist/utils/invoke-user-callback.d.ts.map +1 -0
  168. package/dist/utils/invoke-user-callback.js +19 -0
  169. package/dist/utils/invoke-user-callback.js.map +1 -0
  170. package/dist/utils/normalizePartStatus.js.map +1 -1
  171. package/package.json +10 -10
  172. package/src/internal.ts +12 -0
  173. package/src/model-context/frame/host.test.ts +91 -7
  174. package/src/model-context/frame/host.ts +7 -2
  175. package/src/model-context/frame/provider.test.ts +267 -12
  176. package/src/model-context/frame/provider.ts +113 -31
  177. package/src/model-context/interactable-composer-metadata.ts +1 -1
  178. package/src/model-context/registry.test.ts +44 -0
  179. package/src/model-context/registry.ts +13 -35
  180. package/src/react/client/InMemoryThreadList.test.tsx +49 -0
  181. package/src/react/client/InMemoryThreadList.ts +7 -1
  182. package/src/react/client/Interactables.ts +19 -178
  183. package/src/react/client/RemoteThreadList.test.ts +96 -0
  184. package/src/react/client/RemoteThreadList.ts +27 -59
  185. package/src/react/interactables-legacy/Interactables.ts +20 -177
  186. package/src/react/interactables-shared/useInteractablePersistenceQueue.test.tsx +161 -0
  187. package/src/react/interactables-shared/useInteractablePersistenceQueue.ts +206 -0
  188. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +26 -56
  189. package/src/react/runtimes/assistant-transport/runManager.ts +4 -17
  190. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +92 -0
  191. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +40 -141
  192. package/src/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.test.ts +37 -0
  193. package/src/react/runtimes/cloud/createCloudThreadListAdapterCreateFallback.ts +18 -0
  194. package/src/react/runtimes/external-message-converter.test.ts +0 -14
  195. package/src/react/runtimes/external-message-converter.test.tsx +77 -0
  196. package/src/react/runtimes/external-message-converter.ts +58 -481
  197. package/src/runtime/api/composer-runtime.ts +1 -1
  198. package/src/runtime/api/message-runtime.test.ts +28 -1
  199. package/src/runtime/base/base-composer-runtime-core.ts +20 -40
  200. package/src/runtime/base/base-thread-runtime-core.test.ts +16 -0
  201. package/src/runtime/base/base-thread-runtime-core.ts +6 -11
  202. package/src/runtime/base/default-thread-composer-runtime-core.ts +1 -4
  203. package/src/runtime/interfaces/composer-runtime-core.ts +1 -1
  204. package/src/runtime/interfaces/thread-runtime-core.ts +1 -1
  205. package/src/runtime/internal.ts +5 -0
  206. package/src/runtime/queue/external-thread-queue-adapter.ts +1 -1
  207. package/src/runtime/queue/message-queue.ts +1 -4
  208. package/src/runtime/queue/queue-item.ts +10 -0
  209. package/src/runtime/utils/abortable-thread-load.test.ts +87 -0
  210. package/src/runtime/utils/abortable-thread-load.ts +53 -0
  211. package/src/runtime/utils/attachment-add-operations.ts +59 -0
  212. package/src/runtime/utils/auto-status.ts +33 -0
  213. package/src/runtime/utils/external-message-conversion.test.ts +113 -0
  214. package/src/runtime/utils/external-message-conversion.ts +494 -0
  215. package/src/runtime/utils/external-store-message.ts +2 -0
  216. package/src/runtime/utils/message-repository-session.test.ts +195 -0
  217. package/src/runtime/utils/message-repository-session.ts +176 -0
  218. package/src/runtime/utils/message-repository.ts +7 -4
  219. package/src/runtime/utils/pending-tool-calls.test.ts +57 -0
  220. package/src/runtime/utils/pending-tool-calls.ts +35 -0
  221. package/src/runtime/utils/thread-message-like.ts +2 -0
  222. package/src/runtimes/external-store/external-store-thread-list-runtime-core.ts +6 -14
  223. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +175 -28
  224. package/src/runtimes/local/local-thread-runtime-core.test.ts +172 -1
  225. package/src/runtimes/local/local-thread-runtime-core.ts +48 -3
  226. package/src/runtimes/remote-thread-list/remote-thread-state.test.ts +37 -0
  227. package/src/runtimes/remote-thread-list/remote-thread-state.ts +123 -0
  228. package/src/runtimes/tool-invocations/EDGE_CASES.md +17 -0
  229. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +140 -0
  230. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +3 -0
  231. package/src/store/clients/external-thread.ts +16 -61
  232. package/src/store/clients/single-thread-list.ts +4 -1
  233. package/src/store/clients/suggestions.test.ts +31 -0
  234. package/src/store/clients/suggestions.ts +18 -19
  235. package/src/store/scopes/message.ts +1 -1
  236. package/src/store/scopes/queue-item.ts +2 -9
  237. package/src/tests/MessageRepository.test.ts +77 -0
  238. package/src/tests/RemoteThreadListThreadListRuntimeCore-load-race.test.ts +194 -0
  239. package/src/tests/RemoteThreadListThreadListRuntimeCore-title-race.test.ts +158 -0
  240. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +164 -0
  241. package/src/tests/external-store-thread-runtime-core.test.ts +642 -0
  242. package/src/tests/external-thread-attachments.test.tsx +173 -0
  243. package/src/tests/single-thread-list-item.test.tsx +47 -0
  244. package/src/tests/thread-message-like.test.ts +39 -1
  245. package/src/types/message.ts +8 -3
  246. package/src/utils/data-url.test.ts +52 -1
  247. package/src/utils/data-url.ts +21 -0
  248. package/src/utils/invoke-user-callback.test.ts +82 -0
  249. package/src/utils/invoke-user-callback.ts +31 -0
  250. package/src/utils/normalizePartStatus.ts +1 -1
  251. package/dist/store/scopes/queue-item.js.map +0 -1
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createEmptyRemoteThreadState,
4
+ createThreadMappingId,
5
+ seedNewThread,
6
+ } from "./remote-thread-state";
7
+
8
+ describe("remote thread state", () => {
9
+ it("creates an empty state", () => {
10
+ expect(createEmptyRemoteThreadState()).toEqual({
11
+ isLoading: true,
12
+ isLoadingMore: false,
13
+ cursor: undefined,
14
+ newThreadId: undefined,
15
+ threadIds: [],
16
+ archivedThreadIds: [],
17
+ threadIdMap: {},
18
+ threadData: {},
19
+ });
20
+ });
21
+
22
+ it("seeds unique local threads with matching mapping ids", () => {
23
+ const first = seedNewThread(createEmptyRemoteThreadState());
24
+ const second = seedNewThread(first.state);
25
+
26
+ expect(second.id).not.toBe(first.id);
27
+ expect(first.state.newThreadId).toBe(first.id);
28
+ expect(second.state.newThreadId).toBe(second.id);
29
+ expect(first.state.threadIdMap[first.id]).toBe(
30
+ createThreadMappingId(first.id),
31
+ );
32
+ expect(second.state.threadIdMap[second.id]).toBe(
33
+ createThreadMappingId(second.id),
34
+ );
35
+ expect(Object.keys(second.state.threadData)).toEqual([first.id, second.id]);
36
+ });
37
+ });
@@ -2,6 +2,7 @@ import type {
2
2
  RemoteThreadInitializeResponse,
3
3
  RemoteThreadMetadata,
4
4
  } from "./types";
5
+ import { generateId } from "../../utils/id";
5
6
 
6
7
  export type RemoteThreadData =
7
8
  | {
@@ -11,6 +12,7 @@ export type RemoteThreadData =
11
12
  readonly status: "new";
12
13
  readonly title: undefined;
13
14
  readonly custom: undefined;
15
+ readonly localOrigin?: true;
14
16
  }
15
17
  | {
16
18
  readonly id: string;
@@ -20,6 +22,7 @@ export type RemoteThreadData =
20
22
  readonly status: "regular" | "archived";
21
23
  readonly title?: string | undefined;
22
24
  readonly custom: undefined;
25
+ readonly localOrigin?: true;
23
26
  }
24
27
  | {
25
28
  readonly id: string;
@@ -30,6 +33,7 @@ export type RemoteThreadData =
30
33
  readonly title?: string | undefined;
31
34
  readonly lastMessageAt?: Date | undefined;
32
35
  readonly custom?: Record<string, unknown> | undefined;
36
+ readonly localOrigin?: true;
33
37
  };
34
38
 
35
39
  export type THREAD_MAPPING_ID = string & { __brand: "THREAD_MAPPING_ID" };
@@ -38,6 +42,8 @@ export function createThreadMappingId(id: string): THREAD_MAPPING_ID {
38
42
  return id as THREAD_MAPPING_ID;
39
43
  }
40
44
 
45
+ export const LOCAL_THREAD_ID_PREFIX = "__LOCALID_";
46
+
41
47
  export const normalizeCursor = (c: string | undefined): string | undefined =>
42
48
  c || undefined;
43
49
 
@@ -98,6 +104,123 @@ export type RemoteThreadState = {
98
104
  readonly threadData: Readonly<Record<THREAD_MAPPING_ID, RemoteThreadData>>;
99
105
  };
100
106
 
107
+ export const createEmptyRemoteThreadState = (): RemoteThreadState => ({
108
+ isLoading: true,
109
+ isLoadingMore: false,
110
+ cursor: undefined,
111
+ newThreadId: undefined,
112
+ threadIds: [],
113
+ archivedThreadIds: [],
114
+ threadIdMap: {},
115
+ threadData: {},
116
+ });
117
+
118
+ export const seedNewThread = (
119
+ state: RemoteThreadState,
120
+ ): { id: string; state: RemoteThreadState } => {
121
+ let id: string;
122
+ do {
123
+ id = `${LOCAL_THREAD_ID_PREFIX}${generateId()}`;
124
+ } while (state.threadIdMap[id]);
125
+ const mappingId = createThreadMappingId(id);
126
+ return {
127
+ id,
128
+ state: {
129
+ ...state,
130
+ newThreadId: id,
131
+ threadIdMap: {
132
+ ...state.threadIdMap,
133
+ [id]: mappingId,
134
+ },
135
+ threadData: {
136
+ ...state.threadData,
137
+ [mappingId]: {
138
+ status: "new",
139
+ id,
140
+ remoteId: undefined,
141
+ externalId: undefined,
142
+ title: undefined,
143
+ custom: undefined,
144
+ localOrigin: true,
145
+ } satisfies RemoteThreadData,
146
+ },
147
+ },
148
+ };
149
+ };
150
+
151
+ // A list() response predating a mid-flight local transition (a thread
152
+ // initialized while the request was running) omits that thread, and the
153
+ // completed-optimistic replay cannot re-add it because the merged threadData
154
+ // already carries the final status. Threads whose status moved from new (or
155
+ // nonexistent) at request time to regular/archived are re-inserted at the
156
+ // top, in the order the pre-response lists carried them: updateStatusReducer
157
+ // prepends as transitions happen, so the pre-response lists hold newest
158
+ // first, which threadData insertion order need not match. Threads the server already knew stay governed by the response, so
159
+ // a server-side deletion is not resurrected — and a thread unknown to the
160
+ // snapshot is only rescued when it carries the localOrigin marker stamped at
161
+ // draft creation, so a deep-linked thread the fetch path deliberately
162
+ // appended at the tail is not hoisted, whatever its remote id looks like.
163
+ export const preserveMidLoadTransitions = (
164
+ state: RemoteThreadState,
165
+ priorOrder: Pick<RemoteThreadState, "threadIds" | "archivedThreadIds">,
166
+ statusAtRequest: ReadonlyMap<string, RemoteThreadData["status"]>,
167
+ ): RemoteThreadState => {
168
+ const regular = new Set(state.threadIds);
169
+ const archived = new Set(state.archivedThreadIds);
170
+ const rescuedRegular: RemoteThreadData[] = [];
171
+ const rescuedArchived: RemoteThreadData[] = [];
172
+
173
+ const contains = (ids: ReadonlySet<string>, data: RemoteThreadData) =>
174
+ ids.has(data.id) || (data.remoteId !== undefined && ids.has(data.remoteId));
175
+
176
+ for (const data of Object.values(state.threadData)) {
177
+ const before = statusAtRequest.get(data.id);
178
+ if (before !== undefined && before !== "new") continue;
179
+ if (before === undefined && data.localOrigin !== true) continue;
180
+
181
+ if (data.status === "regular" && !contains(regular, data)) {
182
+ rescuedRegular.push(data);
183
+ regular.add(data.id);
184
+ } else if (data.status === "archived" && !contains(archived, data)) {
185
+ rescuedArchived.push(data);
186
+ archived.add(data.id);
187
+ }
188
+ }
189
+
190
+ if (rescuedRegular.length === 0 && rescuedArchived.length === 0) return state;
191
+
192
+ const position = (ids: readonly string[], data: RemoteThreadData) => {
193
+ const byId = ids.indexOf(data.id);
194
+ if (byId !== -1) return byId;
195
+ const byRemoteId =
196
+ data.remoteId !== undefined ? ids.indexOf(data.remoteId) : -1;
197
+ return byRemoteId !== -1 ? byRemoteId : Number.MAX_SAFE_INTEGER;
198
+ };
199
+ rescuedRegular.sort(
200
+ (a, b) =>
201
+ position(priorOrder.threadIds, a) - position(priorOrder.threadIds, b),
202
+ );
203
+ rescuedArchived.sort(
204
+ (a, b) =>
205
+ position(priorOrder.archivedThreadIds, a) -
206
+ position(priorOrder.archivedThreadIds, b),
207
+ );
208
+
209
+ return {
210
+ ...state,
211
+ threadIds: [...rescuedRegular.map((d) => d.id), ...state.threadIds],
212
+ archivedThreadIds: [
213
+ ...rescuedArchived.map((d) => d.id),
214
+ ...state.archivedThreadIds,
215
+ ],
216
+ };
217
+ };
218
+
219
+ export const statusSnapshot = (
220
+ state: RemoteThreadState,
221
+ ): ReadonlyMap<string, RemoteThreadData["status"]> =>
222
+ new Map(Object.values(state.threadData).map((d) => [d.id, d.status]));
223
+
101
224
  export const getThreadData = (
102
225
  state: RemoteThreadState,
103
226
  threadIdOrRemoteId: string,
@@ -78,6 +78,23 @@ sees only the first result.
78
78
  ### A.7. Previously-resolved tool loses its `result` (back to undefined)
79
79
  Silently ignored. The entry stays in the resolved phase internally.
80
80
 
81
+ ### A.8. Tool call carries a provider `approval`
82
+ The entry is marked `skipExecute`, exactly like a call observed with
83
+ a `result`, whether the gate is present when the call is first observed
84
+ or lands on an already-active entry. A gate belongs to the provider:
85
+ `approved === true` means the provider is producing the result, `false`
86
+ means it records the denial, so the frontend `execute` never fires for
87
+ that `toolCallId` once the gate is present. `streamCall` still fires
88
+ once and the backend result flows through A.5 when it lands.
89
+
90
+ If the gate lands after the args stream has closed but before an
91
+ in-flight `execute` resolves, the execute runs to completion (its side
92
+ effects happen) but its result chunk is dropped: `onResult` never fires,
93
+ and the `executing` status, plus any `human()` interrupt the execution
94
+ parked, stays up until the promise settles. Only a gate that lands after
95
+ the result chunk has already been emitted is fully too late; the adapter
96
+ must project the gate before that (#6285).
97
+
81
98
  ## B. Tool call disappears from snapshot
82
99
 
83
100
  ### B.1. Tool call removed entirely (rollback, branch switch)
@@ -47,6 +47,7 @@ const createAssistantMessage = (
47
47
  toolCallId?: string;
48
48
  toolName?: string;
49
49
  nestedMessages?: ThreadAssistantMessage[];
50
+ approval?: { id: string; approved?: boolean };
50
51
  },
51
52
  ): ThreadAssistantMessage => ({
52
53
  id: "m-1",
@@ -70,6 +71,7 @@ const createAssistantMessage = (
70
71
  ...(options?.result !== undefined && { result: options.result }),
71
72
  ...(options?.isError !== undefined && { isError: options.isError }),
72
73
  ...(options?.nestedMessages && { messages: options.nestedMessages }),
74
+ ...(options?.approval && { approval: options.approval }),
73
75
  },
74
76
  ],
75
77
  });
@@ -370,6 +372,144 @@ describe("ToolInvocationTracker", () => {
370
372
  });
371
373
  });
372
374
 
375
+ it("never executes a tool call that carries a provider approval", async () => {
376
+ const execute = vi.fn(async () => ({ deleted: true }));
377
+ const getTools = () => ({
378
+ deleteFile: {
379
+ parameters: { type: "object", properties: {} },
380
+ execute,
381
+ } satisfies Tool,
382
+ });
383
+ const onResult = vi.fn();
384
+ const tracker = new ToolInvocationTracker(getTools, {
385
+ onResult,
386
+ onStatusesChange: () => {},
387
+ });
388
+ tracker.setState(createState([]));
389
+
390
+ const gated = (approval: { id: string; approved?: boolean }) =>
391
+ createAssistantMessage(
392
+ '{"path":"/tmp/a"}',
393
+ { path: "/tmp/a" },
394
+ { toolName: "deleteFile", approval },
395
+ );
396
+
397
+ tracker.setState(createState([gated({ id: "approval-1" })], false));
398
+ await new Promise((r) => setTimeout(r, 0));
399
+ expect(execute).not.toHaveBeenCalled();
400
+
401
+ tracker.setState(
402
+ createState([gated({ id: "approval-1", approved: true })], true),
403
+ );
404
+ await new Promise((r) => setTimeout(r, 0));
405
+
406
+ expect(execute).not.toHaveBeenCalled();
407
+ expect(onResult).not.toHaveBeenCalled();
408
+ });
409
+
410
+ it("stops executing a live tool call once a provider approval lands", async () => {
411
+ const execute = vi.fn(async () => ({ deleted: true }));
412
+ const getTools = () => ({
413
+ deleteFile: {
414
+ parameters: { type: "object", properties: {} },
415
+ execute,
416
+ } satisfies Tool,
417
+ });
418
+ const onResult = vi.fn();
419
+ const tracker = new ToolInvocationTracker(getTools, {
420
+ onResult,
421
+ onStatusesChange: () => {},
422
+ });
423
+ tracker.setState(createState([]));
424
+
425
+ tracker.setState(
426
+ createState(
427
+ [
428
+ createAssistantMessage(
429
+ '{"path":"/tmp',
430
+ {},
431
+ { toolName: "deleteFile" },
432
+ ),
433
+ ],
434
+ true,
435
+ ),
436
+ );
437
+ tracker.setState(
438
+ createState(
439
+ [
440
+ createAssistantMessage(
441
+ '{"path":"/tmp/a"}',
442
+ { path: "/tmp/a" },
443
+ { toolName: "deleteFile", approval: { id: "approval-1" } },
444
+ ),
445
+ ],
446
+ true,
447
+ ),
448
+ );
449
+ await new Promise((r) => setTimeout(r, 0));
450
+
451
+ expect(execute).not.toHaveBeenCalled();
452
+ expect(onResult).not.toHaveBeenCalled();
453
+ });
454
+
455
+ it("drops the result of an in-flight execute once a provider approval lands", async () => {
456
+ let resolveExecute!: (value: { deleted: boolean }) => void;
457
+ const execute = vi.fn(
458
+ () =>
459
+ new Promise<{ deleted: boolean }>((r) => {
460
+ resolveExecute = r;
461
+ }),
462
+ );
463
+ const getTools = () => ({
464
+ deleteFile: {
465
+ parameters: { type: "object", properties: {} },
466
+ execute,
467
+ } satisfies Tool,
468
+ });
469
+ const onResult = vi.fn();
470
+ const tracker = new ToolInvocationTracker(getTools, {
471
+ onResult,
472
+ onStatusesChange: () => {},
473
+ });
474
+ tracker.setState(createState([]));
475
+
476
+ tracker.setState(
477
+ createState(
478
+ [
479
+ createAssistantMessage(
480
+ '{"path":"/tmp/a"}',
481
+ { path: "/tmp/a" },
482
+ {
483
+ toolName: "deleteFile",
484
+ },
485
+ ),
486
+ ],
487
+ true,
488
+ ),
489
+ );
490
+ await waitFor(() => expect(execute).toHaveBeenCalledTimes(1));
491
+
492
+ tracker.setState(
493
+ createState(
494
+ [
495
+ createAssistantMessage(
496
+ '{"path":"/tmp/a"}',
497
+ { path: "/tmp/a" },
498
+ {
499
+ toolName: "deleteFile",
500
+ approval: { id: "approval-1" },
501
+ },
502
+ ),
503
+ ],
504
+ true,
505
+ ),
506
+ );
507
+ resolveExecute({ deleted: true });
508
+ await new Promise((r) => setTimeout(r, 0));
509
+
510
+ expect(onResult).not.toHaveBeenCalled();
511
+ });
512
+
373
513
  it("does not re-execute asynchronously loaded resolved tool calls after reset", async () => {
374
514
  const execute = vi.fn(async () => ({ forecast: "ok" }));
375
515
  const getTools = () => ({
@@ -519,6 +519,7 @@ export class ToolInvocationTracker {
519
519
  // The host already set the result (via the live snapshot's
520
520
  // `setResponse` path). Suppress the executor's redundant emit.
521
521
  if (entry?.hasResult) return;
522
+ if (entry.skipExecute) return;
522
523
 
523
524
  this._invokeOnResult({
524
525
  type: "add-tool-result",
@@ -794,6 +795,8 @@ export class ToolInvocationTracker {
794
795
  );
795
796
  }
796
797
 
798
+ if (content.approval !== undefined) entry.skipExecute = true;
799
+
797
800
  this._processArgsText(entry, content);
798
801
 
799
802
  if (content.result !== undefined && !entry.hasResult) {
@@ -44,6 +44,10 @@ import type { ComposerSendOptions } from "../scopes/composer";
44
44
  import { fileMatchesAccept } from "../../adapters/attachment";
45
45
  import { getThreadMessageText } from "../../utils/text";
46
46
  import { resolveToolApprovalResponse } from "../../runtime/utils/resolveToolApprovalResponse";
47
+ import {
48
+ AttachmentAddOperations,
49
+ drainAttachmentAdd,
50
+ } from "../../runtime/utils/attachment-add-operations";
47
51
  import { toMessagePartStatus } from "../../utils/normalizePartStatus";
48
52
  import { ModelContext } from "./model-context-client";
49
53
  import { ThreadSuggestions } from "./suggestions";
@@ -420,53 +424,6 @@ type ComposerClientResourceProps = {
420
424
  attachmentAdapter?: AttachmentAdapter | undefined;
421
425
  };
422
426
 
423
- type AttachmentAddOperation = {
424
- cancelled: boolean;
425
- attachmentIds: Set<string>;
426
- };
427
-
428
- class AttachmentAddOperations {
429
- private readonly operations = new Set<AttachmentAddOperation>();
430
-
431
- start() {
432
- const operation: AttachmentAddOperation = {
433
- cancelled: false,
434
- attachmentIds: new Set(),
435
- };
436
- this.operations.add(operation);
437
- return operation;
438
- }
439
-
440
- accept(operation: AttachmentAddOperation, attachmentId: string) {
441
- if (operation.cancelled) return false;
442
- operation.attachmentIds.add(attachmentId);
443
- return true;
444
- }
445
-
446
- finish(operation: AttachmentAddOperation) {
447
- this.operations.delete(operation);
448
- }
449
-
450
- isCancelled(operation: AttachmentAddOperation) {
451
- return operation.cancelled;
452
- }
453
-
454
- cancel(attachmentId: string) {
455
- for (const operation of [...this.operations]) {
456
- if (!operation.attachmentIds.has(attachmentId)) continue;
457
- operation.cancelled = true;
458
- this.operations.delete(operation);
459
- }
460
- }
461
-
462
- cancelAll() {
463
- for (const operation of this.operations) {
464
- operation.cancelled = true;
465
- }
466
- this.operations.clear();
467
- }
468
- }
469
-
470
427
  const useQueueItemClient = ({
471
428
  item,
472
429
  onMove,
@@ -486,19 +443,6 @@ const useQueueItemClient = ({
486
443
 
487
444
  const QueueItemClient = resource(useQueueItemClient);
488
445
 
489
- const drainAdapterAdd = async (
490
- result: ReturnType<AttachmentAdapter["add"]>,
491
- upsert: (attachment: Attachment) => boolean,
492
- ) => {
493
- if (Symbol.asyncIterator in result) {
494
- for await (const attachment of result) {
495
- if (!upsert(attachment)) break;
496
- }
497
- } else {
498
- upsert(await result);
499
- }
500
- };
501
-
502
446
  // State whose setter tracks the latest value in a ref, so imperative
503
447
  // call sequences (setText immediately followed by send) observe the write
504
448
  // before React re-renders — legacy composer parity.
@@ -672,7 +616,7 @@ const useComposerClientResource = ({
672
616
  if (!isCreateAttachment(fileOrAttachment) && attachmentAdapter) {
673
617
  const operation = attachmentAddOperations.start();
674
618
  try {
675
- await drainAdapterAdd(
619
+ await drainAttachmentAdd(
676
620
  attachmentAdapter.add({ file: fileOrAttachment }),
677
621
  (attachment) => {
678
622
  if (!attachmentAddOperations.accept(operation, attachment.id))
@@ -795,6 +739,17 @@ const useComposerClientResource = ({
795
739
  }
796
740
  },
797
741
  cancel: () => {
742
+ // An edit session ends here, so its in-flight adapter adds must not
743
+ // land in a later session; the thread composer's cancel stops the run
744
+ // and leaves the draft (and its pending adds) alone.
745
+ if (type === "edit") {
746
+ attachmentAddOperations.cancelAll();
747
+ const removed = attachmentsRef.current;
748
+ setAttachments([]);
749
+ removePendingAttachments(removed).catch((error) => {
750
+ console.error("Failed to remove cancelled edit attachments", error);
751
+ });
752
+ }
798
753
  onCancel?.();
799
754
  if (type === "edit") setIsEditing(false);
800
755
  },
@@ -93,7 +93,10 @@ const useSingleThreadList = ({
93
93
  !(
94
94
  typeof selector === "object" &&
95
95
  "index" in selector &&
96
- selector.index === 0
96
+ selector.index === 0 &&
97
+ // Index selectors address their archived/regular subset, and the
98
+ // archived subset here is always empty.
99
+ !selector.archived
97
100
  )
98
101
  ) {
99
102
  throw new Error(
@@ -0,0 +1,31 @@
1
+ import { useState } from "react";
2
+ import { createTapRoot, flushTapSync, useResource } from "@assistant-ui/tap";
3
+ import { describe, expect, it } from "vitest";
4
+ import { Suggestions, type SuggestionConfig } from "./suggestions";
5
+
6
+ describe("Suggestions", () => {
7
+ it("updates when the configured suggestions change", () => {
8
+ let setSuggestions!: (suggestions: SuggestionConfig[]) => void;
9
+ const root = createTapRoot(function SuggestionsRoot() {
10
+ const [suggestions, setValue] = useState<SuggestionConfig[]>([
11
+ "account-a",
12
+ ]);
13
+ setSuggestions = setValue;
14
+ return useResource(Suggestions(suggestions));
15
+ });
16
+
17
+ try {
18
+ expect(root.getValue().getState().suggestions[0]?.prompt).toBe(
19
+ "account-a",
20
+ );
21
+
22
+ flushTapSync(() => setSuggestions(["account-b"]));
23
+
24
+ expect(root.getValue().getState().suggestions[0]?.prompt).toBe(
25
+ "account-b",
26
+ );
27
+ } finally {
28
+ root.unmount();
29
+ }
30
+ });
31
+ });
@@ -1,4 +1,4 @@
1
- import { useMemo, useState } from "react";
1
+ import { useMemo } from "react";
2
2
  import { resource, withKey } from "@assistant-ui/tap";
3
3
  import type { ClientOutput } from "@assistant-ui/store";
4
4
  import { useClientLookup } from "@assistant-ui/store/client";
@@ -40,26 +40,25 @@ const useSuggestionsClient = (
40
40
  const useStaticSuggestions = (
41
41
  suggestions?: SuggestionConfig[],
42
42
  ): ClientOutput<"suggestions"> => {
43
- const [state] = useState<SuggestionsState>(() => {
44
- const normalizedSuggestions = (suggestions ?? []).map((s) => {
45
- if (typeof s === "string") {
43
+ const state = useMemo<SuggestionsState>(
44
+ () => ({
45
+ suggestions: (suggestions ?? []).map((s) => {
46
+ if (typeof s === "string") {
47
+ return {
48
+ title: s,
49
+ label: "",
50
+ prompt: s,
51
+ };
52
+ }
46
53
  return {
47
- title: s,
48
- label: "",
49
- prompt: s,
54
+ title: s.title,
55
+ label: s.label,
56
+ prompt: s.prompt,
50
57
  };
51
- }
52
- return {
53
- title: s.title,
54
- label: s.label,
55
- prompt: s.prompt,
56
- };
57
- });
58
-
59
- return {
60
- suggestions: normalizedSuggestions,
61
- };
62
- });
58
+ }),
59
+ }),
60
+ [suggestions],
61
+ );
63
62
 
64
63
  return useSuggestionsClient(state);
65
64
  };
@@ -19,7 +19,7 @@ export type MessageState = ThreadMessage & {
19
19
  * @example
20
20
  * ```ts
21
21
  * import { WebSpeechSynthesisAdapter } from "@assistant-ui/react";
22
- * import { useChatRuntime } from "@assistant-ui/react-ai-sdk";
22
+ * import { useChatRuntime } from "@assistant-ui/ai-sdk";
23
23
  *
24
24
  * const runtime = useChatRuntime({
25
25
  * adapters: {
@@ -1,14 +1,7 @@
1
- import type { FileMessagePart, TextMessagePart } from "../../types/message";
2
1
  import type { QueuePlacement } from "../../runtime/queue/external-thread-queue-adapter";
2
+ import type { QueueItemState } from "../../runtime/queue/queue-item";
3
3
 
4
- export type QueueItemState = {
5
- readonly id: string;
6
- /** @deprecated Derive from the text parts of `parts` instead. Removal after 2026-11-05. */
7
- readonly prompt: string;
8
- readonly parts: readonly (FileMessagePart | TextMessagePart)[];
9
- };
10
-
11
- export const EMPTY_QUEUE_ITEMS: readonly QueueItemState[] = Object.freeze([]);
4
+ export type { QueueItemState };
12
5
 
13
6
  export type QueueItemMethods = {
14
7
  getState(): QueueItemState;