@assistant-ui/core 0.3.14 → 0.3.15

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 (137) hide show
  1. package/dist/adapters/voice.js +11 -0
  2. package/dist/adapters/voice.js.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal.d.ts +2 -1
  5. package/dist/internal.js +2 -1
  6. package/dist/model-context/frame/host.d.ts +1 -1
  7. package/dist/model-context/frame/host.d.ts.map +1 -1
  8. package/dist/model-context/frame/host.js +17 -3
  9. package/dist/model-context/frame/host.js.map +1 -1
  10. package/dist/model-context/frame/provider.d.ts +4 -0
  11. package/dist/model-context/frame/provider.d.ts.map +1 -1
  12. package/dist/model-context/frame/provider.js +50 -3
  13. package/dist/model-context/frame/provider.js.map +1 -1
  14. package/dist/model-context/frame/types.d.ts +4 -1
  15. package/dist/model-context/frame/types.d.ts.map +1 -1
  16. package/dist/model-context/frame/types.js.map +1 -1
  17. package/dist/react/client/RemoteThreadList.d.ts +1 -1
  18. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  19. package/dist/react/client/RemoteThreadList.js +95 -23
  20. package/dist/react/client/RemoteThreadList.js.map +1 -1
  21. package/dist/react/primitive-hooks/useActionBarCopy.js +7 -1
  22. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  23. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts +3 -0
  24. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts.map +1 -1
  25. package/dist/react/primitive-hooks/useThreadIsEmpty.js +3 -0
  26. package/dist/react/primitive-hooks/useThreadIsEmpty.js.map +1 -1
  27. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts +3 -0
  28. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useThreadIsRunning.js +3 -0
  30. package/dist/react/primitive-hooks/useThreadIsRunning.js.map +1 -1
  31. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
  32. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  33. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +6 -0
  34. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  35. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -0
  36. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  37. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +192 -50
  38. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  39. package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
  40. package/dist/react/runtimes/RemoteThreadResource.js +2 -0
  41. package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
  42. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  43. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js +1 -1
  44. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  45. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  46. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +38 -5
  47. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  48. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  49. package/dist/runtime/utils/message-repository.js +27 -18
  50. package/dist/runtime/utils/message-repository.js.map +1 -1
  51. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  52. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -0
  53. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  54. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +102 -0
  55. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -0
  56. package/dist/runtimes/inert/InertThreadRuntimeCore.js +156 -0
  57. package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -0
  58. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  59. package/dist/runtimes/local/local-thread-runtime-core.js +25 -6
  60. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  61. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +12 -60
  62. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  63. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +10 -145
  64. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  65. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts +9 -0
  66. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts.map +1 -0
  67. package/dist/runtimes/remote-thread-list/adapter-changed.js +13 -0
  68. package/dist/runtimes/remote-thread-list/adapter-changed.js.map +1 -0
  69. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  70. package/dist/runtimes/remote-thread-list/empty-thread-core.js +16 -155
  71. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  72. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +2 -0
  73. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  74. package/dist/runtimes/remote-thread-list/optimistic-state.js +11 -0
  75. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  76. package/dist/runtimes/remote-thread-list/types.d.ts +3 -0
  77. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  78. package/dist/store/clients/external-thread.js +1 -1
  79. package/dist/store/clients/external-thread.js.map +1 -1
  80. package/dist/store/runtime-clients/handle-runtime-action.d.ts.map +1 -1
  81. package/dist/store/runtime-clients/handle-runtime-action.js +2 -0
  82. package/dist/store/runtime-clients/handle-runtime-action.js.map +1 -1
  83. package/dist/types/message.d.ts +1 -1
  84. package/dist/types/message.js.map +1 -1
  85. package/dist/utils/silent-runtime-action.d.ts +6 -0
  86. package/dist/utils/silent-runtime-action.d.ts.map +1 -0
  87. package/dist/utils/silent-runtime-action.js +7 -0
  88. package/dist/utils/silent-runtime-action.js.map +1 -0
  89. package/package.json +4 -4
  90. package/src/adapters/voice.test.ts +66 -1
  91. package/src/adapters/voice.ts +8 -0
  92. package/src/internal.ts +1 -0
  93. package/src/model-context/frame/host.test.ts +64 -5
  94. package/src/model-context/frame/host.ts +16 -3
  95. package/src/model-context/frame/provider.test.ts +347 -20
  96. package/src/model-context/frame/provider.ts +58 -2
  97. package/src/model-context/frame/types.ts +5 -0
  98. package/src/react/client/RemoteThreadList.test.ts +196 -0
  99. package/src/react/client/RemoteThreadList.ts +93 -24
  100. package/src/react/primitive-hooks/useActionBarCopy.test.ts +15 -0
  101. package/src/react/primitive-hooks/useActionBarCopy.ts +13 -3
  102. package/src/react/primitive-hooks/useThreadIsEmpty.ts +3 -0
  103. package/src/react/primitive-hooks/useThreadIsRunning.ts +3 -0
  104. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +12 -0
  105. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.republish.test.ts +87 -0
  106. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +268 -63
  107. package/src/react/runtimes/RemoteThreadResource.ts +2 -0
  108. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.test.tsx +58 -0
  109. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.ts +3 -1
  110. package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +98 -3
  111. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +68 -6
  112. package/src/react/runtimes/useLocalRuntime.test.tsx +5 -5
  113. package/src/runtime/utils/message-repository.ts +26 -18
  114. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +4 -0
  115. package/src/runtimes/inert/InertThreadRuntimeCore.test.ts +253 -0
  116. package/src/runtimes/inert/InertThreadRuntimeCore.ts +232 -0
  117. package/src/runtimes/local/local-thread-runtime-core.test.ts +319 -0
  118. package/src/runtimes/local/local-thread-runtime-core.ts +38 -8
  119. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +17 -209
  120. package/src/runtimes/remote-thread-list/adapter-changed.ts +10 -0
  121. package/src/runtimes/remote-thread-list/empty-thread-core.ts +28 -221
  122. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +18 -0
  123. package/src/runtimes/remote-thread-list/optimistic-state.ts +13 -0
  124. package/src/runtimes/remote-thread-list/types.ts +4 -0
  125. package/src/store/clients/external-thread.ts +1 -1
  126. package/src/store/runtime-clients/handle-runtime-action.test.ts +40 -0
  127. package/src/store/runtime-clients/handle-runtime-action.ts +3 -0
  128. package/src/tests/MessageRepository.test.ts +48 -0
  129. package/src/tests/RemoteThreadListThreadListRuntimeCore-errors.test.ts +2 -2
  130. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +8 -6
  131. package/src/tests/empty-thread-core.test.ts +6 -0
  132. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +20 -0
  133. package/src/tests/external-thread-isEmpty.test.tsx +100 -0
  134. package/src/tests/remote-thread-list-adapter-switch.test.ts +383 -0
  135. package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +67 -0
  136. package/src/types/message.ts +1 -1
  137. package/src/utils/silent-runtime-action.ts +6 -0
