@assistant-ui/core 0.2.21 → 0.2.22

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 (209) hide show
  1. package/dist/adapters/attachment.d.ts.map +1 -1
  2. package/dist/adapters/speech.d.ts.map +1 -1
  3. package/dist/adapters/speech.js +9 -2
  4. package/dist/adapters/speech.js.map +1 -1
  5. package/dist/adapters/voice.d.ts.map +1 -1
  6. package/dist/adapters/voice.js +13 -5
  7. package/dist/adapters/voice.js.map +1 -1
  8. package/dist/internal.d.ts +3 -1
  9. package/dist/internal.js +3 -1
  10. package/dist/model-context/frame/host.d.ts +1 -0
  11. package/dist/model-context/frame/host.d.ts.map +1 -1
  12. package/dist/model-context/frame/host.js +5 -0
  13. package/dist/model-context/frame/host.js.map +1 -1
  14. package/dist/model-context/frame/provider.d.ts.map +1 -1
  15. package/dist/model-context/frame/provider.js +1 -0
  16. package/dist/model-context/frame/provider.js.map +1 -1
  17. package/dist/model-context/interactable-composer-metadata.js.map +1 -1
  18. package/dist/react/AssistantProvider.js.map +1 -1
  19. package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
  20. package/dist/react/adapters/LocalStorageThreadListAdapter.js +77 -50
  21. package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
  22. package/dist/react/client/Interactables.js.map +1 -1
  23. package/dist/react/client/Tools.d.ts.map +1 -1
  24. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  25. package/dist/react/model-context/useInteractable.js.map +1 -1
  26. package/dist/react/primitive-hooks/useActionBarCopy.js +13 -13
  27. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  28. package/dist/react/primitive-hooks/useComposerAddAttachment.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useComposerSend.d.ts.map +1 -1
  30. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.d.ts.map +1 -1
  31. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js +1 -1
  32. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
  33. package/dist/react/primitives/composer/ComposerAttachments.js.map +1 -1
  34. package/dist/react/primitives/composer/ComposerQueue.d.ts.map +1 -1
  35. package/dist/react/primitives/composer/ComposerQueue.js.map +1 -1
  36. package/dist/react/primitives/generativeUI/GenerativeUI.d.ts.map +1 -1
  37. package/dist/react/primitives/message/MessageAttachments.js.map +1 -1
  38. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  39. package/dist/react/primitives/message/MessageParts.d.ts +2 -1
  40. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  41. package/dist/react/primitives/message/MessageParts.js +46 -36
  42. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  43. package/dist/react/primitives/part/PartMessages.js.map +1 -1
  44. package/dist/react/primitives/thread/ThreadMessages.js.map +1 -1
  45. package/dist/react/primitives/thread/ThreadSuggestions.js.map +1 -1
  46. package/dist/react/primitives/threadList/ThreadListItems.js.map +1 -1
  47. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.d.ts.map +1 -1
  48. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +18 -30
  49. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js.map +1 -1
  50. package/dist/react/providers/PartByIndexProvider.d.ts.map +1 -1
  51. package/dist/react/providers/PartByIndexProvider.js +18 -30
  52. package/dist/react/providers/PartByIndexProvider.js.map +1 -1
  53. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  54. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +1 -0
  55. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  56. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +9 -2
  57. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  58. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +49 -8
  59. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  60. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +1 -1
  61. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  62. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  63. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js +2 -1
  64. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js.map +1 -1
  65. package/dist/react/runtimes/createMessageConverter.d.ts.map +1 -1
  66. package/dist/react/runtimes/external-message-converter.js +1 -1
  67. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  68. package/dist/react/runtimes/useLocalRuntime.d.ts +1 -0
  69. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  70. package/dist/react/runtimes/useLocalRuntime.js +9 -1
  71. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  72. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  73. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  74. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  75. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  76. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  77. package/dist/runtime/api/message-runtime.js +4 -4
  78. package/dist/runtime/api/message-runtime.js.map +1 -1
  79. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  80. package/dist/runtime/api/thread-list-item-runtime.js +2 -1
  81. package/dist/runtime/api/thread-list-item-runtime.js.map +1 -1
  82. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  83. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  84. package/dist/runtime/base/base-composer-runtime-core.d.ts +4 -1
  85. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  86. package/dist/runtime/base/base-composer-runtime-core.js +41 -14
  87. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  88. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  89. package/dist/runtime/base/base-thread-runtime-core.js +2 -1
  90. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  91. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  92. package/dist/runtime/base/default-edit-composer-runtime-core.js +4 -2
  93. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  94. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  95. package/dist/runtime/base/default-thread-composer-runtime-core.js +2 -2
  96. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  97. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  98. package/dist/runtime/utils/thread-message-like.js +4 -1
  99. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  100. package/dist/runtimes/external-store/external-store-runtime-core.d.ts.map +1 -1
  101. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  102. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  103. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  104. package/dist/runtimes/local/local-runtime-core.d.ts.map +1 -1
  105. package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
  106. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  107. package/dist/runtimes/local/local-thread-runtime-core.js +5 -2
  108. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  109. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  110. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +4 -4
  111. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  112. package/dist/runtimes/remote-thread-list/optimistic-state.js +22 -9
  113. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  114. package/dist/runtimes/remote-thread-list/types.d.ts +1 -1
  115. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  116. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +1 -2
  117. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  118. package/dist/store/clients/chain-of-thought-client.d.ts.map +1 -1
  119. package/dist/store/clients/no-op-composer-client.d.ts.map +1 -1
  120. package/dist/store/runtime-clients/attachment-runtime-client.d.ts.map +1 -1
  121. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  122. package/dist/store/runtime-clients/message-part-runtime-client.d.ts.map +1 -1
  123. package/dist/store/runtime-clients/message-runtime-client.d.ts.map +1 -1
  124. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  125. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  126. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  127. package/dist/subscribable/subscribable.d.ts +1 -1
  128. package/dist/subscribable/subscribable.d.ts.map +1 -1
  129. package/dist/subscribable/subscribable.js +11 -3
  130. package/dist/subscribable/subscribable.js.map +1 -1
  131. package/dist/tests/remote-thread-list-test-helpers.d.ts +2 -1
  132. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  133. package/dist/tests/remote-thread-list-test-helpers.js +5 -2
  134. package/dist/tests/remote-thread-list-test-helpers.js.map +1 -1
  135. package/dist/types/attachment.d.ts +2 -1
  136. package/dist/types/attachment.d.ts.map +1 -1
  137. package/dist/types/attachment.js +6 -0
  138. package/dist/types/attachment.js.map +1 -0
  139. package/dist/types/message.d.ts +1 -0
  140. package/dist/types/message.d.ts.map +1 -1
  141. package/dist/types/message.js.map +1 -1
  142. package/dist/utils/data-url.d.ts +9 -0
  143. package/dist/utils/data-url.d.ts.map +1 -0
  144. package/dist/utils/data-url.js +14 -0
  145. package/dist/utils/data-url.js.map +1 -0
  146. package/dist/utils/notify-event-listeners.d.ts +6 -0
  147. package/dist/utils/notify-event-listeners.d.ts.map +1 -0
  148. package/dist/utils/notify-event-listeners.js +16 -0
  149. package/dist/utils/notify-event-listeners.js.map +1 -0
  150. package/package.json +10 -9
  151. package/src/adapters/speech.test.ts +88 -0
  152. package/src/adapters/speech.ts +17 -2
  153. package/src/adapters/voice.test.ts +130 -0
  154. package/src/adapters/voice.ts +26 -6
  155. package/src/internal.ts +5 -0
  156. package/src/model-context/frame/host.test.ts +100 -0
  157. package/src/model-context/frame/host.ts +10 -0
  158. package/src/model-context/frame/provider.test.ts +83 -0
  159. package/src/model-context/frame/provider.ts +1 -0
  160. package/src/react/adapters/LocalStorageThreadListAdapter.test.ts +82 -0
  161. package/src/react/adapters/LocalStorageThreadListAdapter.tsx +94 -54
  162. package/src/react/primitive-hooks/useActionBarCopy.test.ts +79 -0
  163. package/src/react/primitive-hooks/useActionBarCopy.ts +4 -3
  164. package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -3
  165. package/src/react/primitives/message/MessageParts.tsx +48 -35
  166. package/src/react/providers/ChainOfThoughtPartByIndexProvider.tsx +19 -2
  167. package/src/react/providers/PartByIndexProvider.tsx +16 -2
  168. package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +18 -0
  169. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +1 -0
  170. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +48 -7
  171. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +69 -0
  172. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +1 -1
  173. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.test.ts +16 -0
  174. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.ts +2 -1
  175. package/src/react/runtimes/external-message-converter.test.tsx +21 -0
  176. package/src/react/runtimes/external-message-converter.ts +1 -1
  177. package/src/react/runtimes/useLocalRuntime.test.tsx +56 -0
  178. package/src/react/runtimes/useLocalRuntime.ts +13 -1
  179. package/src/runtime/api/message-runtime.test.ts +81 -0
  180. package/src/runtime/api/message-runtime.ts +4 -6
  181. package/src/runtime/api/thread-list-item-runtime.ts +6 -1
  182. package/src/runtime/base/base-composer-runtime-core.ts +77 -31
  183. package/src/runtime/base/base-thread-runtime-core.ts +2 -1
  184. package/src/runtime/base/default-edit-composer-runtime-core.ts +4 -2
  185. package/src/runtime/base/default-thread-composer-runtime-core.ts +2 -2
  186. package/src/runtime/utils/thread-message-like.ts +1 -0
  187. package/src/runtimes/local/local-thread-runtime-core.test.ts +150 -1
  188. package/src/runtimes/local/local-thread-runtime-core.ts +4 -2
  189. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +63 -0
  190. package/src/runtimes/remote-thread-list/optimistic-state.ts +49 -17
  191. package/src/runtimes/remote-thread-list/types.ts +1 -1
  192. package/src/runtimes/tool-invocations/EDGE_CASES.md +13 -19
  193. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +127 -6
  194. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +4 -2
  195. package/src/subscribable/subscribable.test.ts +58 -0
  196. package/src/subscribable/subscribable.ts +13 -3
  197. package/src/tests/OptimisticState-list-race.test.ts +33 -0
  198. package/src/tests/RemoteThreadListThreadListRuntimeCore-switchToThread-order.test.ts +223 -0
  199. package/src/tests/base-composer-runtime-core-addAttachment.test.ts +44 -0
  200. package/src/tests/base-composer-runtime-core-send.test.ts +433 -5
  201. package/src/tests/default-edit-composer-runtime-core.test.ts +43 -5
  202. package/src/tests/event-subscription-listener-errors.test.ts +205 -0
  203. package/src/tests/remote-thread-list-test-helpers.ts +14 -7
  204. package/src/tests/thread-message-like.test.ts +57 -0
  205. package/src/types/attachment.ts +5 -0
  206. package/src/types/message.ts +1 -0
  207. package/src/utils/data-url.test.ts +50 -0
  208. package/src/utils/data-url.ts +9 -0
  209. package/src/utils/notify-event-listeners.ts +30 -0
