@assistant-ui/react 0.15.17 → 0.15.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/dist/context/providers/ThreadViewportProvider.js +2 -2
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.js +5 -3
- package/dist/mcp-apps/McpAppRenderer.d.ts +30 -8
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +91 -47
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +57 -33
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.js +9 -4
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/index.d.ts +2 -2
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js +47 -79
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +61 -138
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +3 -8
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerInputPluginContext.d.ts +1 -2
- package/dist/primitives/composer/ComposerInputPluginContext.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInputPluginContext.js +1 -6
- package/dist/primitives/composer/ComposerInputPluginContext.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +46 -15
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +40 -10
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js.map +1 -1
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts +6 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.d.ts.map +1 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js +9 -0
- package/dist/primitives/composer/trigger/matchesTriggerItemQuery.js.map +1 -0
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +53 -61
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +129 -141
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/message/MessageParts.js +2 -14
- package/dist/primitives/message/MessageParts.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +3 -3
- package/dist/primitives/message/MessagePartsGrouped.js +2 -2
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +2 -0
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js +13 -36
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js +3 -1
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +3 -3
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +36 -9
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +24 -2
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.d.ts +9 -3
- package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
- package/dist/unstable/useMentionAdapter.js +50 -43
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -5
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +16 -15
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/useModelContextSnapshot.d.ts +34 -0
- package/dist/unstable/useModelContextSnapshot.d.ts.map +1 -0
- package/dist/unstable/useModelContextSnapshot.js +61 -0
- package/dist/unstable/useModelContextSnapshot.js.map +1 -0
- package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +2 -8
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts +14 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.d.ts.map +1 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js +85 -0
- package/dist/unstable/webmcp/WebMcpRegistrationResource.js.map +1 -0
- package/dist/unstable/webmcp/convertTools.d.ts +21 -0
- package/dist/unstable/webmcp/convertTools.d.ts.map +1 -0
- package/dist/unstable/webmcp/convertTools.js +161 -0
- package/dist/unstable/webmcp/convertTools.js.map +1 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts +21 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js +106 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -0
- package/dist/unstable/webmcp/webmcp-host.d.ts +39 -0
- package/dist/unstable/webmcp/webmcp-host.d.ts.map +1 -0
- package/dist/unstable/webmcp/webmcp-host.js +29 -0
- package/dist/unstable/webmcp/webmcp-host.js.map +1 -0
- package/dist/utils/Primitive.d.ts +10 -2
- package/dist/utils/Primitive.d.ts.map +1 -1
- package/dist/utils/Primitive.js +25 -6
- package/dist/utils/Primitive.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +17 -3
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/package.json +13 -13
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/hooks/useToolCallElapsed.ts +3 -0
- package/src/index.ts +14 -1
- package/src/mcp-apps/McpAppRenderer.test.tsx +253 -5
- package/src/mcp-apps/McpAppRenderer.tsx +147 -52
- package/src/mcp-apps/app-frame.test.tsx +437 -1
- package/src/mcp-apps/app-frame.tsx +76 -21
- package/src/mcp-apps/bridge.test.ts +98 -0
- package/src/mcp-apps/bridge.ts +14 -3
- package/src/mcp-apps/index.ts +5 -1
- package/src/primitives/assistantModal/AssistantModalRoot.test.tsx +123 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +11 -31
- package/src/primitives/assistantModal/AssistantModalTrigger.test.tsx +1 -16
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +43 -0
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +2 -10
- package/src/primitives/composer/ComposerInput.test.tsx +22 -0
- package/src/primitives/composer/ComposerInput.tsx +5 -9
- package/src/primitives/composer/ComposerInputPluginContext.tsx +0 -10
- package/src/primitives/composer/trigger/TriggerPopoverAction.tsx +4 -2
- package/src/primitives/composer/trigger/TriggerPopoverBehavior.test.tsx +109 -0
- package/src/primitives/composer/trigger/TriggerPopoverDirective.tsx +4 -2
- package/src/primitives/composer/trigger/matchesTriggerItemQuery.test.ts +52 -0
- package/src/primitives/composer/trigger/matchesTriggerItemQuery.ts +13 -0
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +13 -7
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +8 -13
- package/src/primitives/message/MessageParts.tsx +1 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +93 -0
- package/src/primitives/message/MessagePartsGrouped.tsx +6 -6
- package/src/primitives/messagePart/MessagePartText.test.tsx +69 -0
- package/src/primitives/messagePart/MessagePartText.tsx +22 -9
- package/src/primitives/reasoning/useScrollLock.test.tsx +129 -0
- package/src/primitives/reasoning/useScrollLock.ts +15 -1
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +38 -1
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +7 -6
- package/src/sandbox-host/SandboxHost.test.tsx +160 -2
- package/src/sandbox-host/SandboxHost.tsx +50 -10
- package/src/tests/MessageParts.rendererOptions.test.tsx +117 -0
- package/src/tests/toolCallTiming.test.tsx +55 -1
- package/src/unstable/useComposerInputHistory.test.tsx +2 -1
- package/src/unstable/useLiveCompletionAdapter.test.tsx +202 -1
- package/src/unstable/useLiveCompletionAdapter.ts +38 -8
- package/src/unstable/useMentionAdapter.test.tsx +383 -0
- package/src/unstable/useMentionAdapter.ts +94 -57
- package/src/unstable/useMessageStallDetection.ts +26 -19
- package/src/unstable/useModelContextSnapshot.test.tsx +132 -0
- package/src/unstable/useModelContextSnapshot.ts +67 -0
- package/src/unstable/useSlashCommandAdapter.ts +2 -9
- package/src/unstable/webmcp/WebMcpRegistrationResource.ts +92 -0
- package/src/unstable/webmcp/__tests__/webmcp.fake.tsx +168 -0
- package/src/unstable/webmcp/convertTools.test.ts +553 -0
- package/src/unstable/webmcp/convertTools.ts +260 -0
- package/src/unstable/webmcp/useWebMcpProvider.test.tsx +466 -0
- package/src/unstable/webmcp/useWebMcpProvider.ts +148 -0
- package/src/unstable/webmcp/webmcp-host.test.ts +142 -0
- package/src/unstable/webmcp/webmcp-host.ts +83 -0
- package/src/utils/Primitive.test.tsx +46 -1
- package/src/utils/Primitive.tsx +37 -7
- package/src/utils/getSelectionMessageId.test.ts +176 -1
- package/src/utils/getSelectionMessageId.ts +21 -3
- package/dist/utils/invokeCallbackSafely.d.ts +0 -5
- package/dist/utils/invokeCallbackSafely.d.ts.map +0 -1
- package/dist/utils/invokeCallbackSafely.js +0 -15
- package/dist/utils/invokeCallbackSafely.js.map +0 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +0 -19
- package/dist/utils/useToolArgsFieldStatus.d.ts.map +0 -1
- package/dist/utils/useToolArgsFieldStatus.js +0 -23
- package/dist/utils/useToolArgsFieldStatus.js.map +0 -1
- package/src/utils/invokeCallbackSafely.ts +0 -11
- package/src/utils/useToolArgsFieldStatus.ts +0 -18
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { StrictMode, type ReactNode } from "react";
|
|
4
|
+
import { cleanup } from "@testing-library/react";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { Tool } from "assistant-stream";
|
|
7
|
+
import type {
|
|
8
|
+
WebMcpHost,
|
|
9
|
+
WebMcpModelContext,
|
|
10
|
+
WebMcpToolDescriptor,
|
|
11
|
+
} from "./webmcp-host";
|
|
12
|
+
|
|
13
|
+
const { hostRef } = vi.hoisted(() => ({
|
|
14
|
+
hostRef: { current: null as WebMcpHost | null },
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("./webmcp-host", async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import("./webmcp-host")>();
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
getDefaultWebMcpHost: (): WebMcpHost =>
|
|
22
|
+
hostRef.current ?? actual.getDefaultWebMcpHost(),
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
providerResult,
|
|
28
|
+
createAsyncModelContext,
|
|
29
|
+
createFakeWebMcpHost,
|
|
30
|
+
createProvider,
|
|
31
|
+
frontendTool,
|
|
32
|
+
mountProvider,
|
|
33
|
+
silenceWarnings,
|
|
34
|
+
waitForNames,
|
|
35
|
+
} = await import("./__tests__/webmcp.fake");
|
|
36
|
+
|
|
37
|
+
const useHost = <T extends WebMcpHost>(host: T): T => {
|
|
38
|
+
hostRef.current = host;
|
|
39
|
+
return host;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const strict = (children: ReactNode) => <StrictMode>{children}</StrictMode>;
|
|
43
|
+
|
|
44
|
+
const backendTool = { type: "backend" } as Tool<any, any>;
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
cleanup();
|
|
48
|
+
hostRef.current = null;
|
|
49
|
+
delete (document as { modelContext?: WebMcpModelContext }).modelContext;
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("unstable_useWebMcpProvider", () => {
|
|
54
|
+
it("reports unsupported and registers nothing when the page has no model context", async () => {
|
|
55
|
+
const provider = createProvider({ search: frontendTool() });
|
|
56
|
+
mountProvider(provider);
|
|
57
|
+
|
|
58
|
+
await vi.waitFor(() => expect(providerResult().status).toBe("unsupported"));
|
|
59
|
+
expect(providerResult().registeredToolNames).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("registers the filtered tools and reports them sorted", async () => {
|
|
63
|
+
const host = useHost(createFakeWebMcpHost());
|
|
64
|
+
mountProvider(
|
|
65
|
+
createProvider({
|
|
66
|
+
search: frontendTool(),
|
|
67
|
+
alpha: frontendTool({ description: "alpha" }),
|
|
68
|
+
server: backendTool,
|
|
69
|
+
off: frontendTool({ disabled: true }),
|
|
70
|
+
broken: frontendTool({ execute: undefined }),
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
await waitForNames(["alpha", "search"]);
|
|
75
|
+
expect(providerResult().status).toBe("active");
|
|
76
|
+
expect([...host.registry.keys()].sort()).toEqual(["alpha", "search"]);
|
|
77
|
+
expect(host.registry.get("search")?.description).toBe("search things");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("honours a custom filter and re-syncs when the filter identity changes", async () => {
|
|
81
|
+
const host = useHost(createFakeWebMcpHost());
|
|
82
|
+
const provider = createProvider({
|
|
83
|
+
search: frontendTool(),
|
|
84
|
+
alpha: frontendTool(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { rerender } = mountProvider(provider, {
|
|
88
|
+
filter: (name) => name === "search",
|
|
89
|
+
});
|
|
90
|
+
await waitForNames(["search"]);
|
|
91
|
+
|
|
92
|
+
rerender({ filter: (name) => name === "alpha" });
|
|
93
|
+
await waitForNames(["alpha"]);
|
|
94
|
+
expect(host.unregisterCalls).toEqual(["search"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("warns and skips a tool whose filter throws", async () => {
|
|
98
|
+
const warn = silenceWarnings();
|
|
99
|
+
useHost(createFakeWebMcpHost());
|
|
100
|
+
mountProvider(
|
|
101
|
+
createProvider({ search: frontendTool(), bad: frontendTool() }),
|
|
102
|
+
{
|
|
103
|
+
filter: (name) => {
|
|
104
|
+
if (name === "bad") throw new Error("filter boom");
|
|
105
|
+
return true;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
await waitForNames(["search"]);
|
|
111
|
+
expect(warn).toHaveBeenCalledWith(
|
|
112
|
+
expect.stringContaining('tool "bad"'),
|
|
113
|
+
expect.any(Error),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("warns once for a tool whose filter keeps throwing", async () => {
|
|
118
|
+
const warn = silenceWarnings();
|
|
119
|
+
const adapter = useHost(createFakeWebMcpHost());
|
|
120
|
+
const bad = frontendTool();
|
|
121
|
+
const provider = createProvider({ bad, search: frontendTool() });
|
|
122
|
+
mountProvider(provider, {
|
|
123
|
+
filter: (_name, tool) => {
|
|
124
|
+
if (tool === bad) throw new Error("filter boom");
|
|
125
|
+
return true;
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
await waitForNames(["search"]);
|
|
129
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
130
|
+
|
|
131
|
+
provider.setTools({ bad, search: frontendTool(), extra: frontendTool() });
|
|
132
|
+
await waitForNames(["extra", "search"]);
|
|
133
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
134
|
+
expect(adapter.registry.has("bad")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("re-attempts a tool whose filter stops throwing", async () => {
|
|
138
|
+
const warn = silenceWarnings();
|
|
139
|
+
const adapter = useHost(createFakeWebMcpHost());
|
|
140
|
+
const flaky = frontendTool();
|
|
141
|
+
let throwing = true;
|
|
142
|
+
const provider = createProvider({ flaky });
|
|
143
|
+
mountProvider(provider, {
|
|
144
|
+
filter: (_name, tool) => {
|
|
145
|
+
if (tool === flaky && throwing) throw new Error("filter boom");
|
|
146
|
+
return true;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce());
|
|
150
|
+
expect(adapter.registry.has("flaky")).toBe(false);
|
|
151
|
+
|
|
152
|
+
throwing = false;
|
|
153
|
+
provider.setTools({ flaky });
|
|
154
|
+
await waitForNames(["flaky"]);
|
|
155
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("adds and removes registrations as the model context changes", async () => {
|
|
159
|
+
const host = useHost(createFakeWebMcpHost());
|
|
160
|
+
const provider = createProvider({ search: frontendTool() });
|
|
161
|
+
mountProvider(provider);
|
|
162
|
+
await waitForNames(["search"]);
|
|
163
|
+
|
|
164
|
+
provider.setTools({ search: frontendTool(), alpha: frontendTool() });
|
|
165
|
+
await waitForNames(["alpha", "search"]);
|
|
166
|
+
|
|
167
|
+
provider.setTools({ alpha: frontendTool() });
|
|
168
|
+
await waitForNames(["alpha"]);
|
|
169
|
+
expect(host.unregisterCalls).toEqual(["search"]);
|
|
170
|
+
expect(host.registerCalls).toEqual(["search", "alpha"]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("keeps one registration across an implementation change and calls through to the latest tool", async () => {
|
|
174
|
+
const host = useHost(createFakeWebMcpHost());
|
|
175
|
+
const provider = createProvider({
|
|
176
|
+
search: frontendTool({ execute: async () => "first" }),
|
|
177
|
+
});
|
|
178
|
+
mountProvider(provider);
|
|
179
|
+
await waitForNames(["search"]);
|
|
180
|
+
|
|
181
|
+
const descriptor = host.registry.get("search")!;
|
|
182
|
+
await expect(descriptor.execute({})).resolves.toEqual({
|
|
183
|
+
content: [{ type: "text", text: "first" }],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
provider.setTools({
|
|
187
|
+
search: frontendTool({ execute: async () => "second" }),
|
|
188
|
+
});
|
|
189
|
+
await vi.waitFor(async () => {
|
|
190
|
+
await expect(descriptor.execute({})).resolves.toEqual({
|
|
191
|
+
content: [{ type: "text", text: "second" }],
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
expect(host.registerCalls).toEqual(["search"]);
|
|
195
|
+
expect(host.unregisterCalls).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("re-registers when the description or the schema changes", async () => {
|
|
199
|
+
const host = useHost(createFakeWebMcpHost());
|
|
200
|
+
const provider = createProvider({ search: frontendTool() });
|
|
201
|
+
mountProvider(provider);
|
|
202
|
+
await waitForNames(["search"]);
|
|
203
|
+
|
|
204
|
+
provider.setTools({ search: frontendTool({ description: "renamed" }) });
|
|
205
|
+
await vi.waitFor(() =>
|
|
206
|
+
expect(host.registry.get("search")?.description).toBe("renamed"),
|
|
207
|
+
);
|
|
208
|
+
expect(host.registerCalls).toEqual(["search", "search"]);
|
|
209
|
+
expect(host.unregisterCalls).toEqual(["search"]);
|
|
210
|
+
expect(providerResult().registeredToolNames).toEqual(["search"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("re-registers when a description is mutated on the same tool object", async () => {
|
|
214
|
+
const host = useHost(createFakeWebMcpHost());
|
|
215
|
+
const tool = frontendTool();
|
|
216
|
+
const provider = createProvider({ search: tool });
|
|
217
|
+
mountProvider(provider);
|
|
218
|
+
await waitForNames(["search"]);
|
|
219
|
+
|
|
220
|
+
(tool as { description: string }).description = "renamed in place";
|
|
221
|
+
provider.setTools({ search: tool });
|
|
222
|
+
|
|
223
|
+
await vi.waitFor(() =>
|
|
224
|
+
expect(host.registry.get("search")?.description).toBe("renamed in place"),
|
|
225
|
+
);
|
|
226
|
+
expect(host.registerCalls).toEqual(["search", "search"]);
|
|
227
|
+
expect(host.unregisterCalls).toEqual(["search"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("does not observe a schema mutated in place on the same tool object", async () => {
|
|
231
|
+
const adapter = useHost(createFakeWebMcpHost());
|
|
232
|
+
const tool = frontendTool();
|
|
233
|
+
const provider = createProvider({ search: tool });
|
|
234
|
+
mountProvider(provider);
|
|
235
|
+
await waitForNames(["search"]);
|
|
236
|
+
const before = adapter.registry.get("search")?.inputSchema;
|
|
237
|
+
|
|
238
|
+
(tool.parameters as Record<string, unknown>)["properties"] = {
|
|
239
|
+
city: { type: "string" },
|
|
240
|
+
};
|
|
241
|
+
provider.setTools({ search: tool });
|
|
242
|
+
await vi.waitFor(() => expect(adapter.registerCalls).toEqual(["search"]));
|
|
243
|
+
|
|
244
|
+
expect(adapter.registry.get("search")?.inputSchema).toBe(before);
|
|
245
|
+
expect(adapter.unregisterCalls).toEqual([]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("does not treat a tool named after an Object.prototype key as inherited", async () => {
|
|
249
|
+
const host = useHost(createFakeWebMcpHost());
|
|
250
|
+
const provider = createProvider({
|
|
251
|
+
constructor: frontendTool({ description: "ctor" }),
|
|
252
|
+
toString: frontendTool({ description: "str" }),
|
|
253
|
+
});
|
|
254
|
+
mountProvider(provider);
|
|
255
|
+
|
|
256
|
+
await waitForNames(["constructor", "toString"]);
|
|
257
|
+
expect(host.registry.get("constructor")?.description).toBe("ctor");
|
|
258
|
+
|
|
259
|
+
provider.setTools({ constructor: frontendTool({ description: "ctor" }) });
|
|
260
|
+
await waitForNames(["constructor"]);
|
|
261
|
+
expect(host.unregisterCalls).toEqual(["toString"]);
|
|
262
|
+
expect(host.registerCalls).toEqual(["constructor", "toString"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("unregisters everything on unmount and warns when a disposer throws", async () => {
|
|
266
|
+
const warn = silenceWarnings();
|
|
267
|
+
const host = useHost(createFakeWebMcpHost());
|
|
268
|
+
const { view } = mountProvider(
|
|
269
|
+
createProvider({ search: frontendTool(), alpha: frontendTool() }),
|
|
270
|
+
);
|
|
271
|
+
await waitForNames(["alpha", "search"]);
|
|
272
|
+
|
|
273
|
+
view.unmount();
|
|
274
|
+
expect(host.registry.size).toBe(0);
|
|
275
|
+
expect(host.unregisterCalls.sort()).toEqual(["alpha", "search"]);
|
|
276
|
+
expect(warn).not.toHaveBeenCalled();
|
|
277
|
+
|
|
278
|
+
useHost({
|
|
279
|
+
...createFakeWebMcpHost(),
|
|
280
|
+
registerTool: () => () => {
|
|
281
|
+
throw new Error("dispose boom");
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
const second = mountProvider(createProvider({ search: frontendTool() }));
|
|
285
|
+
await waitForNames(["search"]);
|
|
286
|
+
second.view.unmount();
|
|
287
|
+
expect(warn).toHaveBeenCalledWith(
|
|
288
|
+
expect.stringContaining("Unregistering WebMCP tool"),
|
|
289
|
+
expect.any(Error),
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("warns and skips a name registerTool synchronously refuses", async () => {
|
|
294
|
+
const warn = silenceWarnings();
|
|
295
|
+
const host = useHost(createFakeWebMcpHost());
|
|
296
|
+
host.registry.set("search", {} as WebMcpToolDescriptor);
|
|
297
|
+
|
|
298
|
+
mountProvider(
|
|
299
|
+
createProvider({ search: frontendTool(), alpha: frontendTool() }),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
await waitForNames(["alpha"]);
|
|
303
|
+
expect(warn).toHaveBeenCalledWith(
|
|
304
|
+
expect.stringContaining("registerTool failed"),
|
|
305
|
+
expect.any(Error),
|
|
306
|
+
);
|
|
307
|
+
expect(host.registry.get("search")).toEqual({});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("warns once for a refused name and stops retrying it until it leaves the model context", async () => {
|
|
311
|
+
const warn = silenceWarnings();
|
|
312
|
+
const host = useHost(createFakeWebMcpHost());
|
|
313
|
+
host.registry.set("search", {} as WebMcpToolDescriptor);
|
|
314
|
+
const attempts = vi.spyOn(host, "registerTool");
|
|
315
|
+
|
|
316
|
+
const provider = createProvider({ search: frontendTool() });
|
|
317
|
+
mountProvider(provider);
|
|
318
|
+
await vi.waitFor(() => expect(warn).toHaveBeenCalledOnce());
|
|
319
|
+
|
|
320
|
+
for (let i = 0; i < 10; i++) {
|
|
321
|
+
provider.setTools({ search: frontendTool({ description: `v${i}` }) });
|
|
322
|
+
}
|
|
323
|
+
await vi.waitFor(() =>
|
|
324
|
+
expect(providerResult().registeredToolNames).toEqual([]),
|
|
325
|
+
);
|
|
326
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
327
|
+
expect(attempts).toHaveBeenCalledOnce();
|
|
328
|
+
|
|
329
|
+
host.registry.delete("search");
|
|
330
|
+
provider.setTools({ alpha: frontendTool() });
|
|
331
|
+
await waitForNames(["alpha"]);
|
|
332
|
+
provider.setTools({ alpha: frontendTool(), search: frontendTool() });
|
|
333
|
+
await waitForNames(["alpha", "search"]);
|
|
334
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("does not re-serialize schemas when an inline filter changes identity", async () => {
|
|
338
|
+
useHost(createFakeWebMcpHost());
|
|
339
|
+
let schemaReads = 0;
|
|
340
|
+
const tool = frontendTool();
|
|
341
|
+
Object.defineProperty(tool, "parameters", {
|
|
342
|
+
get: () => {
|
|
343
|
+
schemaReads++;
|
|
344
|
+
return { type: "object", properties: {} };
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const { rerender } = mountProvider(createProvider({ search: tool }), {
|
|
349
|
+
filter: () => true,
|
|
350
|
+
});
|
|
351
|
+
await waitForNames(["search"]);
|
|
352
|
+
const afterMount = schemaReads;
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < 5; i++) rerender({ filter: () => true });
|
|
355
|
+
await waitForNames(["search"]);
|
|
356
|
+
expect(schemaReads).toBe(afterMount);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("holds a single live registration under StrictMode", async () => {
|
|
360
|
+
const host = useHost(createFakeWebMcpHost());
|
|
361
|
+
mountProvider(createProvider({ search: frontendTool() }), {}, strict);
|
|
362
|
+
|
|
363
|
+
await waitForNames(["search"]);
|
|
364
|
+
expect([...host.registry.keys()]).toEqual(["search"]);
|
|
365
|
+
expect(providerResult().status).toBe("active");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("warns and drops the name when a registration is rejected", async () => {
|
|
369
|
+
const warn = silenceWarnings();
|
|
370
|
+
const pageOwned: WebMcpToolDescriptor[] = [];
|
|
371
|
+
const registry = createAsyncModelContext();
|
|
372
|
+
const context = (document as { modelContext?: WebMcpModelContext })
|
|
373
|
+
.modelContext!;
|
|
374
|
+
const original = context.registerTool.bind(context);
|
|
375
|
+
context.registerTool = (tool, options) => {
|
|
376
|
+
if (tool.name === "search") {
|
|
377
|
+
return Promise.reject(new Error("already registered"));
|
|
378
|
+
}
|
|
379
|
+
pageOwned.push(tool);
|
|
380
|
+
return original(tool, options);
|
|
381
|
+
};
|
|
382
|
+
registry.set("search", { name: "search" } as WebMcpToolDescriptor);
|
|
383
|
+
|
|
384
|
+
mountProvider(createProvider({ search: frontendTool() }));
|
|
385
|
+
|
|
386
|
+
await vi.waitFor(() =>
|
|
387
|
+
expect(providerResult().registeredToolNames).toEqual([]),
|
|
388
|
+
);
|
|
389
|
+
expect(warn).toHaveBeenCalledWith(
|
|
390
|
+
expect.stringContaining('tool "search" failed'),
|
|
391
|
+
expect.any(Error),
|
|
392
|
+
);
|
|
393
|
+
expect(registry.get("search")).toEqual({ name: "search" });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("names the permissions policy when the host refuses with NotAllowedError", async () => {
|
|
397
|
+
const warn = silenceWarnings();
|
|
398
|
+
let reject!: (error: unknown) => void;
|
|
399
|
+
useHost({
|
|
400
|
+
available: true,
|
|
401
|
+
registerTool: (_def, onError) => {
|
|
402
|
+
reject = (error) => onError?.(error);
|
|
403
|
+
return () => {};
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
mountProvider(createProvider({ search: frontendTool() }));
|
|
407
|
+
await waitForNames(["search"]);
|
|
408
|
+
|
|
409
|
+
const denied = new Error("denied");
|
|
410
|
+
denied.name = "NotAllowedError";
|
|
411
|
+
reject(denied);
|
|
412
|
+
|
|
413
|
+
await vi.waitFor(() =>
|
|
414
|
+
expect(warn).toHaveBeenCalledWith(
|
|
415
|
+
expect.stringContaining("tools permission is disabled"),
|
|
416
|
+
denied,
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
await waitForNames([]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("keeps the tool exposed across a description change on a conforming host", async () => {
|
|
423
|
+
const registry = createAsyncModelContext();
|
|
424
|
+
const provider = createProvider({ search: frontendTool() });
|
|
425
|
+
mountProvider(provider);
|
|
426
|
+
await waitForNames(["search"]);
|
|
427
|
+
|
|
428
|
+
provider.setTools({ search: frontendTool({ description: "renamed" }) });
|
|
429
|
+
await vi.waitFor(() =>
|
|
430
|
+
expect(registry.get("search")?.description).toBe("renamed"),
|
|
431
|
+
);
|
|
432
|
+
await Promise.resolve();
|
|
433
|
+
expect(registry.get("search")?.description).toBe("renamed");
|
|
434
|
+
expect(providerResult().registeredToolNames).toEqual(["search"]);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("ignores a late failure reported by a replaced registration", async () => {
|
|
438
|
+
const warn = silenceWarnings();
|
|
439
|
+
const calls: {
|
|
440
|
+
def: WebMcpToolDescriptor;
|
|
441
|
+
onError?: (error: unknown) => void;
|
|
442
|
+
dispose: ReturnType<typeof vi.fn>;
|
|
443
|
+
}[] = [];
|
|
444
|
+
useHost({
|
|
445
|
+
available: true,
|
|
446
|
+
registerTool: (def, onError) => {
|
|
447
|
+
const dispose = vi.fn();
|
|
448
|
+
calls.push({ def, onError, dispose });
|
|
449
|
+
return dispose;
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const provider = createProvider({ search: frontendTool() });
|
|
454
|
+
mountProvider(provider);
|
|
455
|
+
await waitForNames(["search"]);
|
|
456
|
+
|
|
457
|
+
provider.setTools({ search: frontendTool({ description: "renamed" }) });
|
|
458
|
+
await vi.waitFor(() => expect(calls).toHaveLength(2));
|
|
459
|
+
|
|
460
|
+
calls[0]!.onError?.(new Error("late failure"));
|
|
461
|
+
|
|
462
|
+
expect(providerResult().registeredToolNames).toEqual(["search"]);
|
|
463
|
+
expect(calls[1]!.dispose).not.toHaveBeenCalled();
|
|
464
|
+
expect(warn).not.toHaveBeenCalled();
|
|
465
|
+
});
|
|
466
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { useResources, useTapRoot, withKey } from "@assistant-ui/tap";
|
|
5
|
+
import { useAui, type AssistantClient } from "@assistant-ui/store";
|
|
6
|
+
import type { Tool } from "assistant-stream";
|
|
7
|
+
import { getDefaultWebMcpHost, type WebMcpHost } from "./webmcp-host";
|
|
8
|
+
import { defaultWebMcpFilter, toWebMcpInputSchema } from "./convertTools";
|
|
9
|
+
import { WebMcpRegistrationResource } from "./WebMcpRegistrationResource";
|
|
10
|
+
import { shallowEqual } from "@assistant-ui/store/internal";
|
|
11
|
+
import {
|
|
12
|
+
useModelContextSnapshot,
|
|
13
|
+
type ModelContextSnapshotSource,
|
|
14
|
+
} from "../useModelContextSnapshot";
|
|
15
|
+
|
|
16
|
+
export type Unstable_WebMcpProviderOptions = {
|
|
17
|
+
filter?: (name: string, tool: Tool<any, any>) => boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type Unstable_WebMcpProviderResult = {
|
|
21
|
+
status: "unsupported" | "active";
|
|
22
|
+
registeredToolNames: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const EMPTY_NAMES: readonly string[] = Object.freeze([]);
|
|
26
|
+
const EMPTY_TOOLS: Record<string, Tool<any, any>> = Object.freeze({});
|
|
27
|
+
|
|
28
|
+
// The description is re-read on every sync so mutating it in place is
|
|
29
|
+
// observed; the schema is converted only when the tool object itself changes.
|
|
30
|
+
// Keyed on the tool so a schema or filter that throws warns once instead of
|
|
31
|
+
// on every model-context notify.
|
|
32
|
+
const warned = new WeakSet<Tool<any, any>>();
|
|
33
|
+
|
|
34
|
+
const signatures = new WeakMap<
|
|
35
|
+
Tool<any, any>,
|
|
36
|
+
{ description: string | undefined; signature: string }
|
|
37
|
+
>();
|
|
38
|
+
|
|
39
|
+
const signatureOf = (tool: Tool<any, any>) => {
|
|
40
|
+
const cached = signatures.get(tool);
|
|
41
|
+
if (cached && cached.description === tool.description)
|
|
42
|
+
return cached.signature;
|
|
43
|
+
const signature = `${tool.description ?? ""}\u0000${JSON.stringify(
|
|
44
|
+
toWebMcpInputSchema(tool),
|
|
45
|
+
)}`;
|
|
46
|
+
signatures.set(tool, { description: tool.description, signature });
|
|
47
|
+
return signature;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const NO_SUBSCRIPTION = () => {};
|
|
51
|
+
|
|
52
|
+
// No `isEqual`: a caller republishing an unchanged tool set is how it asks for
|
|
53
|
+
// a re-sync, which is what re-attempts a tool whose filter has stopped throwing
|
|
54
|
+
// and what picks up a description edited in place on a stable tool object.
|
|
55
|
+
const modelContextToolSource: ModelContextSnapshotSource<
|
|
56
|
+
Record<string, Tool<any, any>>
|
|
57
|
+
> = {
|
|
58
|
+
empty: EMPTY_TOOLS,
|
|
59
|
+
read: (aui) => aui.modelContext.getModelContext().tools ?? EMPTY_TOOLS,
|
|
60
|
+
subscribe: (aui, onChange) =>
|
|
61
|
+
aui.modelContext.subscribe?.(onChange) ?? NO_SUBSCRIPTION,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const useStableNames = (names: readonly (string | null)[]) => {
|
|
65
|
+
const [cell] = useState(() => ({ names: EMPTY_NAMES }));
|
|
66
|
+
const next = names.filter((name): name is string => name !== null).sort();
|
|
67
|
+
const previous = cell.names;
|
|
68
|
+
if (shallowEqual(previous, next)) {
|
|
69
|
+
return previous;
|
|
70
|
+
}
|
|
71
|
+
cell.names = next;
|
|
72
|
+
return next;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const useWebMcpRegistry = ({
|
|
76
|
+
aui,
|
|
77
|
+
host,
|
|
78
|
+
filter,
|
|
79
|
+
}: {
|
|
80
|
+
aui: AssistantClient;
|
|
81
|
+
host: WebMcpHost;
|
|
82
|
+
filter: (name: string, tool: Tool<any, any>) => boolean;
|
|
83
|
+
}) => {
|
|
84
|
+
const tools = useModelContextSnapshot(
|
|
85
|
+
aui,
|
|
86
|
+
host.available,
|
|
87
|
+
modelContextToolSource,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const elements = [];
|
|
91
|
+
for (const [name, tool] of Object.entries(tools)) {
|
|
92
|
+
try {
|
|
93
|
+
if (!filter(name, tool)) continue;
|
|
94
|
+
const signature = signatureOf(tool);
|
|
95
|
+
elements.push(
|
|
96
|
+
withKey(
|
|
97
|
+
name,
|
|
98
|
+
WebMcpRegistrationResource({ host, name, signature, tool }),
|
|
99
|
+
[host, name, signature, tool],
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (warned.has(tool)) continue;
|
|
104
|
+
warned.add(tool);
|
|
105
|
+
console.warn(
|
|
106
|
+
`[assistant-ui] Skipping WebMCP registration for tool "${name}": filter or schema conversion failed.`,
|
|
107
|
+
error,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return useStableNames(useResources(elements));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Publishes the frontend tools in the model context to a WebMCP-capable
|
|
117
|
+
* browser, so the user's own browser agent can call them.
|
|
118
|
+
*
|
|
119
|
+
* Returns `status: "unsupported"` when the page exposes no
|
|
120
|
+
* `document.modelContext` (or `navigator.modelContext`), and the sorted names
|
|
121
|
+
* of the tools currently registered with the host.
|
|
122
|
+
*/
|
|
123
|
+
export const unstable_useWebMcpProvider = (
|
|
124
|
+
options: Unstable_WebMcpProviderOptions = {},
|
|
125
|
+
): Unstable_WebMcpProviderResult => {
|
|
126
|
+
const aui = useAui();
|
|
127
|
+
const [host] = useState(getDefaultWebMcpHost);
|
|
128
|
+
const filter = options.filter ?? defaultWebMcpFilter;
|
|
129
|
+
|
|
130
|
+
const root = useTapRoot(function WebMcpProviderRoot() {
|
|
131
|
+
return useWebMcpRegistry({ aui, host, filter });
|
|
132
|
+
});
|
|
133
|
+
const registeredToolNames = useSyncExternalStore(
|
|
134
|
+
root.subscribe,
|
|
135
|
+
root.getValue,
|
|
136
|
+
() => EMPTY_NAMES,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const [published, setPublished] = useState(false);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (host.available) setPublished(true);
|
|
142
|
+
}, [host]);
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
status: published ? "active" : "unsupported",
|
|
146
|
+
registeredToolNames,
|
|
147
|
+
};
|
|
148
|
+
};
|