@@ -9,6 +9,45 @@ describe("AssistantFrameProvider", () => {
9
9
  let messageHandler: ((event: MessageEvent) => void) | undefined;
10
10
  let parentWindow: Window;
11
11
 
12
+ const dispatchToolCall = (
13
+ origin: string,
14
+ source: Window = parentWindow,
15
+ id = "tool-call-1",
16
+ ) => {
17
+ messageHandler?.(
18
+ new MessageEvent("message", {
19
+ data: {
20
+ channel: FRAME_MESSAGE_CHANNEL,
21
+ message: {
22
+ type: "tool-call",
23
+ id,
24
+ toolName: "sensitiveTool",
25
+ args: {},
26
+ },
27
+ },
28
+ origin,
29
+ source,
30
+ }),
31
+ );
32
+ };
33
+
34
+ const dispatchToolCancel = (
35
+ origin: string,
36
+ source: Window = parentWindow,
37
+ id = "tool-call-1",
38
+ ) => {
39
+ messageHandler?.(
40
+ new MessageEvent("message", {
41
+ data: {
42
+ channel: FRAME_MESSAGE_CHANNEL,
43
+ message: { type: "tool-cancel", id },
44
+ },
45
+ origin,
46
+ source,
47
+ }),
48
+ );
49
+ };
50
+
12
51
  beforeEach(() => {
13
52
  parentWindow = {
14
53
  postMessage: vi.fn(),
@@ -47,37 +86,325 @@ describe("AssistantFrameProvider", () => {
47
86
  "https://parent.example",
48
87
  );
49
88
 
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
89
  const otherWindow = {
60
90
  postMessage: vi.fn(),
61
91
  } as unknown as Window;
62
92
 
63
- messageHandler?.(
64
- new MessageEvent("message", {
65
- data: toolCall,
66
- origin: "https://parent.example",
67
- source: otherWindow,
93
+ dispatchToolCall("https://parent.example", otherWindow);
94
+
95
+ expect(execute).not.toHaveBeenCalled();
96
+
97
+ dispatchToolCall("https://parent.example");
98
+
99
+ await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce());
100
+ });
101
+
102
+ it("aborts in-flight tool calls when the parent cancels them", async () => {
103
+ let toolSignal: AbortSignal | undefined;
104
+ const execute = vi.fn(
105
+ async (_args: unknown, context: { abortSignal: AbortSignal }) => {
106
+ toolSignal = context.abortSignal;
107
+ await new Promise<never>((_resolve, reject) => {
108
+ context.abortSignal.addEventListener(
109
+ "abort",
110
+ () => reject(context.abortSignal.reason),
111
+ { once: true },
112
+ );
113
+ });
114
+ },
115
+ );
116
+ AssistantFrameProvider.addModelContextProvider({
117
+ getModelContext: () => ({
118
+ tools: { sensitiveTool: { execute } },
119
+ }),
120
+ });
121
+
122
+ dispatchToolCall("*");
123
+ await vi.waitFor(() => expect(toolSignal).toBeDefined());
124
+
125
+ dispatchToolCancel("*");
126
+
127
+ expect(toolSignal?.aborted).toBe(true);
128
+ await new Promise((resolve) => setTimeout(resolve, 0));
129
+ expect(parentWindow.postMessage).not.toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ message: expect.objectContaining({ type: "tool-result" }),
68
132
  }),
133
+ expect.anything(),
134
+ );
135
+ });
136
+
137
+ it("cancels only the matching in-flight tool call", async () => {
138
+ const signals = new Map<string, AbortSignal>();
139
+ const execute = vi.fn(
140
+ async (
141
+ _args: unknown,
142
+ context: { toolCallId: string; abortSignal: AbortSignal },
143
+ ) => {
144
+ signals.set(context.toolCallId, context.abortSignal);
145
+ await new Promise<never>((_resolve, reject) => {
146
+ context.abortSignal.addEventListener(
147
+ "abort",
148
+ () => reject(context.abortSignal.reason),
149
+ { once: true },
150
+ );
151
+ });
152
+ },
153
+ );
154
+ AssistantFrameProvider.addModelContextProvider({
155
+ getModelContext: () => ({ tools: { sensitiveTool: { execute } } }),
156
+ });
157
+
158
+ dispatchToolCall("*", parentWindow, "tool-a");
159
+ dispatchToolCall("*", parentWindow, "tool-b");
160
+ await vi.waitFor(() => expect(signals.size).toBe(2));
161
+
162
+ dispatchToolCancel("*", parentWindow, "tool-a");
163
+
164
+ expect(signals.get("tool-a")?.aborted).toBe(true);
165
+ expect(signals.get("tool-b")?.aborted).toBe(false);
166
+ });
167
+
168
+ it("aborts an earlier call when a duplicate ID arrives", async () => {
169
+ const signals: AbortSignal[] = [];
170
+ const execute = vi.fn(
171
+ async (_args: unknown, context: { abortSignal: AbortSignal }) => {
172
+ signals.push(context.abortSignal);
173
+ await new Promise<never>((_resolve, reject) => {
174
+ context.abortSignal.addEventListener(
175
+ "abort",
176
+ () => reject(context.abortSignal.reason),
177
+ { once: true },
178
+ );
179
+ });
180
+ },
181
+ );
182
+ AssistantFrameProvider.addModelContextProvider({
183
+ getModelContext: () => ({ tools: { sensitiveTool: { execute } } }),
184
+ });
185
+
186
+ dispatchToolCall("*", parentWindow, "duplicate");
187
+ await vi.waitFor(() => expect(signals).toHaveLength(1));
188
+ dispatchToolCall("*", parentWindow, "duplicate");
189
+ await vi.waitFor(() => expect(signals).toHaveLength(2));
190
+
191
+ expect(signals[0]?.aborted).toBe(true);
192
+ expect(signals[1]?.aborted).toBe(false);
193
+ });
194
+
195
+ it("aborts in-flight tool calls when the provider is disposed", async () => {
196
+ let toolSignal: AbortSignal | undefined;
197
+ const execute = vi.fn(
198
+ async (_args: unknown, context: { abortSignal: AbortSignal }) => {
199
+ toolSignal = context.abortSignal;
200
+ await new Promise<never>((_resolve, reject) => {
201
+ context.abortSignal.addEventListener(
202
+ "abort",
203
+ () => reject(context.abortSignal.reason),
204
+ { once: true },
205
+ );
206
+ });
207
+ },
208
+ );
209
+ AssistantFrameProvider.addModelContextProvider({
210
+ getModelContext: () => ({ tools: { sensitiveTool: { execute } } }),
211
+ });
212
+
213
+ dispatchToolCall("*");
214
+ await vi.waitFor(() => expect(toolSignal).toBeDefined());
215
+
216
+ AssistantFrameProvider.dispose();
217
+
218
+ expect(toolSignal?.aborted).toBe(true);
219
+ expect(parentWindow.postMessage).toHaveBeenCalledWith(
220
+ {
221
+ channel: FRAME_MESSAGE_CHANNEL,
222
+ message: {
223
+ type: "tool-result",
224
+ id: "tool-call-1",
225
+ error: "AssistantFrameProvider has been disposed",
226
+ },
227
+ },
228
+ { targetOrigin: "*" },
229
+ );
230
+ await new Promise((resolve) => setTimeout(resolve, 0));
231
+ const toolResults = vi
232
+ .mocked(parentWindow.postMessage)
233
+ .mock.calls.filter(
234
+ ([data]) =>
235
+ (data as { message?: { type?: string } }).message?.type ===
236
+ "tool-result",
237
+ );
238
+ expect(toolResults).toHaveLength(1);
239
+ });
240
+
241
+ it("upgrades a wildcard origin policy when a strict provider registers", async () => {
242
+ AssistantFrameProvider.addModelContextProvider(
243
+ { getModelContext: () => ({}) },
244
+ "*",
69
245
  );
70
246
 
247
+ const execute = vi.fn(async () => "result");
248
+ AssistantFrameProvider.addModelContextProvider(
249
+ {
250
+ getModelContext: () => ({
251
+ tools: {
252
+ sensitiveTool: { execute },
253
+ },
254
+ }),
255
+ },
256
+ "https://parent.example",
257
+ );
258
+
259
+ expect(parentWindow.postMessage).toHaveBeenLastCalledWith(
260
+ expect.anything(),
261
+ "https://parent.example",
262
+ );
263
+
264
+ dispatchToolCall("https://untrusted.example");
265
+
71
266
  expect(execute).not.toHaveBeenCalled();
72
267
 
73
- messageHandler?.(
74
- new MessageEvent("message", {
75
- data: toolCall,
76
- origin: "https://parent.example",
77
- source: parentWindow,
78
- }),
268
+ dispatchToolCall("https://parent.example");
269
+
270
+ await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce());
271
+ });
272
+
273
+ it("does not downgrade a strict origin policy for a wildcard provider", async () => {
274
+ const execute = vi.fn(async () => "result");
275
+ AssistantFrameProvider.addModelContextProvider(
276
+ {
277
+ getModelContext: () => ({
278
+ tools: {
279
+ sensitiveTool: { execute },
280
+ },
281
+ }),
282
+ },
283
+ "https://parent.example",
284
+ );
285
+ AssistantFrameProvider.addModelContextProvider(
286
+ { getModelContext: () => ({}) },
287
+ "*",
288
+ );
289
+
290
+ dispatchToolCall("https://untrusted.example");
291
+
292
+ expect(execute).not.toHaveBeenCalled();
293
+
294
+ dispatchToolCall("https://parent.example");
295
+
296
+ await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce());
297
+ });
298
+
299
+ it("rejects conflicting strict origin policies", () => {
300
+ AssistantFrameProvider.addModelContextProvider(
301
+ { getModelContext: () => ({}) },
302
+ "https://first.example",
303
+ );
304
+
305
+ expect(() =>
306
+ AssistantFrameProvider.addModelContextProvider(
307
+ { getModelContext: () => ({}) },
308
+ "https://second.example",
309
+ ),
310
+ ).toThrow(
311
+ 'AssistantFrameProvider cannot register conflicting target origins: "https://first.example" and "https://second.example"',
312
+ );
313
+ });
314
+
315
+ it("resets the origin policy after every provider unsubscribes", () => {
316
+ const unsubscribe = AssistantFrameProvider.addModelContextProvider(
317
+ { getModelContext: () => ({}) },
318
+ "https://first.example",
319
+ );
320
+
321
+ unsubscribe();
322
+
323
+ expect(parentWindow.postMessage).toHaveBeenLastCalledWith(
324
+ expect.anything(),
325
+ "*",
326
+ );
327
+
328
+ expect(() =>
329
+ AssistantFrameProvider.addModelContextProvider(
330
+ { getModelContext: () => ({}) },
331
+ "https://second.example",
332
+ ),
333
+ ).not.toThrow();
334
+ expect(parentWindow.postMessage).toHaveBeenLastCalledWith(
335
+ expect.anything(),
336
+ "https://second.example",
79
337
  );
338
+ });
339
+
340
+ it("treats a second unsubscribe as a no-op", () => {
341
+ const unsubscribe = AssistantFrameProvider.addModelContextProvider(
342
+ { getModelContext: () => ({}) },
343
+ "https://first.example",
344
+ );
345
+
346
+ unsubscribe();
347
+ unsubscribe();
348
+
349
+ const unsubscribeSecond = AssistantFrameProvider.addModelContextProvider(
350
+ { getModelContext: () => ({}) },
351
+ "https://second.example",
352
+ );
353
+ expect(parentWindow.postMessage).toHaveBeenLastCalledWith(
354
+ expect.anything(),
355
+ "https://second.example",
356
+ );
357
+
358
+ unsubscribeSecond();
359
+ unsubscribeSecond();
360
+
361
+ expect(parentWindow.postMessage).toHaveBeenLastCalledWith(
362
+ expect.anything(),
363
+ "*",
364
+ );
365
+ });
366
+
367
+ it("recomputes the origin policy from providers that remain", () => {
368
+ AssistantFrameProvider.addModelContextProvider(
369
+ { getModelContext: () => ({}) },
370
+ "*",
371
+ );
372
+ const unsubscribeStrict = AssistantFrameProvider.addModelContextProvider(
373
+ { getModelContext: () => ({}) },
374
+ "https://parent.example",
375
+ );
376
+
377
+ unsubscribeStrict();
378
+
379
+ expect(parentWindow.postMessage).toHaveBeenLastCalledWith(
380
+ expect.anything(),
381
+ "*",
382
+ );
383
+ });
384
+
385
+ it("keeps a shared strict origin after one provider unsubscribes", async () => {
386
+ const unsubscribeFirst = AssistantFrameProvider.addModelContextProvider(
387
+ { getModelContext: () => ({}) },
388
+ "https://parent.example",
389
+ );
390
+ const execute = vi.fn(async () => "result");
391
+ AssistantFrameProvider.addModelContextProvider(
392
+ {
393
+ getModelContext: () => ({
394
+ tools: {
395
+ sensitiveTool: { execute },
396
+ },
397
+ }),
398
+ },
399
+ "https://parent.example",
400
+ );
401
+
402
+ unsubscribeFirst();
403
+
404
+ dispatchToolCall("https://untrusted.example");
405
+ expect(execute).not.toHaveBeenCalled();
80
406
 
407
+ dispatchToolCall("https://parent.example");
81
408
  await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce());
82
409
  });
83
410
  });