@@ -0,0 +1,130 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ createVoiceSession,
4
+ type VoiceSessionControls,
5
+ type VoiceSessionHelpers,
6
+ } from "./voice";
7
+
8
+ const createTestSession = () => {
9
+ let helpers: VoiceSessionHelpers | undefined;
10
+ const session = createVoiceSession({}, async (sessionHelpers) => {
11
+ helpers = sessionHelpers;
12
+ return {
13
+ disconnect: vi.fn(),
14
+ mute: vi.fn(),
15
+ unmute: vi.fn(),
16
+ };
17
+ });
18
+
19
+ if (!helpers) throw new Error("Voice session setup did not start");
20
+ return { helpers, session };
21
+ };
22
+
23
+ const createPendingTestSession = () => {
24
+ let resolveControls!: (controls: VoiceSessionControls) => void;
25
+ const controlsPromise = new Promise<VoiceSessionControls>((resolve) => {
26
+ resolveControls = resolve;
27
+ });
28
+ const controls = {
29
+ disconnect: vi.fn(),
30
+ mute: vi.fn(),
31
+ unmute: vi.fn(),
32
+ };
33
+ const session = createVoiceSession({}, () => controlsPromise);
34
+
35
+ return {
36
+ controls,
37
+ controlsPromise,
38
+ resolveControls: () => resolveControls(controls),
39
+ session,
40
+ };
41
+ };
42
+
43
+ afterEach(() => {
44
+ vi.restoreAllMocks();
45
+ });
46
+
47
+ describe("createVoiceSession", () => {
48
+ it("applies mute requested while setup is pending", async () => {
49
+ const { controls, controlsPromise, resolveControls, session } =
50
+ createPendingTestSession();
51
+
52
+ session.mute();
53
+
54
+ expect(session.isMuted).toBe(true);
55
+ expect(controls.mute).not.toHaveBeenCalled();
56
+
57
+ resolveControls();
58
+ await controlsPromise;
59
+
60
+ expect(controls.mute).toHaveBeenCalledOnce();
61
+ });
62
+
63
+ it("does not apply stale mute after unmuting during setup", async () => {
64
+ const { controls, controlsPromise, resolveControls, session } =
65
+ createPendingTestSession();
66
+
67
+ session.mute();
68
+ session.unmute();
69
+ resolveControls();
70
+ await controlsPromise;
71
+
72
+ expect(session.isMuted).toBe(false);
73
+ expect(controls.mute).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("continues notifying listeners when one throws", () => {
77
+ const listenerError = new Error("listener failed");
78
+ const consoleError = vi
79
+ .spyOn(console, "error")
80
+ .mockImplementation(() => {});
81
+ const { helpers, session } = createTestSession();
82
+
83
+ const transcriptListener = vi.fn();
84
+ session.onTranscript(() => {
85
+ throw listenerError;
86
+ });
87
+ session.onTranscript(transcriptListener);
88
+
89
+ const modeListener = vi.fn();
90
+ session.onModeChange(() => {
91
+ throw listenerError;
92
+ });
93
+ session.onModeChange(modeListener);
94
+
95
+ const volumeListener = vi.fn();
96
+ session.onVolumeChange(() => {
97
+ throw listenerError;
98
+ });
99
+ session.onVolumeChange(volumeListener);
100
+
101
+ const statusListener = vi.fn();
102
+ session.onStatusChange(() => {
103
+ throw listenerError;
104
+ });
105
+ session.onStatusChange(statusListener);
106
+
107
+ helpers.emitTranscript({ role: "assistant", text: "hello" });
108
+ helpers.emitMode("speaking");
109
+ helpers.emitVolume(0.5);
110
+ helpers.end("finished");
111
+
112
+ expect(transcriptListener).toHaveBeenCalledWith({
113
+ role: "assistant",
114
+ text: "hello",
115
+ });
116
+ expect(modeListener).toHaveBeenCalledWith("speaking");
117
+ expect(volumeListener).toHaveBeenCalledWith(0.5);
118
+ expect(statusListener).toHaveBeenCalledWith({
119
+ type: "ended",
120
+ reason: "finished",
121
+ error: undefined,
122
+ });
123
+ expect(helpers.isDisposed()).toBe(true);
124
+ expect(consoleError).toHaveBeenCalledTimes(4);
125
+ expect(consoleError).toHaveBeenCalledWith(
126
+ "[assistant-ui] Voice session listener threw an error",
127
+ listenerError,
128
+ );
129
+ });
130
+ });
@@ -57,6 +57,22 @@ export type VoiceSessionHelpers = {
57
57
  isDisposed: () => boolean;
58
58
  };
59
59
 
60
+ const notifyListeners = <T>(
61
+ listeners: ReadonlySet<(value: T) => void>,
62
+ value: T,
63
+ ) => {
64
+ for (const listener of listeners) {
65
+ try {
66
+ listener(value);
67
+ } catch (error) {
68
+ console.error(
69
+ "[assistant-ui] Voice session listener threw an error",
70
+ error,
71
+ );
72
+ }
73
+ }
74
+ };
75
+
60
76
  export function createVoiceSession(
61
77
  options: { abortSignal?: AbortSignal },
62
78
  setup: (helpers: VoiceSessionHelpers) => Promise<VoiceSessionControls>,
@@ -85,25 +101,25 @@ export function createVoiceSession(
85
101
  setStatus: (status) => {
86
102
  if (disposed) return;
87
103
  currentStatus = status;
88
- for (const cb of statusCbs) cb(status);
104
+ notifyListeners(statusCbs, status);
89
105
  },
90
106
  end: (reason, error?) => {
91
107
  if (disposed) return;
92
108
  currentStatus = { type: "ended", reason, error };
93
- for (const cb of statusCbs) cb(currentStatus);
109
+ notifyListeners(statusCbs, currentStatus);
94
110
  cleanup();
95
111
  },
96
112
  emitTranscript: (item) => {
97
113
  if (disposed) return;
98
- for (const cb of transcriptCbs) cb(item);
114
+ notifyListeners(transcriptCbs, item);
99
115
  },
100
116
  emitMode: (mode) => {
101
117
  if (disposed) return;
102
- for (const cb of modeCbs) cb(mode);
118
+ notifyListeners(modeCbs, mode);
103
119
  },
104
120
  emitVolume: (volume) => {
105
121
  if (disposed) return;
106
- for (const cb of volumeCbs) cb(volume);
122
+ notifyListeners(volumeCbs, volume);
107
123
  },
108
124
  isDisposed: () => disposed,
109
125
  };
@@ -155,7 +171,11 @@ export function createVoiceSession(
155
171
  try {
156
172
  if (disposed) return;
157
173
  controls = await setup(helpers);
158
- if (disposed) controls.disconnect();
174
+ if (disposed) {
175
+ controls.disconnect();
176
+ } else if (isMuted) {
177
+ controls.mute();
178
+ }
159
179
  } catch (error) {
160
180
  helpers.end("error", error);
161
181
  }
package/src/internal.ts CHANGED
@@ -41,6 +41,7 @@ export { CompositeContextProvider } from "./utils/composite-context-provider";
41
41
  // Shared attachment data-URL encoder, reused by framework adapters so the
42
42
  // FileReader fallback lives in one place.
43
43
  export { getFileDataURL } from "./adapters/attachment";
44
+ export { isCreateAttachment } from "./types/attachment";
44
45
 
45
46
  // Streaming-stable tool-args stringifier, reused by framework adapters so the
46
47
  // key-order stabilization lives in one place.
@@ -53,6 +54,10 @@ export {
53
54
  // validation lives in one place.
54
55
  export { isJSONValue, isRecord } from "./utils/json/is-json";
55
56
 
57
+ // Data-URL decoder and http(s) matcher, reused by framework adapters so the
58
+ // outbound part conversion lives in one place.
59
+ export { httpUrlPattern, parseDataUrl } from "./utils/data-url";
60
+
56
61
  // Runtime extras helper for external-store adapters. Internal because the
57
62
  // tap-native runtime path replaces the `thread.extras` side-channel it wraps.
58
63
  export {
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { AssistantFrameHost } from "./host";
3
+ import { type FrameMessage, FRAME_MESSAGE_CHANNEL } from "./types";
4
+
5
+ const executionContext = {
6
+ toolCallId: "tool-call",
7
+ abortSignal: new AbortController().signal,
8
+ human: async () => undefined,
9
+ };
10
+
11
+ const createHost = () => {
12
+ let handleMessage: ((event: MessageEvent) => void) | undefined;
13
+ const addEventListener = vi.fn(
14
+ (_type: string, listener: EventListenerOrEventListenerObject) => {
15
+ handleMessage = listener as (event: MessageEvent) => void;
16
+ },
17
+ );
18
+ const removeEventListener = vi.fn();
19
+ vi.stubGlobal("window", { addEventListener, removeEventListener });
20
+
21
+ const postMessage = vi.fn();
22
+ const iframeWindow = { postMessage } as unknown as Window;
23
+ const host = new AssistantFrameHost(iframeWindow);
24
+
25
+ const dispatchMessage = (message: FrameMessage) =>
26
+ handleMessage?.({
27
+ source: iframeWindow,
28
+ data: {
29
+ channel: FRAME_MESSAGE_CHANNEL,
30
+ message,
31
+ },
32
+ } as unknown as MessageEvent);
33
+
34
+ dispatchMessage({
35
+ type: "model-context-update",
36
+ context: {
37
+ tools: {
38
+ search: {
39
+ type: "frontend",
40
+ parameters: { type: "object", properties: {} },
41
+ },
42
+ },
43
+ },
44
+ });
45
+
46
+ const execute = host.getModelContext().tools?.search?.execute;
47
+ if (!execute) throw new Error("Expected the search tool to be available");
48
+
49
+ return { dispatchMessage, execute, host, postMessage };
50
+ };
51
+
52
+ beforeEach(() => {
53
+ vi.useFakeTimers();
54
+ });
55
+
56
+ afterEach(() => {
57
+ vi.useRealTimers();
58
+ vi.unstubAllGlobals();
59
+ });
60
+
61
+ describe("AssistantFrameHost", () => {
62
+ it("resolves tool calls from frame results", async () => {
63
+ const { dispatchMessage, execute, host } = createHost();
64
+ const result = Promise.resolve(
65
+ execute({ query: "weather" }, executionContext),
66
+ );
67
+
68
+ dispatchMessage({
69
+ type: "tool-result",
70
+ id: "tool-0",
71
+ result: "sunny",
72
+ });
73
+
74
+ await expect(result).resolves.toBe("sunny");
75
+ expect(vi.getTimerCount()).toBe(0);
76
+ host.dispose();
77
+ });
78
+
79
+ it("rejects pending tool calls when disposed", async () => {
80
+ const { execute, host } = createHost();
81
+ const result = Promise.resolve(execute({}, executionContext));
82
+
83
+ host.dispose();
84
+
85
+ await expect(result).rejects.toThrow(
86
+ "AssistantFrameHost has been disposed",
87
+ );
88
+ expect(vi.getTimerCount()).toBe(0);
89
+ });
90
+
91
+ it("rejects tool calls made after disposal without posting a request", async () => {
92
+ const { execute, host, postMessage } = createHost();
93
+ host.dispose();
94
+
95
+ await expect(execute({}, executionContext)).rejects.toThrow(
96
+ "AssistantFrameHost has been disposed",
97
+ );
98
+ expect(postMessage).toHaveBeenCalledOnce();
99
+ });
100
+ });
@@ -53,6 +53,7 @@ export class AssistantFrameHost implements ModelContextProvider {
53
53
  private _requestCounter = 0;
54
54
  private _iframeWindow: Window;
55
55
  private _targetOrigin: string;
56
+ private _disposed = false;
56
57
 
57
58
  constructor(iframeWindow: Window, targetOrigin: string = "*") {
58
59
  this._iframeWindow = iframeWindow;
@@ -130,6 +131,10 @@ export class AssistantFrameHost implements ModelContextProvider {
130
131
  timeout = 30000,
131
132
  timeoutMessage = "Request timed out",
132
133
  ): Promise<any> {
134
+ if (this._disposed) {
135
+ return Promise.reject(new Error("AssistantFrameHost has been disposed"));
136
+ }
137
+
133
138
  return new Promise((resolve, reject) => {
134
139
  this._pendingRequests.set(message.id, { resolve, reject });
135
140
 
@@ -188,8 +193,13 @@ export class AssistantFrameHost implements ModelContextProvider {
188
193
  }
189
194
 
190
195
  dispose() {
196
+ this._disposed = true;
191
197
  window.removeEventListener("message", this.handleMessage);
192
198
  this._subscribers.clear();
199
+ const error = new Error("AssistantFrameHost has been disposed");
200
+ for (const pending of this._pendingRequests.values()) {
201
+ pending.reject(error);
202
+ }
193
203
  this._pendingRequests.clear();
194
204
  }
195
205
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { AssistantFrameProvider } from "./provider";
6
+ import { FRAME_MESSAGE_CHANNEL } from "./types";
7
+
8
+ describe("AssistantFrameProvider", () => {
9
+ let messageHandler: ((event: MessageEvent) => void) | undefined;
10
+ let parentWindow: Window;
11
+
12
+ beforeEach(() => {
13
+ parentWindow = {
14
+ postMessage: vi.fn(),
15
+ } as unknown as Window;
16
+
17
+ Object.defineProperty(window, "parent", {
18
+ value: parentWindow,
19
+ configurable: true,
20
+ });
21
+
22
+ vi.spyOn(window, "addEventListener").mockImplementation(
23
+ (event, listener) => {
24
+ if (event === "message" && typeof listener === "function") {
25
+ messageHandler = listener as (event: MessageEvent) => void;
26
+ }
27
+ },
28
+ );
29
+ vi.spyOn(window, "removeEventListener").mockImplementation(() => {});
30
+ });
31
+
32
+ afterEach(() => {
33
+ AssistantFrameProvider.dispose();
34
+ vi.restoreAllMocks();
35
+ });
36
+
37
+ it("only accepts tool calls from the parent window", async () => {
38
+ const execute = vi.fn(async () => "result");
39
+ AssistantFrameProvider.addModelContextProvider(
40
+ {
41
+ getModelContext: () => ({
42
+ tools: {
43
+ sensitiveTool: { execute },
44
+ },
45
+ }),
46
+ },
47
+ "https://parent.example",
48
+ );
49
+
50
+ const toolCall = {
51
+ channel: FRAME_MESSAGE_CHANNEL,
52
+ message: {
53
+ type: "tool-call",
54
+ id: "tool-call-1",
55
+ toolName: "sensitiveTool",
56
+ args: {},
57
+ },
58
+ };
59
+ const otherWindow = {
60
+ postMessage: vi.fn(),
61
+ } as unknown as Window;
62
+
63
+ messageHandler?.(
64
+ new MessageEvent("message", {
65
+ data: toolCall,
66
+ origin: "https://parent.example",
67
+ source: otherWindow,
68
+ }),
69
+ );
70
+
71
+ expect(execute).not.toHaveBeenCalled();
72
+
73
+ messageHandler?.(
74
+ new MessageEvent("message", {
75
+ data: toolCall,
76
+ origin: "https://parent.example",
77
+ source: parentWindow,
78
+ }),
79
+ );
80
+
81
+ await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce());
82
+ });
83
+ });
@@ -59,6 +59,7 @@ export class AssistantFrameProvider {
59
59
  private handleMessage(event: MessageEvent) {
60
60
  if (this._targetOrigin !== "*" && event.origin !== this._targetOrigin)
61
61
  return;
62
+ if (event.source !== window.parent) return;
62
63
  if (event.data?.channel !== FRAME_MESSAGE_CHANNEL) return;
63
64
 
64
65
  const message = event.data.message as FrameMessage;
@@ -207,6 +207,88 @@ describe("createLocalStorageAdapter", () => {
207
207
  ]);
208
208
  });
209
209
 
210
+ it("preserves concurrent metadata mutations across adapters", async () => {
211
+ const threadsKey = "@assistant-ui:threads";
212
+ const values = new Map<string, string>();
213
+ let metadataReads = 0;
214
+ let metadataWrites = 0;
215
+ let markFirstWriteStarted!: () => void;
216
+ let releaseFirstWrite!: () => void;
217
+ const firstWriteStarted = new Promise<void>((resolve) => {
218
+ markFirstWriteStarted = resolve;
219
+ });
220
+ const firstWriteCanFinish = new Promise<void>((resolve) => {
221
+ releaseFirstWrite = resolve;
222
+ });
223
+ const storage: AsyncStorageLike = {
224
+ getItem: async (key) => {
225
+ if (key === threadsKey) metadataReads += 1;
226
+ return values.get(key) ?? null;
227
+ },
228
+ setItem: async (key, value) => {
229
+ if (key === threadsKey) {
230
+ metadataWrites += 1;
231
+ if (metadataWrites === 1) {
232
+ markFirstWriteStarted();
233
+ await firstWriteCanFinish;
234
+ }
235
+ }
236
+ values.set(key, value);
237
+ },
238
+ removeItem: async (key) => {
239
+ values.delete(key);
240
+ },
241
+ };
242
+ const firstAdapter = createLocalStorageAdapter({ storage });
243
+ const secondAdapter = createLocalStorageAdapter({ storage });
244
+
245
+ const firstInitialization = firstAdapter.initialize("thread-1");
246
+ await firstWriteStarted;
247
+ const secondInitialization = secondAdapter.initialize("thread-2");
248
+ const readsWhileFirstWritePending = metadataReads;
249
+
250
+ releaseFirstWrite();
251
+ await Promise.all([firstInitialization, secondInitialization]);
252
+
253
+ expect(readsWhileFirstWritePending).toBe(1);
254
+ expect(JSON.parse(values.get(threadsKey) ?? "")).toEqual([
255
+ { remoteId: "thread-2", status: "regular" },
256
+ { remoteId: "thread-1", status: "regular" },
257
+ ]);
258
+ });
259
+
260
+ it("continues processing mutations after a storage failure", async () => {
261
+ const threadsKey = "@assistant-ui:threads";
262
+ const values = new Map<string, string>();
263
+ let shouldFail = true;
264
+ const storage: AsyncStorageLike = {
265
+ getItem: async (key) => values.get(key) ?? null,
266
+ setItem: async (key, value) => {
267
+ if (shouldFail) {
268
+ shouldFail = false;
269
+ throw new Error("Storage unavailable");
270
+ }
271
+ values.set(key, value);
272
+ },
273
+ removeItem: async (key) => {
274
+ values.delete(key);
275
+ },
276
+ };
277
+ const adapter = createLocalStorageAdapter({ storage });
278
+
279
+ await expect(adapter.initialize("thread-1")).rejects.toThrow(
280
+ "Storage unavailable",
281
+ );
282
+ await expect(adapter.initialize("thread-2")).resolves.toEqual({
283
+ remoteId: "thread-2",
284
+ externalId: undefined,
285
+ });
286
+
287
+ expect(JSON.parse(values.get(threadsKey) ?? "")).toEqual([
288
+ { remoteId: "thread-2", status: "regular" },
289
+ ]);
290
+ });
291
+
210
292
  it("includes the thread id when a stored thread cannot be fetched", async () => {
211
293
  const storage = createStorage({
212
294
  "@assistant-ui:threads": JSON.stringify([