@hyperframes/studio 0.8.15 → 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.
- package/dist/assets/hyperframes-player-DejBDgXB.js +459 -0
- package/dist/assets/index-BX3KHhGX.js +71 -0
- package/dist/assets/{index-DKoZIpw1.js → index-CU6o8PuW.js} +180 -180
- package/dist/assets/{index-CVtYYTnA.js → index-CklNVmi3.js} +1 -1
- package/dist/assets/{index-CtDg2lB7.js → index-uu4Zd3BU.js} +1 -1
- package/dist/index.d.ts +9 -13
- package/dist/index.html +1 -1
- package/dist/index.js +1660 -1284
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/components/EditorShell.tsx +4 -0
- package/src/components/editor/DomEditCropHandles.test.tsx +1 -1
- package/src/components/editor/DomEditCropHandles.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -1
- package/src/components/editor/DomEditSelectionChrome.tsx +1 -1
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +38 -4
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +1 -1
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +8 -8
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
- package/src/components/editor/propertyPanelStyleSections.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +1 -1
- package/src/components/editor/useDomEditOverlayGestures.ts +8 -2
- package/src/components/editor/useInspectorGestureTransaction.ts +3 -3
- package/src/components/sidebar/AudioRow.tsx +13 -2
- package/src/hooks/domEditCommitRunner.ts +47 -0
- package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
- package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
- package/src/hooks/useDomEditTextCommits.ts +17 -9
- package/src/hooks/useDomEditWiring.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
- package/src/hooks/useDomGeometryCommits.ts +10 -2
- package/src/hooks/useDomSelection.test.ts +48 -0
- package/src/hooks/useDomSelection.ts +26 -82
- package/src/hooks/useDomSelectionTypes.ts +82 -0
- package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
- package/src/hooks/useElementLifecycleOps.ts +11 -5
- package/src/hooks/useGsapSelectionHandlers.ts +4 -2
- package/src/hooks/usePanelLayout.test.ts +38 -0
- package/src/hooks/usePanelLayout.ts +21 -6
- package/src/player/lib/playbackTypes.ts +3 -12
- package/src/player/lib/runtimeProtocol.test.ts +1 -0
- package/src/utils/studioUiPreferences.ts +11 -0
- package/src/webmcp/StudioAgentTools.tsx +46 -0
- package/src/webmcp/handles.test.ts +130 -0
- package/src/webmcp/handles.ts +129 -0
- package/src/webmcp/polyfill.test.ts +98 -0
- package/src/webmcp/polyfill.ts +60 -0
- package/src/webmcp/registrar.test.ts +150 -0
- package/src/webmcp/registrar.ts +115 -0
- package/src/webmcp/toolResult.ts +67 -0
- package/src/webmcp/tools/lookTools.test.ts +225 -0
- package/src/webmcp/tools/lookTools.ts +201 -0
- package/src/webmcp/types.ts +90 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
- package/src/webmcp/useStudioAgentTools.ts +119 -0
- package/dist/assets/hyperframes-player-BlIp1Y-b.js +0 -459
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape every Studio tool resolves with.
|
|
3
|
+
*
|
|
4
|
+
* Tools resolve, they never reject. That is forced by the spec, not a style
|
|
5
|
+
* choice: a rejected `execute` has its reason DISCARDED and the caller is
|
|
6
|
+
* rejected with a bare `UnknownError` (`index.bs`, the execute-tool completion
|
|
7
|
+
* steps). Rejecting would therefore guarantee the agent cannot see why the edit
|
|
8
|
+
* failed, which is the one thing it needs most.
|
|
9
|
+
*
|
|
10
|
+
* There is no `outputSchema` in the platform yet, so this discriminant is
|
|
11
|
+
* invisible to the agent's schema layer. Every tool's `description` has to say
|
|
12
|
+
* that it returns `ok`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ToolFailureKind =
|
|
16
|
+
/** A real state the agent can route around: save queue paused, capability off. */
|
|
17
|
+
| "blocked"
|
|
18
|
+
/** The agent's fault: unknown handle, bad enum, out of range. */
|
|
19
|
+
| "invalid"
|
|
20
|
+
/** Exogenous: the server said no, the patch target could not be resolved. */
|
|
21
|
+
| "failed"
|
|
22
|
+
/** Our bug. Reported AND re-thrown, so it is findable instead of plausible. */
|
|
23
|
+
| "internal";
|
|
24
|
+
|
|
25
|
+
export interface ToolFailure {
|
|
26
|
+
ok: false;
|
|
27
|
+
kind: ToolFailureKind;
|
|
28
|
+
reason: string;
|
|
29
|
+
/** What to try instead. This is what turns a failure into a next action. */
|
|
30
|
+
hint?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ToolResult<T> = ({ ok: true } & T) | ToolFailure;
|
|
34
|
+
|
|
35
|
+
export function toolOk<T extends object>(value: T): { ok: true } & T {
|
|
36
|
+
return { ok: true, ...value };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
|
|
40
|
+
return hint ? { ok: false, kind, reason, hint } : { ok: false, kind, reason };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Wrap a tool body so a thrown bug becomes a legible result instead of a
|
|
45
|
+
* rejection the agent cannot read.
|
|
46
|
+
*
|
|
47
|
+
* The split matters. A `TypeError` means a handler signature moved under us and
|
|
48
|
+
* the tool is permanently broken; reporting that as an ordinary failure would
|
|
49
|
+
* let it ship looking like a bad request forever. So it is tagged `internal`
|
|
50
|
+
* AND re-thrown to the console, where it is findable. Everything else is a
|
|
51
|
+
* failure the agent should route around.
|
|
52
|
+
*/
|
|
53
|
+
export async function runToolBody<T>(
|
|
54
|
+
toolName: string,
|
|
55
|
+
body: () => Promise<ToolResult<T>>,
|
|
56
|
+
): Promise<ToolResult<T>> {
|
|
57
|
+
try {
|
|
58
|
+
return await body();
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
61
|
+
if (error instanceof TypeError || error instanceof ReferenceError) {
|
|
62
|
+
console.error(`[hf-webmcp] ${toolName} threw`, error);
|
|
63
|
+
return toolFailure("internal", reason);
|
|
64
|
+
}
|
|
65
|
+
return toolFailure("failed", reason);
|
|
66
|
+
}
|
|
67
|
+
}
|