@assistant-ui/react 0.10.43 → 0.10.45

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 (65) hide show
  1. package/dist/model-context/frame/AssistantFrameHost.d.ts +37 -0
  2. package/dist/model-context/frame/AssistantFrameHost.d.ts.map +1 -0
  3. package/dist/model-context/frame/AssistantFrameHost.js +151 -0
  4. package/dist/model-context/frame/AssistantFrameHost.js.map +1 -0
  5. package/dist/model-context/frame/AssistantFrameProvider.d.ts +41 -0
  6. package/dist/model-context/frame/AssistantFrameProvider.d.ts.map +1 -0
  7. package/dist/model-context/frame/AssistantFrameProvider.js +142 -0
  8. package/dist/model-context/frame/AssistantFrameProvider.js.map +1 -0
  9. package/dist/model-context/frame/AssistantFrameTypes.d.ts +29 -0
  10. package/dist/model-context/frame/AssistantFrameTypes.d.ts.map +1 -0
  11. package/dist/model-context/frame/AssistantFrameTypes.js +6 -0
  12. package/dist/model-context/frame/AssistantFrameTypes.js.map +1 -0
  13. package/dist/model-context/frame/index.d.ts +5 -0
  14. package/dist/model-context/frame/index.d.ts.map +1 -0
  15. package/dist/model-context/frame/index.js +6 -0
  16. package/dist/model-context/frame/index.js.map +1 -0
  17. package/dist/model-context/frame/useAssistantFrameHost.d.ts +28 -0
  18. package/dist/model-context/frame/useAssistantFrameHost.d.ts.map +1 -0
  19. package/dist/model-context/frame/useAssistantFrameHost.js +25 -0
  20. package/dist/model-context/frame/useAssistantFrameHost.js.map +1 -0
  21. package/dist/model-context/index.d.ts +2 -0
  22. package/dist/model-context/index.d.ts.map +1 -1
  23. package/dist/model-context/index.js +2 -0
  24. package/dist/model-context/index.js.map +1 -1
  25. package/dist/model-context/registry/ModelContextRegistry.d.ts +19 -0
  26. package/dist/model-context/registry/ModelContextRegistry.d.ts.map +1 -0
  27. package/dist/model-context/registry/ModelContextRegistry.js +117 -0
  28. package/dist/model-context/registry/ModelContextRegistry.js.map +1 -0
  29. package/dist/model-context/registry/ModelContextRegistryHandles.d.ts +14 -0
  30. package/dist/model-context/registry/ModelContextRegistryHandles.d.ts.map +1 -0
  31. package/dist/model-context/registry/ModelContextRegistryHandles.js +1 -0
  32. package/dist/model-context/registry/ModelContextRegistryHandles.js.map +1 -0
  33. package/dist/model-context/registry/index.d.ts +3 -0
  34. package/dist/model-context/registry/index.d.ts.map +1 -0
  35. package/dist/model-context/registry/index.js +4 -0
  36. package/dist/model-context/registry/index.js.map +1 -0
  37. package/dist/model-context/useAssistantInstructions.d.ts +1 -2
  38. package/dist/model-context/useAssistantInstructions.d.ts.map +1 -1
  39. package/dist/model-context/useAssistantInstructions.js.map +1 -1
  40. package/dist/runtimes/composer/BaseComposerRuntimeCore.d.ts.map +1 -1
  41. package/dist/runtimes/composer/BaseComposerRuntimeCore.js +5 -4
  42. package/dist/runtimes/composer/BaseComposerRuntimeCore.js.map +1 -1
  43. package/dist/runtimes/external-store/ExternalStoreThreadRuntimeCore.d.ts +4 -1
  44. package/dist/runtimes/external-store/ExternalStoreThreadRuntimeCore.d.ts.map +1 -1
  45. package/dist/runtimes/external-store/ExternalStoreThreadRuntimeCore.js +25 -10
  46. package/dist/runtimes/external-store/ExternalStoreThreadRuntimeCore.js.map +1 -1
  47. package/dist/types/MessagePartTypes.d.ts +2 -0
  48. package/dist/types/MessagePartTypes.d.ts.map +1 -1
  49. package/package.json +3 -3
  50. package/src/model-context/frame/AssistantFrame.test.ts +353 -0
  51. package/src/model-context/frame/AssistantFrameHost.ts +218 -0
  52. package/src/model-context/frame/AssistantFrameProvider.ts +225 -0
  53. package/src/model-context/frame/AssistantFrameTypes.ts +40 -0
  54. package/src/model-context/frame/SPEC_AssistantFrame.md +104 -0
  55. package/src/model-context/frame/index.ts +4 -0
  56. package/src/model-context/frame/useAssistantFrameHost.ts +48 -0
  57. package/src/model-context/index.ts +3 -0
  58. package/src/model-context/registry/ModelContextRegistry.ts +165 -0
  59. package/src/model-context/registry/ModelContextRegistryHandles.ts +19 -0
  60. package/src/model-context/registry/SPEC_ModelContextRegistry.md +40 -0
  61. package/src/model-context/registry/index.ts +2 -0
  62. package/src/model-context/useAssistantInstructions.tsx +1 -1
  63. package/src/runtimes/composer/BaseComposerRuntimeCore.tsx +5 -4
  64. package/src/runtimes/external-store/ExternalStoreThreadRuntimeCore.tsx +29 -11
  65. package/src/types/MessagePartTypes.ts +2 -0
