@hyperframes/studio 0.8.16 → 0.8.17

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 (53) hide show
  1. package/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DejBDgXB.js} +1 -1
  2. package/dist/assets/index-BX3KHhGX.js +71 -0
  3. package/dist/assets/{index-D8o3ZIo2.js → index-CU6o8PuW.js} +128 -128
  4. package/dist/assets/{index-Cf-mbMRL.js → index-CklNVmi3.js} +1 -1
  5. package/dist/assets/{index-YmetcS6L.js → index-uu4Zd3BU.js} +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +1638 -1280
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/components/EditorShell.tsx +4 -0
  12. package/src/components/editor/DomEditCropHandles.test.tsx +1 -1
  13. package/src/components/editor/DomEditCropHandles.tsx +1 -1
  14. package/src/components/editor/DomEditOverlay.tsx +1 -1
  15. package/src/components/editor/DomEditSelectionChrome.tsx +1 -1
  16. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +38 -4
  17. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  18. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +3 -3
  19. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +1 -1
  20. package/src/components/editor/propertyPanelFlatMediaSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +8 -8
  23. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  24. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  25. package/src/components/editor/propertyPanelStyleSections.tsx +1 -1
  26. package/src/components/editor/propertyPanelTypes.ts +1 -1
  27. package/src/components/editor/useDomEditOverlayGestures.ts +8 -2
  28. package/src/components/editor/useInspectorGestureTransaction.ts +3 -3
  29. package/src/hooks/domEditCommitRunner.ts +47 -0
  30. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
  31. package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
  32. package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
  33. package/src/hooks/useDomEditTextCommits.ts +17 -9
  34. package/src/hooks/useDomEditWiring.ts +1 -1
  35. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  36. package/src/hooks/useDomGeometryCommits.ts +10 -2
  37. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
  38. package/src/hooks/useElementLifecycleOps.ts +11 -5
  39. package/src/hooks/useGsapSelectionHandlers.ts +4 -2
  40. package/src/utils/studioUiPreferences.ts +11 -0
  41. package/src/webmcp/StudioAgentTools.tsx +46 -0
  42. package/src/webmcp/handles.test.ts +130 -0
  43. package/src/webmcp/handles.ts +129 -0
  44. package/src/webmcp/polyfill.test.ts +98 -0
  45. package/src/webmcp/polyfill.ts +60 -0
  46. package/src/webmcp/registrar.test.ts +150 -0
  47. package/src/webmcp/registrar.ts +115 -0
  48. package/src/webmcp/toolResult.ts +67 -0
  49. package/src/webmcp/tools/lookTools.test.ts +225 -0
  50. package/src/webmcp/tools/lookTools.ts +201 -0
  51. package/src/webmcp/types.ts +90 -0
  52. package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
  53. package/src/webmcp/useStudioAgentTools.ts +119 -0
