@runtypelabs/persona 3.21.3 → 3.23.0

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 (66) hide show
  1. package/README.md +67 -0
  2. package/dist/animations/glyph-cycle.cjs +2 -262
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/glyph-cycle.js +2 -235
  6. package/dist/animations/{types-CWPIj66R.d.cts → types-BZVr1YOV.d.cts} +10 -0
  7. package/dist/animations/{types-CWPIj66R.d.ts → types-BZVr1YOV.d.ts} +10 -0
  8. package/dist/animations/wipe.cjs +2 -72
  9. package/dist/animations/wipe.d.cts +1 -1
  10. package/dist/animations/wipe.d.ts +1 -1
  11. package/dist/animations/wipe.js +2 -45
  12. package/dist/index.cjs +52 -45
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +474 -6
  15. package/dist/index.d.ts +474 -6
  16. package/dist/index.global.js +107 -97
  17. package/dist/index.global.js.map +1 -1
  18. package/dist/index.js +52 -45
  19. package/dist/index.js.map +1 -1
  20. package/dist/smart-dom-reader.cjs +23 -0
  21. package/dist/smart-dom-reader.d.cts +4521 -0
  22. package/dist/smart-dom-reader.d.ts +4521 -0
  23. package/dist/smart-dom-reader.js +23 -0
  24. package/dist/testing.cjs +3 -84
  25. package/dist/testing.js +3 -55
  26. package/dist/theme-editor.cjs +57 -22501
  27. package/dist/theme-editor.d.cts +348 -1
  28. package/dist/theme-editor.d.ts +348 -1
  29. package/dist/theme-editor.js +57 -22503
  30. package/package.json +16 -6
  31. package/src/client.test.ts +165 -0
  32. package/src/client.ts +144 -23
  33. package/src/components/event-stream-view.ts +122 -1
  34. package/src/index.ts +26 -0
  35. package/src/session.test.ts +258 -0
  36. package/src/session.ts +886 -30
  37. package/src/session.webmcp.test.ts +815 -0
  38. package/src/smart-dom-reader.test.ts +135 -0
  39. package/src/smart-dom-reader.ts +135 -0
  40. package/src/theme-editor/color-utils.test.ts +59 -0
  41. package/src/theme-editor/color-utils.ts +38 -2
  42. package/src/theme-editor/index.ts +35 -0
  43. package/src/theme-editor/webmcp/coerce.test.ts +86 -0
  44. package/src/theme-editor/webmcp/coerce.ts +286 -0
  45. package/src/theme-editor/webmcp/index.ts +45 -0
  46. package/src/theme-editor/webmcp/summary.ts +324 -0
  47. package/src/theme-editor/webmcp/tools.test.ts +205 -0
  48. package/src/theme-editor/webmcp/tools.ts +795 -0
  49. package/src/theme-editor/webmcp/types.ts +87 -0
  50. package/src/types.ts +186 -0
  51. package/src/ui.composer-keyboard.test.ts +229 -0
  52. package/src/ui.ts +151 -8
  53. package/src/utils/composer-history.test.ts +128 -0
  54. package/src/utils/composer-history.ts +113 -0
  55. package/src/utils/message-fingerprint.test.ts +20 -0
  56. package/src/utils/message-fingerprint.ts +2 -0
  57. package/src/utils/smart-dom-adapter.test.ts +257 -0
  58. package/src/utils/smart-dom-adapter.ts +217 -0
  59. package/src/utils/throughput-tracker.test.ts +366 -0
  60. package/src/utils/throughput-tracker.ts +427 -0
  61. package/{LICENSE → src/vendor/smart-dom-reader/LICENSE} +2 -2
  62. package/src/vendor/smart-dom-reader/README.md +61 -0
  63. package/src/vendor/smart-dom-reader/index.d.ts +476 -0
  64. package/src/vendor/smart-dom-reader/index.js +1618 -0
  65. package/src/webmcp-bridge.test.ts +429 -0
  66. package/src/webmcp-bridge.ts +547 -0