@@ -37,7 +37,12 @@ export class AssistantFrameProvider {
37
37
  ModelContextProvider,
38
38
  Unsubscribe | undefined
39
39
  >();
40
+ private _activeToolCalls = new Map<
41
+ string,
42
+ { abortController: AbortController; event: MessageEvent }
43
+ >();
40
44
  private _targetOrigin: string;
45
+ private _strictRegistrations = 0;
41
46
 
42
47
  private constructor(targetOrigin: string = "*") {
43
48
  this._targetOrigin = targetOrigin;
@@ -52,10 +57,25 @@ export class AssistantFrameProvider {
52
57
  AssistantFrameProvider._instance = new AssistantFrameProvider(
53
58
  targetOrigin,
54
59
  );
60
+ } else {
61
+ AssistantFrameProvider._instance.reconcileTargetOrigin(targetOrigin);
55
62
  }
56
63
  return AssistantFrameProvider._instance;
57
64
  }
58
65
 
66
+ private reconcileTargetOrigin(targetOrigin: string = "*") {
67
+ if (targetOrigin === "*" || targetOrigin === this._targetOrigin) return;
68
+
69
+ if (this._targetOrigin === "*") {
70
+ this._targetOrigin = targetOrigin;
71
+ return;
72
+ }
73
+
74
+ throw new Error(
75
+ `AssistantFrameProvider cannot register conflicting target origins: "${this._targetOrigin}" and "${targetOrigin}"`,
76
+ );
77
+ }
78
+
59
79
  private handleMessage(event: MessageEvent) {
60
80
  if (this._targetOrigin !== "*" && event.origin !== this._targetOrigin)
61
81
  return;
@@ -75,6 +95,10 @@ export class AssistantFrameProvider {
75
95
  case "tool-call":
76
96
  this.handleToolCall(message, event);
77
97
  break;
98
+
99
+ case "tool-cancel":
100
+ this.cancelToolCall(message.id);
101
+ break;
78
102
  }
79
103
  }
80
104
 
@@ -83,6 +107,10 @@ export class AssistantFrameProvider {
83
107
  event: MessageEvent,
84
108
  ) {
85
109
  const tool = this.getModelContext().tools?.[message.toolName];
110
+ const abortController = new AbortController();
111
+ this._activeToolCalls.get(message.id)?.abortController.abort();
112
+ const activeCall = { abortController, event };
113
+ this._activeToolCalls.set(message.id, activeCall);
86
114
 
87
115
  let result: any;
88
116
  let error: string | undefined;
@@ -94,7 +122,7 @@ export class AssistantFrameProvider {
94
122
  result = tool.execute
95
123
  ? await tool.execute(message.args, {
96
124
  toolCallId: message.id,
97
- abortSignal: new AbortController().signal,
125
+ abortSignal: abortController.signal,
98
126
  human: async () => {
99
127
  throw new Error(
100
128
  "Tool human input is not supported in frame context",
@@ -107,6 +135,9 @@ export class AssistantFrameProvider {
107
135
  }
108
136
  }
109
137
 
138
+ if (this._activeToolCalls.get(message.id) !== activeCall) return;
139
+ this._activeToolCalls.delete(message.id);
140
+
110
141
  this.sendMessage(event, {
111
142
  type: "tool-result",
112
143
  id: message.id,
@@ -114,6 +145,13 @@ export class AssistantFrameProvider {
114
145
  });
115
146
  }
116
147
 
148
+ private cancelToolCall(id: string) {
149
+ const activeCall = this._activeToolCalls.get(id);
150
+ if (!activeCall) return;
151
+ this._activeToolCalls.delete(id);
152
+ activeCall.abortController.abort();
153
+ }
154
+
117
155
  private sendMessage(event: MessageEvent, message: FrameMessage) {
118
156
  event.source?.postMessage(
119
157
  { channel: FRAME_MESSAGE_CHANNEL, message },
@@ -157,8 +195,10 @@ export class AssistantFrameProvider {
157
195
  provider: ModelContextProvider,
158
196
  targetOrigin?: string,
159
197
  ): Unsubscribe {
160
- const instance = AssistantFrameProvider.getInstance(targetOrigin);
198
+ const origin = targetOrigin ?? "*";
199
+ const instance = AssistantFrameProvider.getInstance(origin);
161
200
  instance._providers.add(provider);
201
+ if (origin !== "*") instance._strictRegistrations += 1;
162
202
 
163
203
  const unsubscribe = provider.subscribe?.(() => instance.broadcastUpdate());
164
204
  if (unsubscribe) {
@@ -167,10 +207,17 @@ export class AssistantFrameProvider {
167
207
 
168
208
  instance.broadcastUpdate();
169
209
 
210
+ let released = false;
170
211
  return () => {
212
+ if (released) return;
213
+ released = true;
171
214
  instance._providers.delete(provider);
172
215
  instance._providerUnsubscribes.get(provider)?.();
173
216
  instance._providerUnsubscribes.delete(provider);
217
+ if (origin !== "*") {
218
+ instance._strictRegistrations -= 1;
219
+ if (instance._strictRegistrations === 0) instance._targetOrigin = "*";
220
+ }
174
221
  instance.broadcastUpdate();
175
222
  };
176
223
  }
@@ -183,6 +230,15 @@ export class AssistantFrameProvider {
183
230
  instance._providerUnsubscribes.forEach((unsubscribe) => unsubscribe?.());
184
231
  instance._providerUnsubscribes.clear();
185
232
  instance._providers.clear();
233
+ instance._activeToolCalls.forEach(({ abortController, event }, id) => {
234
+ abortController.abort();
235
+ instance.sendMessage(event, {
236
+ type: "tool-result",
237
+ id,
238
+ error: "AssistantFrameProvider has been disposed",
239
+ });
240
+ });
241
+ instance._activeToolCalls.clear();
186
242
 
187
243
  AssistantFrameProvider._instance = null;
188
244
  }
@@ -14,6 +14,7 @@ export type FrameMessageType =
14
14
  | "model-context-request"
15
15
  | "model-context-update"
16
16
  | "tool-call"
17
+ | "tool-cancel"
17
18
  | "tool-result";
18
19
 
19
20
  export type FrameMessage =
@@ -30,6 +31,10 @@ export type FrameMessage =
30
31
  toolName: string;
31
32
  args: unknown;
32
33
  }
34
+ | {
35
+ type: "tool-cancel";
36
+ id: string;
37
+ }
33
38
  | {
34
39
  type: "tool-result";
35
40
  id: string;