@@ -0,0 +1,130 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import type { TimelineElement } from "../player/store/timelineElement";
4
+ import {
5
+ mintElementHandle,
6
+ parseElementHandle,
7
+ resolveElementHandle,
8
+ timelineElementAddress,
9
+ } from "./handles";
10
+
11
+ function timelineElement(overrides: Partial<TimelineElement>): TimelineElement {
12
+ return { id: "synthetic-id", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
13
+ }
14
+
15
+ /** A separate document, standing in for the preview iframe's realm. */
16
+ function previewDoc(html: string): Document {
17
+ const iframe = document.createElement("iframe");
18
+ document.body.append(iframe);
19
+ const doc = iframe.contentDocument;
20
+ if (!doc) throw new Error("expected iframe document");
21
+ doc.body.innerHTML = html;
22
+ return doc;
23
+ }
24
+
25
+ describe("mintElementHandle", () => {
26
+ it("prefers data-hf-id, the stable patch target", () => {
27
+ const handle = mintElementHandle(
28
+ timelineElementAddress(
29
+ timelineElement({ hfId: "abc123", domId: "headline", selector: ".title" }),
30
+ ),
31
+ );
32
+ expect(handle).toBe("hf:abc123");
33
+ });
34
+
35
+ it("falls back to the DOM id when there is no hf id", () => {
36
+ expect(
37
+ mintElementHandle(
38
+ timelineElementAddress(timelineElement({ domId: "headline", selector: ".title" })),
39
+ ),
40
+ ).toBe("dom:headline");
41
+ });
42
+
43
+ it("falls back to a selector with its occurrence index", () => {
44
+ expect(
45
+ mintElementHandle(
46
+ timelineElementAddress(timelineElement({ selector: ".card", selectorIndex: 2 })),
47
+ ),
48
+ ).toBe("sel:.card#2");
49
+ });
50
+
51
+ it("defaults a missing occurrence index to the first match", () => {
52
+ expect(mintElementHandle(timelineElementAddress(timelineElement({ selector: ".card" })))).toBe(
53
+ "sel:.card#0",
54
+ );
55
+ });
56
+
57
+ it("returns null when the element carries no way to address it", () => {
58
+ // The synthesised `id` is deliberately NOT used: it cannot resolve.
59
+ expect(mintElementHandle(timelineElementAddress(timelineElement({})))).toBeNull();
60
+ });
61
+ });
62
+
63
+ describe("parseElementHandle", () => {
64
+ it("splits the index off the LAST hash, so id selectors survive", () => {
65
+ expect(parseElementHandle("sel:#card > .title#3")).toEqual({
66
+ scheme: "sel",
67
+ value: "#card > .title",
68
+ index: 3,
69
+ });
70
+ });
71
+
72
+ it("treats a selector with no index as the first match", () => {
73
+ expect(parseElementHandle("sel:.card")).toEqual({ scheme: "sel", value: ".card", index: 0 });
74
+ });
75
+
76
+ it("rejects an unknown scheme", () => {
77
+ expect(parseElementHandle("xpath://div")).toBeNull();
78
+ });
79
+
80
+ it("rejects a handle with no value", () => {
81
+ expect(parseElementHandle("dom:")).toBeNull();
82
+ expect(parseElementHandle("")).toBeNull();
83
+ expect(parseElementHandle(":headline")).toBeNull();
84
+ });
85
+ });
86
+
87
+ describe("resolveElementHandle", () => {
88
+ it("round-trips every handle scheme a read can mint", () => {
89
+ const doc = previewDoc(
90
+ `<div id="headline" data-hf-id="abc123">A</div>
91
+ <div class="card">first</div>
92
+ <div class="card">second</div>`,
93
+ );
94
+
95
+ expect(resolveElementHandle(doc, "hf:abc123")?.id).toBe("headline");
96
+ expect(resolveElementHandle(doc, "dom:headline")?.id).toBe("headline");
97
+ expect(resolveElementHandle(doc, "sel:.card#1")?.textContent).toBe("second");
98
+ });
99
+
100
+ it("resolves across realms, where a naive instanceof check fails", () => {
101
+ const doc = previewDoc('<div id="headline">A</div>');
102
+ const resolved = resolveElementHandle(doc, "dom:headline");
103
+
104
+ expect(resolved).not.toBeNull();
105
+ // The preview element is NOT an instance of Studio's own HTMLElement.
106
+ expect(resolved instanceof HTMLElement).toBe(false);
107
+ });
108
+
109
+ it("returns null for a handle that no longer matches", () => {
110
+ const doc = previewDoc('<div id="headline">A</div>');
111
+ expect(resolveElementHandle(doc, "dom:deleted")).toBeNull();
112
+ expect(resolveElementHandle(doc, "hf:missing")).toBeNull();
113
+ expect(resolveElementHandle(doc, "sel:.card#0")).toBeNull();
114
+ });
115
+
116
+ it("returns null for an out-of-range occurrence rather than the wrong element", () => {
117
+ const doc = previewDoc('<div class="card">only</div>');
118
+ expect(resolveElementHandle(doc, "sel:.card#4")).toBeNull();
119
+ });
120
+
121
+ it("returns null for a selector that is invalid in this document", () => {
122
+ const doc = previewDoc('<div class="card">only</div>');
123
+ expect(resolveElementHandle(doc, "sel:>>>broken#0")).toBeNull();
124
+ });
125
+
126
+ it("returns null for a malformed handle", () => {
127
+ const doc = previewDoc('<div id="headline">A</div>');
128
+ expect(resolveElementHandle(doc, "nonsense")).toBeNull();
129
+ });
130
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Opaque element handles: the one thing reads mint and writes consume.
3
+ *
4
+ * `TimelineElement.id` cannot do this job. It is a SYNTHESISED identity built
5
+ * from label, index, selector and source file when the clip has no authored id
6
+ * (`timelineElementHelpers.buildTimelineElementIdentity`), so
7
+ * `getElementById(element.id)` misses most elements. The real addressing fields
8
+ * are separate: `hfId` (the `data-hf-id` the codebase calls the stable primary
9
+ * patch target), `domId`, and a `selector` plus occurrence index.
10
+ *
11
+ * Handles are strings so they survive a JSON round trip through the agent
12
+ * untouched. The agent never builds one; it passes back what a read gave it.
13
+ */
14
+
15
+ import type { TimelineElement } from "../player/store/timelineElement";
16
+ import type { PatchTarget } from "../utils/sourcePatcher";
17
+
18
+ const SEPARATOR = ":";
19
+ const INDEX_SEPARATOR = "#";
20
+
21
+ /**
22
+ * How to find one element. `TimelineElement` calls the DOM id `domId` and
23
+ * `PatchTarget` calls it `id`, so both adapt into this rather than the minter
24
+ * knowing about either.
25
+ */
26
+ export interface ElementAddress {
27
+ hfId?: string;
28
+ domId?: string | null;
29
+ selector?: string;
30
+ selectorIndex?: number;
31
+ }
32
+
33
+ /**
34
+ * Address an element the same way Studio's own patcher does, most stable first.
35
+ * `data-hf-id` survives edits that renumber or reorder; a bare selector does not.
36
+ */
37
+ export function mintElementHandle(address: ElementAddress): string | null {
38
+ if (address.hfId) return `hf${SEPARATOR}${address.hfId}`;
39
+ if (address.domId) return `dom${SEPARATOR}${address.domId}`;
40
+ if (address.selector) {
41
+ const index = address.selectorIndex ?? 0;
42
+ return `sel${SEPARATOR}${address.selector}${INDEX_SEPARATOR}${index}`;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export function timelineElementAddress(element: TimelineElement): ElementAddress {
48
+ return {
49
+ hfId: element.hfId,
50
+ domId: element.domId,
51
+ selector: element.selector,
52
+ selectorIndex: element.selectorIndex,
53
+ };
54
+ }
55
+
56
+ export function patchTargetAddress(target: PatchTarget): ElementAddress {
57
+ return {
58
+ hfId: target.hfId,
59
+ domId: target.id,
60
+ selector: target.selector,
61
+ selectorIndex: target.selectorIndex,
62
+ };
63
+ }
64
+
65
+ interface ParsedHandle {
66
+ scheme: "hf" | "dom" | "sel";
67
+ value: string;
68
+ index: number;
69
+ }
70
+
71
+ export function parseElementHandle(handle: string): ParsedHandle | null {
72
+ const separatorAt = handle.indexOf(SEPARATOR);
73
+ if (separatorAt <= 0) return null;
74
+ const scheme = handle.slice(0, separatorAt);
75
+ const rest = handle.slice(separatorAt + 1);
76
+ if (!rest) return null;
77
+ if (scheme === "hf" || scheme === "dom") return { scheme, value: rest, index: 0 };
78
+ if (scheme !== "sel") return null;
79
+
80
+ // Only the LAST `#` splits the index off: CSS selectors contain `#` themselves.
81
+ const indexAt = rest.lastIndexOf(INDEX_SEPARATOR);
82
+ if (indexAt <= 0) return { scheme, value: rest, index: 0 };
83
+ const index = Number(rest.slice(indexAt + 1));
84
+ if (!Number.isInteger(index) || index < 0) return { scheme, value: rest, index: 0 };
85
+ return { scheme, value: rest.slice(0, indexAt), index };
86
+ }
87
+
88
+ /**
89
+ * Resolve a handle against the preview document.
90
+ *
91
+ * Always re-resolve per call rather than holding an element across calls: a
92
+ * preview reload replaces the document, and a node from the destroyed one is
93
+ * detached but still looks like an element.
94
+ */
95
+ export function resolveElementHandle(doc: Document, handle: string): HTMLElement | null {
96
+ const parsed = parseElementHandle(handle);
97
+ if (!parsed) return null;
98
+
99
+ if (parsed.scheme === "dom") return asHtmlElement(doc, doc.getElementById(parsed.value));
100
+ if (parsed.scheme === "hf") {
101
+ return asHtmlElement(doc, doc.querySelector(`[data-hf-id="${cssEscape(parsed.value)}"]`));
102
+ }
103
+
104
+ let matches: NodeListOf<Element>;
105
+ try {
106
+ matches = doc.querySelectorAll(parsed.value);
107
+ } catch {
108
+ // A selector minted from a previous document can be invalid in this one.
109
+ return null;
110
+ }
111
+ return asHtmlElement(doc, matches.item(parsed.index));
112
+ }
113
+
114
+ function cssEscape(value: string): string {
115
+ // ponytail: happy-dom and jsdom don't always ship CSS.escape; quoting the two
116
+ // characters that can break out of an attribute selector covers this use.
117
+ return typeof CSS?.escape === "function" ? CSS.escape(value) : value.replace(/["\\]/g, "\\$&");
118
+ }
119
+
120
+ /**
121
+ * `instanceof HTMLElement` is checked against the OWNING document's realm.
122
+ * The preview lives in an iframe, so Studio's own `HTMLElement` is a different
123
+ * constructor and the naive check fails on every real preview element.
124
+ */
125
+ function asHtmlElement(doc: Document, node: Element | null): HTMLElement | null {
126
+ if (!node) return null;
127
+ const ctor = doc.defaultView?.HTMLElement;
128
+ return ctor && node instanceof ctor ? node : null;
129
+ }
@@ -0,0 +1,98 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { ModelContext } from "./types";
4
+
5
+ // The real package defines `document.modelContext` as an import side effect.
6
+ // A mock cannot do that, so tests stand the object up themselves to represent
7
+ // the import having happened.
8
+ const trackEvent = vi.hoisted(() => vi.fn());
9
+ vi.mock("@mcp-b/global", () => ({}));
10
+ vi.mock("../telemetry/client", () => ({ trackEvent }));
11
+
12
+ let loadModelContextPolyfill: typeof import("./polyfill").loadModelContextPolyfill;
13
+
14
+ function installModelContext(): ModelContext {
15
+ const modelContext: ModelContext = { registerTool: vi.fn().mockResolvedValue(undefined) };
16
+ Object.defineProperty(document, "modelContext", {
17
+ value: modelContext,
18
+ configurable: true,
19
+ writable: true,
20
+ });
21
+ return modelContext;
22
+ }
23
+
24
+ beforeEach(async () => {
25
+ vi.resetModules();
26
+ ({ loadModelContextPolyfill } = await import("./polyfill"));
27
+ trackEvent.mockReset();
28
+ });
29
+
30
+ afterEach(() => {
31
+ Reflect.deleteProperty(document, "modelContext");
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ describe("loadModelContextPolyfill", () => {
36
+ it("returns the model context the package defines", async () => {
37
+ const modelContext = installModelContext();
38
+
39
+ await expect(loadModelContextPolyfill()).resolves.toBe(modelContext);
40
+ expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_loaded");
41
+ });
42
+
43
+ it("shares one load between callers that race", async () => {
44
+ installModelContext();
45
+
46
+ // Identity, not a call count: the guard being tested is the module-level
47
+ // promise, and the ESM registry would dedupe the import either way.
48
+ const first = loadModelContextPolyfill();
49
+ const second = loadModelContextPolyfill();
50
+
51
+ expect(first).toBe(second);
52
+ await expect(first).resolves.toBe(await second);
53
+ });
54
+
55
+ it("reuses the settled load rather than starting another", async () => {
56
+ installModelContext();
57
+
58
+ const first = loadModelContextPolyfill();
59
+ await first;
60
+
61
+ expect(loadModelContextPolyfill()).toBe(first);
62
+ });
63
+
64
+ it("returns null when the package loads but defines nothing", async () => {
65
+ // Studio must still boot. A missing agent surface is not a broken editor.
66
+ const first = loadModelContextPolyfill();
67
+ await expect(first).resolves.toBeNull();
68
+
69
+ expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_failed", {
70
+ error_name: "ModelContextMissingError",
71
+ });
72
+ const retry = loadModelContextPolyfill();
73
+ expect(retry).not.toBe(first);
74
+ await expect(retry).resolves.toBeNull();
75
+ });
76
+
77
+ it("reports a polyfill failure and lets a later mount retry", async () => {
78
+ const failure = new TypeError("blocked by policy");
79
+ Object.defineProperty(document, "modelContext", {
80
+ configurable: true,
81
+ get: () => {
82
+ throw failure;
83
+ },
84
+ });
85
+
86
+ const first = loadModelContextPolyfill();
87
+ await expect(first).resolves.toBeNull();
88
+ expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_failed", {
89
+ error_name: "TypeError",
90
+ });
91
+
92
+ Reflect.deleteProperty(document, "modelContext");
93
+ const modelContext = installModelContext();
94
+ const retry = loadModelContextPolyfill();
95
+ expect(retry).not.toBe(first);
96
+ await expect(retry).resolves.toBe(modelContext);
97
+ });
98
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The fallback for browsers that have not shipped WebMCP.
3
+ *
4
+ * `@mcp-b/global` does two things: it defines `document.modelContext`, and it
5
+ * stands up an in-page MCP server for a bridge extension to attach to. The
6
+ * second is the reason this is the chosen package over the bare
7
+ * `@mcp-b/webmcp-polyfill`: without the server there is nothing for an
8
+ * out-of-browser agent to connect to, which is the only case the fallback
9
+ * exists to serve.
10
+ *
11
+ * It is a DYNAMIC import so a browser with native support never downloads it,
12
+ * and so it lands in its own chunk rather than the entry bundle.
13
+ */
14
+
15
+ import { makeStudioDebugLogger } from "../utils/studioDebug";
16
+ import { trackEvent } from "../telemetry/client";
17
+ import { getModelContext, type ModelContext } from "./types";
18
+
19
+ const log = makeStudioDebugLogger("webmcp");
20
+
21
+ /**
22
+ * Module-level, so two mounts racing (React StrictMode, or a remount during
23
+ * the import) share one load instead of pulling the package twice.
24
+ */
25
+ let pending: Promise<ModelContext | null> | null = null;
26
+
27
+ async function importPolyfill(): Promise<ModelContext | null> {
28
+ try {
29
+ await import("@mcp-b/global");
30
+ const modelContext = getModelContext();
31
+ if (!modelContext) {
32
+ // The package loaded but did not define what it promises to define.
33
+ log("polyfill", { loaded: true, modelContext: false });
34
+ trackEvent("webmcp.polyfill_failed", { error_name: "ModelContextMissingError" });
35
+ } else {
36
+ trackEvent("webmcp.polyfill_loaded");
37
+ }
38
+ return modelContext;
39
+ } catch (error) {
40
+ // A missing agent surface must never break Studio's boot.
41
+ log("polyfill", { failed: error instanceof Error ? error.message : String(error) });
42
+ trackEvent("webmcp.polyfill_failed", {
43
+ error_name: error instanceof Error ? error.name : "NonError",
44
+ });
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function loadModelContextPolyfill(): Promise<ModelContext | null> {
50
+ if (pending) return pending;
51
+
52
+ const attempt = importPolyfill();
53
+ pending = attempt;
54
+ // A transient chunk/CSP failure must not disable WebMCP for the rest of the
55
+ // tab. Concurrent callers still share this attempt; a later mount may retry.
56
+ void attempt.then((modelContext) => {
57
+ if (modelContext === null && pending === attempt) pending = null;
58
+ });
59
+ return pending;
60
+ }
@@ -0,0 +1,150 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { findToolDefinitionError, registerStudioTools } from "./registrar";
4
+ import type { ModelContext, ModelContextTool } from "./types";
5
+
6
+ function tool(overrides: Partial<ModelContextTool> = {}): ModelContextTool {
7
+ return {
8
+ name: "studio_look",
9
+ description: "Read Studio's live state.",
10
+ execute: async () => ({ ok: true }),
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ function fakeModelContext(
16
+ registerTool: ModelContext["registerTool"] = vi.fn().mockResolvedValue(undefined),
17
+ ): ModelContext {
18
+ return { registerTool };
19
+ }
20
+
21
+ function domException(name: string, message = name): DOMException {
22
+ return new DOMException(message, name);
23
+ }
24
+
25
+ describe("findToolDefinitionError", () => {
26
+ it("accepts the names Studio actually uses", () => {
27
+ expect(findToolDefinitionError(tool({ name: "studio_look" }))).toBeNull();
28
+ expect(findToolDefinitionError(tool({ name: "studio.look-2" }))).toBeNull();
29
+ });
30
+
31
+ it("rejects a name the browser would reject, naming the tool", () => {
32
+ expect(findToolDefinitionError(tool({ name: "studio look" }))).toMatch(/name must be/);
33
+ expect(findToolDefinitionError(tool({ name: "a".repeat(129) }))).toMatch(/name must be/);
34
+ expect(findToolDefinitionError(tool({ name: "" }))).toMatch(/name must be/);
35
+ });
36
+
37
+ it("rejects an empty description", () => {
38
+ expect(findToolDefinitionError(tool({ description: " " }))).toBe(
39
+ "description must not be empty",
40
+ );
41
+ });
42
+ });
43
+
44
+ describe("registerStudioTools", () => {
45
+ it("registers every tool with the shared abort signal", async () => {
46
+ const registerTool = vi.fn().mockResolvedValue(undefined);
47
+ const controller = new AbortController();
48
+
49
+ const report = await registerStudioTools(
50
+ fakeModelContext(registerTool),
51
+ [tool({ name: "studio_look" }), tool({ name: "studio_frame" })],
52
+ controller.signal,
53
+ );
54
+
55
+ expect(report.registered).toEqual(["studio_look", "studio_frame"]);
56
+ expect(report.failed).toEqual([]);
57
+ expect(registerTool).toHaveBeenCalledTimes(2);
58
+ expect(registerTool.mock.calls[0]?.[1]).toEqual({ signal: controller.signal });
59
+ });
60
+
61
+ it("stops silently when the signal aborts mid-registration", async () => {
62
+ // A StrictMode mount-cleanup-mount rejects the in-flight registrations with
63
+ // AbortError. That is teardown working; it must not surface as a failure or
64
+ // escape as an unhandled rejection.
65
+ const registerTool = vi
66
+ .fn()
67
+ .mockResolvedValueOnce(undefined)
68
+ .mockRejectedValueOnce(domException("AbortError"));
69
+
70
+ const report = await registerStudioTools(
71
+ fakeModelContext(registerTool),
72
+ [tool({ name: "studio_look" }), tool({ name: "studio_frame" })],
73
+ new AbortController().signal,
74
+ );
75
+
76
+ expect(report.registered).toEqual(["studio_look"]);
77
+ expect(report.failed).toEqual([]);
78
+ });
79
+
80
+ it("keeps the DOMException name, which is the only thing that tells the gates apart", async () => {
81
+ const registerTool = vi
82
+ .fn()
83
+ .mockRejectedValue(domException("SecurityError", "not origin-keyed"));
84
+
85
+ const report = await registerStudioTools(
86
+ fakeModelContext(registerTool),
87
+ [tool()],
88
+ new AbortController().signal,
89
+ );
90
+
91
+ expect(report.failed).toHaveLength(1);
92
+ expect(report.failed[0]?.tool).toBe("studio_look");
93
+ expect(report.failed[0]?.name).toBe("SecurityError");
94
+ // Substring, not equality: jsdom prefixes DOMException.message with the
95
+ // name and real browsers do not.
96
+ expect(report.failed[0]?.message).toContain("not origin-keyed");
97
+ expect(report.registered).toEqual([]);
98
+ });
99
+
100
+ it("keeps going after one tool fails", async () => {
101
+ const registerTool = vi
102
+ .fn()
103
+ .mockRejectedValueOnce(domException("NotAllowedError", "tools policy"))
104
+ .mockResolvedValueOnce(undefined);
105
+
106
+ const report = await registerStudioTools(
107
+ fakeModelContext(registerTool),
108
+ [tool({ name: "studio_look" }), tool({ name: "studio_frame" })],
109
+ new AbortController().signal,
110
+ );
111
+
112
+ expect(report.registered).toEqual(["studio_frame"]);
113
+ expect(report.failed.map((f) => f.tool)).toEqual(["studio_look"]);
114
+ });
115
+
116
+ it("catches a duplicate name before the browser does, so the report names it", async () => {
117
+ const registerTool = vi.fn().mockResolvedValue(undefined);
118
+
119
+ const report = await registerStudioTools(
120
+ fakeModelContext(registerTool),
121
+ [tool({ name: "studio_look" }), tool({ name: "studio_look" })],
122
+ new AbortController().signal,
123
+ );
124
+
125
+ expect(report.registered).toEqual(["studio_look"]);
126
+ expect(report.failed).toEqual([
127
+ {
128
+ tool: "studio_look",
129
+ name: "InvalidStateError",
130
+ message: "duplicate tool name in this registration set",
131
+ },
132
+ ]);
133
+ // Registering the same name twice REJECTS rather than replacing, so the
134
+ // second one must never reach the browser.
135
+ expect(registerTool).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ it("does not send a tool the browser would reject", async () => {
139
+ const registerTool = vi.fn().mockResolvedValue(undefined);
140
+
141
+ const report = await registerStudioTools(
142
+ fakeModelContext(registerTool),
143
+ [tool({ name: "studio look" })],
144
+ new AbortController().signal,
145
+ );
146
+
147
+ expect(registerTool).not.toHaveBeenCalled();
148
+ expect(report.failed[0]?.name).toBe("InvalidStateError");
149
+ });
150
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Registers Studio's tools with the browser, once.
3
+ *
4
+ * The only file besides `types.ts` that touches the WebMCP API, so a spec
5
+ * change lands here. Tool names are document-scoped, so Studio relies on its
6
+ * single live `EditorShell` mounting one `StudioAgentTools`. A second live
7
+ * shell would register the same names and receive `InvalidStateError`; the
8
+ * duplicate check below only owns duplicates within one registration set.
9
+ */
10
+
11
+ import type { ModelContext, ModelContextTool } from "./types";
12
+
13
+ export interface ToolRegistrationFailure {
14
+ tool: string;
15
+ /** The DOMException name where there is one. It is the only thing that tells
16
+ * a duplicate name (InvalidStateError) apart from a document that is not
17
+ * origin-keyed (SecurityError) or not permitted to use `tools`
18
+ * (NotAllowedError), and all three look identical without it. */
19
+ name: string;
20
+ message: string;
21
+ }
22
+
23
+ export interface ToolRegistrationReport {
24
+ registered: string[];
25
+ failed: ToolRegistrationFailure[];
26
+ }
27
+
28
+ /** Max 128 chars, ASCII alphanumeric plus `_`, `-`, `.` (`index.bs`). */
29
+ const VALID_TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
30
+
31
+ /**
32
+ * A tool whose name or description the browser would reject anyway. Caught here
33
+ * so the failure names the offending tool instead of arriving as one of N
34
+ * identical InvalidStateErrors.
35
+ */
36
+ export function findToolDefinitionError(tool: ModelContextTool): string | null {
37
+ if (!VALID_TOOL_NAME.test(tool.name)) {
38
+ return `name must be 1-128 chars of A-Z a-z 0-9 _ - . (got ${JSON.stringify(tool.name)})`;
39
+ }
40
+ if (!tool.description.trim()) return "description must not be empty";
41
+ return null;
42
+ }
43
+
44
+ function isAbortError(error: unknown): boolean {
45
+ return error instanceof DOMException && error.name === "AbortError";
46
+ }
47
+
48
+ function invalidState(tool: string, message: string): ToolRegistrationFailure {
49
+ return { tool, name: "InvalidStateError", message };
50
+ }
51
+
52
+ /**
53
+ * `"aborted"` is a third outcome, not a failure: teardown got there first and
54
+ * the caller should stop rather than record anything.
55
+ */
56
+ type RegisterOneOutcome =
57
+ | { status: "registered" }
58
+ | { status: "failed"; failure: ToolRegistrationFailure }
59
+ | { status: "aborted" };
60
+
61
+ async function registerOne(
62
+ modelContext: ModelContext,
63
+ tool: ModelContextTool,
64
+ signal: AbortSignal,
65
+ ): Promise<RegisterOneOutcome> {
66
+ const definitionError = findToolDefinitionError(tool);
67
+ if (definitionError) {
68
+ return { status: "failed", failure: invalidState(tool.name, definitionError) };
69
+ }
70
+
71
+ try {
72
+ await modelContext.registerTool(tool, { signal });
73
+ return { status: "registered" };
74
+ } catch (error) {
75
+ // A mount-cleanup-mount cycle (React StrictMode in dev) aborts the signal in
76
+ // the same task the registration promise is queued in, which rejects every
77
+ // registerTool with AbortError. That is teardown working, not a failure, and
78
+ // letting it escape fills the dev console with unhandled rejections.
79
+ if (isAbortError(error)) return { status: "aborted" };
80
+ return {
81
+ status: "failed",
82
+ failure: {
83
+ tool: tool.name,
84
+ name: error instanceof DOMException ? error.name : "Error",
85
+ message: error instanceof Error ? error.message : String(error),
86
+ },
87
+ };
88
+ }
89
+ }
90
+
91
+ export async function registerStudioTools(
92
+ modelContext: ModelContext,
93
+ tools: readonly ModelContextTool[],
94
+ signal: AbortSignal,
95
+ ): Promise<ToolRegistrationReport> {
96
+ const report: ToolRegistrationReport = { registered: [], failed: [] };
97
+ const seen = new Set<string>();
98
+
99
+ for (const tool of tools) {
100
+ // Registering a name twice REJECTS rather than replacing, so a duplicate
101
+ // must never reach the browser.
102
+ if (seen.has(tool.name)) {
103
+ report.failed.push(invalidState(tool.name, "duplicate tool name in this registration set"));
104
+ continue;
105
+ }
106
+ seen.add(tool.name);
107
+
108
+ const outcome = await registerOne(modelContext, tool, signal);
109
+ if (outcome.status === "aborted") return report;
110
+ if (outcome.status === "failed") report.failed.push(outcome.failure);
111
+ else report.registered.push(tool.name);
112
+ }
113
+
114
+ return report;
115
+ }