@@ -0,0 +1,353 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
5
+ import { AssistantFrameProvider } from "./AssistantFrameProvider";
6
+ import { AssistantFrameHost } from "./AssistantFrameHost";
7
+ import { ModelContextRegistry } from "../registry/ModelContextRegistry";
8
+ import z from "zod";
9
+
10
+ describe("AssistantFrame Integration", () => {
11
+ let messageHandlers: Map<string, (event: MessageEvent) => void>;
12
+ let iframeWindow: Window;
13
+ let parentWindow: any;
14
+
15
+ beforeEach(() => {
16
+ messageHandlers = new Map();
17
+
18
+ // Create a mock parent window that the iframe can post back to
19
+ parentWindow = {
20
+ postMessage: vi.fn((data: any) => {
21
+ // When iframe posts to parent, deliver to parent handler
22
+ const parentHandler = messageHandlers.get("parent");
23
+ if (parentHandler) {
24
+ Promise.resolve().then(() => {
25
+ parentHandler({
26
+ data,
27
+ source: iframeWindow,
28
+ origin: "*",
29
+ } as MessageEvent);
30
+ });
31
+ }
32
+ }),
33
+ };
34
+
35
+ // Create mock iframe window with proper message routing
36
+ iframeWindow = {
37
+ postMessage: vi.fn((data: any) => {
38
+ // Route message to iframe handler (provider)
39
+ const iframeHandler = messageHandlers.get("iframe");
40
+ if (iframeHandler) {
41
+ Promise.resolve().then(() => {
42
+ iframeHandler({
43
+ data,
44
+ source: parentWindow, // parent window is the source for subscription
45
+ origin: "*",
46
+ } as MessageEvent);
47
+ });
48
+ }
49
+ }),
50
+ } as any;
51
+
52
+ // Mock window.parent for iframe to broadcast to
53
+ Object.defineProperty(window, "parent", {
54
+ value: parentWindow,
55
+ writable: true,
56
+ configurable: true,
57
+ });
58
+
59
+ // Mock window methods for message passing
60
+ vi.spyOn(window, "addEventListener").mockImplementation(
61
+ (event: string, handler: any) => {
62
+ if (event === "message") {
63
+ // Store both handlers - we'll determine which is which based on usage
64
+ if (!messageHandlers.has("iframe")) {
65
+ messageHandlers.set("iframe", handler); // First registration is provider
66
+ } else {
67
+ messageHandlers.set("parent", handler); // Second is host
68
+ }
69
+ }
70
+ },
71
+ );
72
+
73
+ vi.spyOn(window, "removeEventListener").mockImplementation(() => {});
74
+
75
+ vi.spyOn(window, "postMessage").mockImplementation(() => {
76
+ // This shouldn't be called in our test setup
77
+ });
78
+ });
79
+
80
+ afterEach(() => {
81
+ // Clean up
82
+ vi.restoreAllMocks();
83
+ AssistantFrameProvider.dispose();
84
+ messageHandlers.clear();
85
+ });
86
+
87
+ it("should establish connection between host and provider", async () => {
88
+ // Setup provider in iframe
89
+ const registry = new ModelContextRegistry();
90
+ const unsubscribe =
91
+ AssistantFrameProvider.addModelContextProvider(registry);
92
+
93
+ // Setup host in parent
94
+ const host = new AssistantFrameHost(iframeWindow);
95
+
96
+ // Wait for connection
97
+ await vi.waitFor(() => {
98
+ expect(iframeWindow.postMessage).toHaveBeenCalledWith(
99
+ expect.objectContaining({
100
+ channel: "assistant-ui-frame",
101
+ message: expect.objectContaining({
102
+ type: "model-context-request",
103
+ }),
104
+ }),
105
+ "*",
106
+ );
107
+ });
108
+
109
+ // Clean up
110
+ host.dispose();
111
+ unsubscribe();
112
+ });
113
+
114
+ it("should sync tools from provider to host", async () => {
115
+ // Setup provider with tools
116
+ const registry = new ModelContextRegistry();
117
+
118
+ const toolExecute = vi.fn().mockResolvedValue({ result: "search results" });
119
+ registry.addTool({
120
+ toolName: "search",
121
+ description: "Search the web",
122
+ parameters: z.object({ query: z.string() }),
123
+ execute: toolExecute,
124
+ });
125
+
126
+ const unsubscribe =
127
+ AssistantFrameProvider.addModelContextProvider(registry);
128
+
129
+ // Setup host
130
+ const host = new AssistantFrameHost(iframeWindow);
131
+
132
+ // Wait for connection and initial sync
133
+ await vi.waitFor(() => {
134
+ const context = host.getModelContext();
135
+ expect(context.tools).toBeDefined();
136
+ expect(context.tools?.search).toBeDefined();
137
+ expect(context.tools?.search.description).toBe("Search the web");
138
+ });
139
+
140
+ // Clean up
141
+ host.dispose();
142
+ unsubscribe();
143
+ });
144
+
145
+ it("should execute tools through the frame boundary", async () => {
146
+ // Setup provider with executable tool
147
+ const registry = new ModelContextRegistry();
148
+
149
+ const toolExecute = vi
150
+ .fn()
151
+ .mockResolvedValue({ results: ["result1", "result2"] });
152
+ registry.addTool({
153
+ toolName: "search",
154
+ description: "Search the web",
155
+ parameters: z.object({ query: z.string() }),
156
+ execute: toolExecute,
157
+ });
158
+
159
+ const unsubscribe =
160
+ AssistantFrameProvider.addModelContextProvider(registry);
161
+
162
+ // Setup host
163
+ const host = new AssistantFrameHost(iframeWindow);
164
+
165
+ // Wait for tools to be available
166
+ await vi.waitFor(() => {
167
+ const context = host.getModelContext();
168
+ expect(context.tools?.search).toBeDefined();
169
+ });
170
+
171
+ // Execute tool through host
172
+ const context = host.getModelContext();
173
+ const searchTool = context.tools?.search;
174
+
175
+ const resultPromise = searchTool!.execute!(
176
+ { query: "test query" },
177
+ {} as any,
178
+ );
179
+
180
+ // Wait for tool execution
181
+ await vi.waitFor(() => {
182
+ expect(toolExecute).toHaveBeenCalledWith(
183
+ { query: "test query" },
184
+ expect.objectContaining({
185
+ toolCallId: expect.any(String),
186
+ abortSignal: expect.any(AbortSignal),
187
+ }),
188
+ );
189
+ });
190
+
191
+ const result = await resultPromise;
192
+ expect(result).toEqual({ results: ["result1", "result2"] });
193
+
194
+ // Clean up
195
+ host.dispose();
196
+ unsubscribe();
197
+ });
198
+
199
+ it("should handle tool execution errors", async () => {
200
+ // Setup provider with failing tool
201
+ const registry = new ModelContextRegistry();
202
+
203
+ const toolExecute = vi
204
+ .fn()
205
+ .mockRejectedValue(new Error("Tool execution failed"));
206
+ registry.addTool({
207
+ toolName: "failingTool",
208
+ description: "A tool that fails",
209
+ parameters: z.object({ input: z.string() }),
210
+ execute: toolExecute,
211
+ });
212
+
213
+ const unsubscribe =
214
+ AssistantFrameProvider.addModelContextProvider(registry);
215
+
216
+ // Setup host
217
+ const host = new AssistantFrameHost(iframeWindow);
218
+
219
+ // Wait for tools to be available
220
+ await vi.waitFor(() => {
221
+ const context = host.getModelContext();
222
+ expect(context.tools?.failingTool).toBeDefined();
223
+ });
224
+
225
+ // Execute tool and expect error
226
+ const context = host.getModelContext();
227
+ const failingTool = context.tools?.failingTool;
228
+
229
+ await expect(
230
+ failingTool!.execute!({ input: "test" }, {} as any),
231
+ ).rejects.toThrow("Tool execution failed");
232
+
233
+ // Clean up
234
+ host.dispose();
235
+ unsubscribe();
236
+ });
237
+
238
+ it("should handle multiple providers", async () => {
239
+ // Setup multiple providers
240
+ const registry1 = new ModelContextRegistry();
241
+ const registry2 = new ModelContextRegistry();
242
+
243
+ registry1.addTool({
244
+ toolName: "tool1",
245
+ description: "First tool",
246
+ parameters: z.object({ input: z.string() }),
247
+ execute: async () => ({ from: "tool1" }),
248
+ });
249
+
250
+ registry2.addTool({
251
+ toolName: "tool2",
252
+ description: "Second tool",
253
+ parameters: z.object({ input: z.string() }),
254
+ execute: async () => ({ from: "tool2" }),
255
+ });
256
+
257
+ const unsub1 = AssistantFrameProvider.addModelContextProvider(registry1);
258
+ const unsub2 = AssistantFrameProvider.addModelContextProvider(registry2);
259
+
260
+ // Setup host
261
+ const host = new AssistantFrameHost(iframeWindow);
262
+
263
+ // Wait for both tools to be available
264
+ await vi.waitFor(() => {
265
+ const context = host.getModelContext();
266
+ expect(context.tools?.tool1).toBeDefined();
267
+ expect(context.tools?.tool2).toBeDefined();
268
+ });
269
+
270
+ // Clean up
271
+ host.dispose();
272
+ unsub1();
273
+ unsub2();
274
+ });
275
+
276
+ it("should merge system instructions from multiple providers", async () => {
277
+ // Setup providers with system instructions
278
+ const registry1 = new ModelContextRegistry();
279
+ const registry2 = new ModelContextRegistry();
280
+
281
+ registry1.addInstruction("You are a helpful assistant.");
282
+ registry2.addInstruction("Always be concise.");
283
+
284
+ const unsub1 = AssistantFrameProvider.addModelContextProvider(registry1);
285
+ const unsub2 = AssistantFrameProvider.addModelContextProvider(registry2);
286
+
287
+ // Setup host
288
+ const host = new AssistantFrameHost(iframeWindow);
289
+
290
+ // Wait for instructions to be synced
291
+ await vi.waitFor(() => {
292
+ const context = host.getModelContext();
293
+ expect(context.system).toBeDefined();
294
+ expect(context.system).toContain("You are a helpful assistant.");
295
+ expect(context.system).toContain("Always be concise.");
296
+ });
297
+
298
+ // Clean up
299
+ host.dispose();
300
+ unsub1();
301
+ unsub2();
302
+ });
303
+
304
+ it("should act as empty ModelContextProvider when iframe has no providers", async () => {
305
+ // Don't register any providers in the iframe
306
+ // This simulates an iframe that doesn't respond to model-context requests
307
+
308
+ // Setup host
309
+ const host = new AssistantFrameHost(iframeWindow);
310
+
311
+ // Host should immediately return empty context
312
+ const context = host.getModelContext();
313
+ expect(context).toEqual({});
314
+
315
+ // Wait a bit to ensure no errors occur
316
+ await new Promise((resolve) => setTimeout(resolve, 100));
317
+
318
+ // Context should still be empty
319
+ expect(host.getModelContext()).toEqual({});
320
+
321
+ // Clean up
322
+ host.dispose();
323
+ });
324
+
325
+ it("should clean up properly on dispose", async () => {
326
+ // Setup provider
327
+ const registry = new ModelContextRegistry();
328
+
329
+ const unsubscribe =
330
+ AssistantFrameProvider.addModelContextProvider(registry);
331
+
332
+ // Setup host
333
+ const host = new AssistantFrameHost(iframeWindow);
334
+
335
+ // Wait for connection
336
+ await vi.waitFor(() => {
337
+ expect(iframeWindow.postMessage).toHaveBeenCalled();
338
+ });
339
+
340
+ // Dispose host
341
+ host.dispose();
342
+
343
+ // Verify event listener was removed (no unsubscribe message in new design)
344
+ expect(window.removeEventListener).toHaveBeenCalledWith(
345
+ "message",
346
+ expect.any(Function),
347
+ );
348
+
349
+ // Clean up provider
350
+ unsubscribe();
351
+ AssistantFrameProvider.dispose();
352
+ });
353
+ });
@@ -0,0 +1,218 @@
1
+ import {
2
+ ModelContextProvider,
3
+ ModelContext,
4
+ } from "../../model-context/ModelContextTypes";
5
+ import { Unsubscribe } from "../../types/Unsubscribe";
6
+ import { Tool } from "assistant-stream";
7
+ import {
8
+ FrameMessage,
9
+ FRAME_MESSAGE_CHANNEL,
10
+ SerializedModelContext,
11
+ SerializedTool,
12
+ } from "./AssistantFrameTypes";
13
+
14
+ /**
15
+ * Deserializes tools from JSON Schema format back to Tool objects
16
+ */
17
+ const deserializeTool = (serializedTool: SerializedTool): Tool<any, any> =>
18
+ ({
19
+ parameters: serializedTool.parameters,
20
+ ...(serializedTool.description && {
21
+ description: serializedTool.description,
22
+ }),
23
+ ...(serializedTool.disabled !== undefined && {
24
+ disabled: serializedTool.disabled,
25
+ }),
26
+ ...(serializedTool.type && { type: serializedTool.type }),
27
+ }) as Tool<any, any>;
28
+
29
+ /**
30
+ * Deserializes a ModelContext from transmission format
31
+ */
32
+ const deserializeModelContext = (
33
+ serialized: SerializedModelContext,
34
+ ): ModelContext => ({
35
+ ...(serialized.system !== undefined && { system: serialized.system }),
36
+ ...(serialized.tools && {
37
+ tools: Object.fromEntries(
38
+ Object.entries(serialized.tools).map(([name, tool]) => [
39
+ name,
40
+ deserializeTool(tool),
41
+ ]),
42
+ ),
43
+ }),
44
+ });
45
+
46
+ /**
47
+ * AssistantFrameHost - Runs in the parent window and acts as a ModelContextProvider
48
+ * that receives context from an iframe's AssistantFrameProvider.
49
+ *
50
+ * Usage example:
51
+ * ```typescript
52
+ * // In parent window
53
+ * const frameHost = new AssistantFrameHost(iframeWindow);
54
+ *
55
+ * // Register with assistant runtime
56
+ * const runtime = useAssistantRuntime();
57
+ * runtime.registerModelContextProvider(frameHost);
58
+ *
59
+ * // The assistant now has access to tools from the iframe
60
+ * ```
61
+ */
62
+ export class AssistantFrameHost implements ModelContextProvider {
63
+ private _context: ModelContext = {};
64
+ private _subscribers = new Set<() => void>();
65
+ private _pendingRequests = new Map<
66
+ string,
67
+ {
68
+ resolve: (value: any) => void;
69
+ reject: (error: any) => void;
70
+ }
71
+ >();
72
+ private _requestCounter = 0;
73
+ private _iframeWindow: Window;
74
+ private _targetOrigin: string;
75
+
76
+ constructor(iframeWindow: Window, targetOrigin: string = "*") {
77
+ this._iframeWindow = iframeWindow;
78
+ this._targetOrigin = targetOrigin;
79
+
80
+ this.handleMessage = this.handleMessage.bind(this);
81
+ window.addEventListener("message", this.handleMessage);
82
+
83
+ // Request initial context
84
+ this.requestContext();
85
+ }
86
+
87
+ private handleMessage(event: MessageEvent) {
88
+ // Security: Validate origin and source
89
+ if (this._targetOrigin !== "*" && event.origin !== this._targetOrigin)
90
+ return;
91
+ if (event.source !== this._iframeWindow) return;
92
+ if (event.data?.channel !== FRAME_MESSAGE_CHANNEL) return;
93
+
94
+ const message = event.data.message as FrameMessage;
95
+
96
+ switch (message.type) {
97
+ case "model-context-update": {
98
+ this.updateContext(message.context);
99
+ break;
100
+ }
101
+
102
+ case "tool-result": {
103
+ const pending = this._pendingRequests.get(message.id);
104
+ if (pending) {
105
+ if (message.error) {
106
+ pending.reject(new Error(message.error));
107
+ } else {
108
+ pending.resolve(message.result);
109
+ }
110
+ this._pendingRequests.delete(message.id);
111
+ }
112
+ break;
113
+ }
114
+ }
115
+ }
116
+
117
+ private updateContext(serializedContext: SerializedModelContext) {
118
+ const context = deserializeModelContext(serializedContext);
119
+ this._context = {
120
+ ...context,
121
+ tools:
122
+ context.tools &&
123
+ Object.fromEntries(
124
+ Object.entries(context.tools).map(([name, tool]) => [
125
+ name,
126
+ {
127
+ ...tool,
128
+ execute: (args: any) => this.callTool(name, args),
129
+ } as Tool<any, any>,
130
+ ]),
131
+ ),
132
+ };
133
+ this.notifySubscribers();
134
+ }
135
+
136
+ private callTool(toolName: string, args: any): Promise<any> {
137
+ return this.sendRequest(
138
+ {
139
+ type: "tool-call",
140
+ id: `tool-${this._requestCounter++}`,
141
+ toolName,
142
+ args,
143
+ },
144
+ 30000,
145
+ `Tool call "${toolName}" timed out`,
146
+ );
147
+ }
148
+
149
+ private sendRequest<T extends FrameMessage & { id: string }>(
150
+ message: T,
151
+ timeout = 30000,
152
+ timeoutMessage = "Request timed out",
153
+ ): Promise<any> {
154
+ return new Promise((resolve, reject) => {
155
+ this._pendingRequests.set(message.id, { resolve, reject });
156
+
157
+ this._iframeWindow.postMessage(
158
+ { channel: FRAME_MESSAGE_CHANNEL, message },
159
+ this._targetOrigin,
160
+ );
161
+
162
+ const timeoutId = setTimeout(() => {
163
+ const pending = this._pendingRequests.get(message.id);
164
+ if (pending) {
165
+ pending.reject(new Error(timeoutMessage));
166
+ this._pendingRequests.delete(message.id);
167
+ }
168
+ }, timeout);
169
+
170
+ // Store original resolve/reject with timeout cleanup
171
+ const originalResolve = this._pendingRequests.get(message.id)!.resolve;
172
+ const originalReject = this._pendingRequests.get(message.id)!.reject;
173
+
174
+ this._pendingRequests.set(message.id, {
175
+ resolve: (value: any) => {
176
+ clearTimeout(timeoutId);
177
+ originalResolve(value);
178
+ },
179
+ reject: (error: any) => {
180
+ clearTimeout(timeoutId);
181
+ originalReject(error);
182
+ },
183
+ });
184
+ });
185
+ }
186
+
187
+ private requestContext() {
188
+ // Request current context from iframe
189
+ this._iframeWindow.postMessage(
190
+ {
191
+ channel: FRAME_MESSAGE_CHANNEL,
192
+ message: {
193
+ type: "model-context-request",
194
+ } as FrameMessage,
195
+ },
196
+ this._targetOrigin,
197
+ );
198
+ }
199
+
200
+ private notifySubscribers() {
201
+ this._subscribers.forEach((callback) => callback());
202
+ }
203
+
204
+ getModelContext(): ModelContext {
205
+ return this._context;
206
+ }
207
+
208
+ subscribe(callback: () => void): Unsubscribe {
209
+ this._subscribers.add(callback);
210
+ return () => this._subscribers.delete(callback);
211
+ }
212
+
213
+ dispose() {
214
+ window.removeEventListener("message", this.handleMessage);
215
+ this._subscribers.clear();
216
+ this._pendingRequests.clear();
217
+ }
218
+ }