@@ -0,0 +1,429 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type {
4
+ AgentWidgetWebMcpConfig,
5
+ WebMcpConfirmHandler,
6
+ WebMcpToolResult,
7
+ } from "./types";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Mock the strict @mcp-b/webmcp-polyfill. The bridge dynamically imports it and
11
+ // calls `initializeWebMCPPolyfill()` (idempotent install of document.modelContext).
12
+ // We stub the install as a no-op and provide `document.modelContext` ourselves,
13
+ // modeling the strict producer-preview surface the bridge consumes:
14
+ // - getTools(): async; inputSchema is a JSON string; NO annotations
15
+ // - executeTool(info, argsJson, { signal }): async; validates+runs execute(),
16
+ // returns JSON.stringify(rawResult) or null; honors the abort signal.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const polyfillMock = { initThrows: false };
20
+
21
+ vi.mock("@mcp-b/webmcp-polyfill", () => ({
22
+ initializeWebMCPPolyfill: vi.fn(() => {
23
+ if (polyfillMock.initThrows) throw new Error("init boom");
24
+ }),
25
+ }));
26
+
27
+ // Import AFTER vi.mock so the bridge's dynamic import resolves to the mock.
28
+ import {
29
+ WebMcpBridge,
30
+ isWebMcpToolName,
31
+ stripWebMcpPrefix,
32
+ } from "./webmcp-bridge";
33
+
34
+ type MockClient = { requestUserInteraction: (cb: () => unknown) => Promise<unknown> };
35
+
36
+ type MockTool = {
37
+ name: string;
38
+ description?: string;
39
+ inputSchema?: object;
40
+ execute: (args: Record<string, unknown>, client: MockClient) => unknown;
41
+ };
42
+
43
+ const registry: { tools: MockTool[] } = { tools: [] };
44
+
45
+ /** A fake `document.modelContext` exposing the strict consumer surface. */
46
+ const makeModelContext = () => ({
47
+ async getTools() {
48
+ return registry.tools.map((t) => ({
49
+ name: t.name,
50
+ description: t.description ?? `mock ${t.name}`,
51
+ inputSchema: JSON.stringify(t.inputSchema ?? { type: "object" }),
52
+ }));
53
+ },
54
+ async executeTool(
55
+ info: { name: string },
56
+ inputArgsJson: string,
57
+ options?: { signal?: AbortSignal },
58
+ ): Promise<string | null> {
59
+ if (options?.signal?.aborted) throw new Error("Tool execution was cancelled");
60
+ const tool = registry.tools.find((t) => t.name === info.name);
61
+ if (!tool) throw new Error(`Tool not found: ${info.name}`);
62
+ const args = inputArgsJson ? JSON.parse(inputArgsJson) : {};
63
+ // The polyfill owns this client; `requestUserInteraction` is a pass-through.
64
+ const client: MockClient = {
65
+ requestUserInteraction: async (cb) => cb(),
66
+ };
67
+ const execPromise = Promise.resolve(tool.execute(args, client));
68
+ const raced = options?.signal
69
+ ? Promise.race<unknown>([
70
+ execPromise,
71
+ new Promise<never>((_, reject) => {
72
+ const sig = options.signal!;
73
+ if (sig.aborted) reject(new Error("Tool execution was cancelled"));
74
+ else
75
+ sig.addEventListener(
76
+ "abort",
77
+ () => reject(new Error("Tool execution was cancelled")),
78
+ { once: true },
79
+ );
80
+ }),
81
+ ])
82
+ : execPromise;
83
+ const raw = await raced;
84
+ return raw === undefined ? null : JSON.stringify(raw);
85
+ },
86
+ });
87
+
88
+ const fakeTool = (
89
+ overrides: Partial<MockTool> & { name: string },
90
+ ): MockTool => ({
91
+ description: `mock ${overrides.name}`,
92
+ execute: () => "ok",
93
+ ...overrides,
94
+ });
95
+
96
+ const allowAll: WebMcpConfirmHandler = vi.fn(async () => true);
97
+
98
+ beforeEach(() => {
99
+ vi.clearAllMocks();
100
+ polyfillMock.initThrows = false;
101
+ registry.tools = [];
102
+ // location is read by snapshotForDispatch; document.modelContext is the
103
+ // consumer surface. Both are absent in the Node test environment.
104
+ vi.stubGlobal("location", { origin: "https://example.test" });
105
+ vi.stubGlobal("document", { modelContext: makeModelContext() });
106
+ });
107
+
108
+ afterEach(() => {
109
+ vi.unstubAllGlobals();
110
+ });
111
+
112
+ describe("stripWebMcpPrefix", () => {
113
+ it("strips a leading 'webmcp:' prefix", () => {
114
+ expect(stripWebMcpPrefix("webmcp:add_to_cart")).toBe("add_to_cart");
115
+ });
116
+ it("leaves names without the prefix untouched", () => {
117
+ expect(stripWebMcpPrefix("add_to_cart")).toBe("add_to_cart");
118
+ });
119
+ });
120
+
121
+ describe("isWebMcpToolName", () => {
122
+ it("returns true for prefixed names", () => {
123
+ expect(isWebMcpToolName("webmcp:search")).toBe(true);
124
+ });
125
+ it("returns false otherwise", () => {
126
+ expect(isWebMcpToolName("builtin:search")).toBe(false);
127
+ expect(isWebMcpToolName("search")).toBe(false);
128
+ });
129
+ });
130
+
131
+ describe("WebMcpBridge.snapshotForDispatch", () => {
132
+ it("returns empty when config.webmcp.enabled is not set", async () => {
133
+ const bridge = new WebMcpBridge({} as AgentWidgetWebMcpConfig);
134
+ expect(bridge.isOperational()).toBe(false);
135
+ expect(await bridge.snapshotForDispatch()).toEqual([]);
136
+ });
137
+
138
+ it("returns empty when document.modelContext is absent (no polyfill, no native)", async () => {
139
+ vi.stubGlobal("document", {});
140
+ const bridge = new WebMcpBridge({ enabled: true });
141
+ expect(await bridge.snapshotForDispatch()).toEqual([]);
142
+ expect(bridge.isOperational()).toBe(false);
143
+ });
144
+
145
+ it("returns empty when initializeWebMCPPolyfill() throws", async () => {
146
+ polyfillMock.initThrows = true;
147
+ const bridge = new WebMcpBridge({ enabled: true });
148
+ expect(await bridge.snapshotForDispatch()).toEqual([]);
149
+ expect(bridge.isOperational()).toBe(false);
150
+ });
151
+
152
+ it("ships only the JSON-serializable surface (name, description, schema, origin)", async () => {
153
+ registry.tools = [
154
+ fakeTool({
155
+ name: "search",
156
+ description: "search the shop",
157
+ inputSchema: { type: "object", properties: { q: { type: "string" } } },
158
+ }),
159
+ ];
160
+ const bridge = new WebMcpBridge({ enabled: true });
161
+ const snap = await bridge.snapshotForDispatch();
162
+ expect(snap).toHaveLength(1);
163
+ const tool = snap[0]!;
164
+ expect(tool.name).toBe("search");
165
+ expect(tool.description).toBe("search the shop");
166
+ expect(tool.parametersSchema).toEqual({
167
+ type: "object",
168
+ properties: { q: { type: "string" } },
169
+ });
170
+ expect(tool.origin).toBe("webmcp");
171
+ expect(tool.pageOrigin).toBe("https://example.test");
172
+ expect((tool as unknown as { execute?: unknown }).execute).toBeUndefined();
173
+ // Now that the registry was read, the bridge reports operational.
174
+ expect(bridge.isOperational()).toBe(true);
175
+ });
176
+
177
+ it("applies client-side allowlist glob (`search_*`)", async () => {
178
+ registry.tools = [
179
+ fakeTool({ name: "search_products" }),
180
+ fakeTool({ name: "add_to_cart" }),
181
+ ];
182
+ const bridge = new WebMcpBridge({
183
+ enabled: true,
184
+ allowlist: ["search_*"],
185
+ });
186
+ const snap = await bridge.snapshotForDispatch();
187
+ expect(snap.map((t) => t.name)).toEqual(["search_products"]);
188
+ });
189
+
190
+ it("respects '*' as match-all", async () => {
191
+ registry.tools = [fakeTool({ name: "foo" }), fakeTool({ name: "bar" })];
192
+ const bridge = new WebMcpBridge({ enabled: true, allowlist: ["*"] });
193
+ expect((await bridge.snapshotForDispatch()).map((t) => t.name)).toEqual([
194
+ "foo",
195
+ "bar",
196
+ ]);
197
+ });
198
+ });
199
+
200
+ describe("WebMcpBridge.executeToolCall", () => {
201
+ it("returns isError when WebMCP is not enabled", async () => {
202
+ const bridge = new WebMcpBridge({} as AgentWidgetWebMcpConfig);
203
+ const r = await bridge.executeToolCall("webmcp:search", {});
204
+ expect(r.isError).toBe(true);
205
+ expect(r.content[0]?.type).toBe("text");
206
+ });
207
+
208
+ it("returns isError when document.modelContext is absent", async () => {
209
+ vi.stubGlobal("document", {});
210
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
211
+ const r = await bridge.executeToolCall("webmcp:search", {});
212
+ expect(r.isError).toBe(true);
213
+ expect((r.content[0] as { text: string }).text).toMatch(/not operational/i);
214
+ expect((r.content[0] as { text: string }).text).toMatch(/not available/i);
215
+ });
216
+
217
+ it("warns once and degrades cleanly when document.modelContext is present but incompatible", async () => {
218
+ // A different / older WebMCP polyfill (or divergent native draft) squats
219
+ // document.modelContext without the strict getTools()/executeTool() surface.
220
+ // @mcp-b's initializeWebMCPPolyfill correctly declines to overwrite it, so
221
+ // Persona must (a) report non-operational, (b) surface an actionable error
222
+ // distinct from "not available", and (c) warn exactly once.
223
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
224
+ vi.stubGlobal("document", {
225
+ modelContext: { registerTool: () => undefined }, // no getTools/executeTool
226
+ });
227
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
228
+
229
+ expect(await bridge.snapshotForDispatch()).toEqual([]);
230
+ expect(bridge.isOperational()).toBe(false);
231
+
232
+ const r = await bridge.executeToolCall("webmcp:search", {});
233
+ expect(r.isError).toBe(true);
234
+ expect((r.content[0] as { text: string }).text).toMatch(/present but/i);
235
+
236
+ // Warned about the incompatible context, exactly once despite multiple hits.
237
+ const incompatWarnings = warnSpy.mock.calls.filter(([msg]) =>
238
+ String(msg).includes("does not expose getTools()/executeTool()"),
239
+ );
240
+ expect(incompatWarnings).toHaveLength(1);
241
+ warnSpy.mockRestore();
242
+ });
243
+
244
+ it("strips the webmcp: prefix before registry lookup and forwards args", async () => {
245
+ const execute = vi.fn(() => ({ matches: 3 }));
246
+ registry.tools = [fakeTool({ name: "search", execute })];
247
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
248
+ const r = await bridge.executeToolCall("webmcp:search", { q: "shoes" });
249
+ expect(execute).toHaveBeenCalledTimes(1);
250
+ expect(execute).toHaveBeenCalledWith({ q: "shoes" }, expect.anything());
251
+ expect(r.isError).toBeUndefined();
252
+ });
253
+
254
+ it("returns isError when the tool is not in the registry (unmount race)", async () => {
255
+ registry.tools = [fakeTool({ name: "search" })];
256
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
257
+ const r = await bridge.executeToolCall("webmcp:add_to_cart", {});
258
+ expect(r.isError).toBe(true);
259
+ expect((r.content[0] as { text: string }).text).toMatch(/not registered/);
260
+ });
261
+
262
+ it("normalizes a string return into a single text content block", async () => {
263
+ registry.tools = [fakeTool({ name: "ping", execute: () => "pong" })];
264
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
265
+ const r = await bridge.executeToolCall("webmcp:ping", {});
266
+ expect(r).toEqual({ content: [{ type: "text", text: "pong" }] });
267
+ });
268
+
269
+ it("normalizes an object return by JSON-stringifying it", async () => {
270
+ registry.tools = [
271
+ fakeTool({ name: "lookup", execute: () => ({ found: true, n: 7 }) }),
272
+ ];
273
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
274
+ const r = await bridge.executeToolCall("webmcp:lookup", {});
275
+ expect(r.content[0]).toEqual({
276
+ type: "text",
277
+ text: JSON.stringify({ found: true, n: 7 }),
278
+ });
279
+ });
280
+
281
+ it("passes already-MCP-shaped returns through unchanged", async () => {
282
+ const shaped: WebMcpToolResult = {
283
+ content: [
284
+ { type: "text", text: "hi" },
285
+ { type: "image", url: "x" },
286
+ ],
287
+ };
288
+ registry.tools = [fakeTool({ name: "render", execute: () => shaped })];
289
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
290
+ const r = await bridge.executeToolCall("webmcp:render", {});
291
+ expect(r).toEqual(shaped);
292
+ });
293
+
294
+ it("normalizes an undefined return into an empty text block", async () => {
295
+ registry.tools = [fakeTool({ name: "act", execute: () => undefined })];
296
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
297
+ const r = await bridge.executeToolCall("webmcp:act", {});
298
+ expect(r).toEqual({ content: [{ type: "text", text: "" }] });
299
+ });
300
+
301
+ it("runs a tool's client.requestUserInteraction callback without a second confirm", async () => {
302
+ const confirmSpy = vi.fn(async () => true);
303
+ registry.tools = [
304
+ fakeTool({
305
+ name: "sensitive",
306
+ execute: async (_args, client) => {
307
+ const ack = await client.requestUserInteraction(async () => "ok!");
308
+ return { ack };
309
+ },
310
+ }),
311
+ ];
312
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: confirmSpy });
313
+ const r = await bridge.executeToolCall("webmcp:sensitive", {});
314
+ // Only the single outer gate fires — the polyfill owns the in-tool callback.
315
+ expect(confirmSpy).toHaveBeenCalledTimes(1);
316
+ expect(r.isError).toBeUndefined();
317
+ expect(r.content[0]).toEqual({
318
+ type: "text",
319
+ text: JSON.stringify({ ack: "ok!" }),
320
+ });
321
+ });
322
+
323
+ it("returns isError when the user declines the confirm gate", async () => {
324
+ const decline: WebMcpConfirmHandler = vi.fn(async () => false);
325
+ const executeSpy = vi.fn(() => "should not run");
326
+ registry.tools = [fakeTool({ name: "checkout", execute: executeSpy })];
327
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: decline });
328
+ const r = await bridge.executeToolCall("webmcp:checkout", { sku: "x" });
329
+ expect(r.isError).toBe(true);
330
+ expect((r.content[0] as { text: string }).text).toMatch(/declined/);
331
+ expect(decline).toHaveBeenCalledTimes(1);
332
+ expect(executeSpy).not.toHaveBeenCalled();
333
+ });
334
+
335
+ it("translates a thrown execute() into an isError result (no rethrow)", async () => {
336
+ registry.tools = [
337
+ fakeTool({
338
+ name: "boom",
339
+ execute: () => {
340
+ throw new Error("network down");
341
+ },
342
+ }),
343
+ ];
344
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
345
+ const r = await bridge.executeToolCall("webmcp:boom", {});
346
+ expect(r.isError).toBe(true);
347
+ expect((r.content[0] as { text: string }).text).toBe("network down");
348
+ });
349
+
350
+ it("times out an execute() that exceeds the 30s budget", async () => {
351
+ vi.useFakeTimers();
352
+ try {
353
+ registry.tools = [
354
+ fakeTool({ name: "slow", execute: () => new Promise(() => undefined) }),
355
+ ];
356
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
357
+ const pending = bridge.executeToolCall("webmcp:slow", {});
358
+ await vi.advanceTimersByTimeAsync(30_000);
359
+ const r = await pending;
360
+ expect(r.isError).toBe(true);
361
+ expect((r.content[0] as { text: string }).text).toMatch(/timed out/);
362
+ } finally {
363
+ vi.useRealTimers();
364
+ }
365
+ });
366
+
367
+ it("bails before rendering the confirm bubble when signal is already aborted", async () => {
368
+ // A late approval after cancel() must not fire a host-page side effect. The
369
+ // bridge checks the signal BEFORE rendering the confirm.
370
+ const confirmSpy = vi.fn(async () => true);
371
+ const executeSpy = vi.fn(() => "should not run");
372
+ registry.tools = [fakeTool({ name: "checkout", execute: executeSpy })];
373
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: confirmSpy });
374
+ const controller = new AbortController();
375
+ controller.abort();
376
+ const r = await bridge.executeToolCall(
377
+ "webmcp:checkout",
378
+ {},
379
+ controller.signal,
380
+ );
381
+ expect(r.isError).toBe(true);
382
+ expect((r.content[0] as { text: string }).text).toMatch(/abort/i);
383
+ expect(confirmSpy).not.toHaveBeenCalled();
384
+ expect(executeSpy).not.toHaveBeenCalled();
385
+ });
386
+
387
+ it("aborts a stuck execute() when the signal fires mid-flight", async () => {
388
+ let resolveStuck: ((v: string) => void) | undefined;
389
+ const stuck = new Promise<string>((resolve) => {
390
+ resolveStuck = resolve;
391
+ });
392
+ const executeSpy = vi.fn(() => stuck);
393
+ registry.tools = [fakeTool({ name: "slow", execute: executeSpy })];
394
+ const bridge = new WebMcpBridge({ enabled: true, onConfirm: allowAll });
395
+ const controller = new AbortController();
396
+ const pending = bridge.executeToolCall(
397
+ "webmcp:slow",
398
+ {},
399
+ controller.signal,
400
+ );
401
+ // Wait until execute() has actually started, then cancel.
402
+ await vi.waitFor(() => expect(executeSpy).toHaveBeenCalledTimes(1));
403
+ controller.abort();
404
+ const r = await pending;
405
+ expect(r.isError).toBe(true);
406
+ expect((r.content[0] as { text: string }).text).toMatch(/abort/i);
407
+ // Late resolve from the page side — must not poison anything.
408
+ resolveStuck?.("late");
409
+ });
410
+
411
+ it("rejects a webmcp call for a tool excluded by the client allowlist", async () => {
412
+ // snapshotForDispatch filters by allowlist for the wire surface, but
413
+ // executeToolCall must also re-check it — defense-in-depth alongside the
414
+ // server-side check.
415
+ const executeSpy = vi.fn(() => "should not run");
416
+ registry.tools = [
417
+ fakeTool({ name: "secret_admin_action", execute: executeSpy }),
418
+ ];
419
+ const bridge = new WebMcpBridge({
420
+ enabled: true,
421
+ onConfirm: allowAll,
422
+ allowlist: ["search_*"],
423
+ });
424
+ const r = await bridge.executeToolCall("webmcp:secret_admin_action", {});
425
+ expect(r.isError).toBe(true);
426
+ expect((r.content[0] as { text: string }).text).toMatch(/allowlist/i);
427
+ expect(executeSpy).not.toHaveBeenCalled();
428
+ });
429
+ });