@hyperframes/studio 0.8.16 → 0.8.18
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-iAIHATIw.js → hyperframes-player-DzRNJZAz.js} +1 -1
- package/dist/assets/{index-Cf-mbMRL.js → index-B4qse6wy.js} +1 -1
- package/dist/assets/index-BX3KHhGX.js +71 -0
- package/dist/assets/{index-D8o3ZIo2.js → index-F-PUkOVc.js} +128 -128
- package/dist/assets/{index-YmetcS6L.js → index-SGl0bb71.js} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js +1640 -1282
- 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/nle/useCompositionStack.test.tsx +69 -0
- package/src/components/nle/useCompositionStack.ts +8 -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/useElementLifecycleOps.multiDelete.test.tsx +81 -31
- package/src/hooks/useElementLifecycleOps.ts +11 -5
- package/src/hooks/useGsapSelectionHandlers.ts +4 -2
- 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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `studio_look`: the one call that orients an agent.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately fat. Every field here is one the agent would otherwise have to
|
|
5
|
+
* spend a round trip discovering.
|
|
6
|
+
*
|
|
7
|
+
* The building is a pure function over a snapshot so it can be tested with
|
|
8
|
+
* values. Gathering the snapshot is the React layer's job.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { DomEditSelection } from "../../components/editor/domEditingTypes";
|
|
12
|
+
import type { TimelineElement } from "../../player/store/timelineElement";
|
|
13
|
+
import { mintElementHandle, patchTargetAddress, timelineElementAddress } from "../handles";
|
|
14
|
+
import { toolOk, type ToolResult } from "../toolResult";
|
|
15
|
+
|
|
16
|
+
export interface StudioLookSnapshot {
|
|
17
|
+
projectId: string | null;
|
|
18
|
+
compositionPath: string | null;
|
|
19
|
+
currentTime: number;
|
|
20
|
+
duration: number;
|
|
21
|
+
isPlaying: boolean;
|
|
22
|
+
elements: readonly TimelineElement[];
|
|
23
|
+
selection: DomEditSelection | null;
|
|
24
|
+
/** Live animations for the current selection arrive outside DomEditSelection. */
|
|
25
|
+
selectionAnimationCount: number;
|
|
26
|
+
/**
|
|
27
|
+
* The undo stack as Studio's shell actually exposes it.
|
|
28
|
+
*
|
|
29
|
+
* This is a weaker signal than a revision counter, and deliberately not
|
|
30
|
+
* dressed up as one: the depth lives in component-local state and is not
|
|
31
|
+
* reachable here without plumbing it through the shell context. What an agent
|
|
32
|
+
* CAN do is checkpoint `undoLabel` before a batch and notice it change to
|
|
33
|
+
* something it did not do, which means a human pressed undo and its earlier
|
|
34
|
+
* edits are gone.
|
|
35
|
+
*/
|
|
36
|
+
history: {
|
|
37
|
+
canUndo: boolean;
|
|
38
|
+
canRedo: boolean;
|
|
39
|
+
undoLabel: string | null;
|
|
40
|
+
redoLabel: string | null;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface LookElement {
|
|
45
|
+
/** Pass back to any tool that takes a handle. Null means unaddressable. */
|
|
46
|
+
handle: string | null;
|
|
47
|
+
label: string | null;
|
|
48
|
+
tag: string;
|
|
49
|
+
kind: string | null;
|
|
50
|
+
start: number;
|
|
51
|
+
duration: number;
|
|
52
|
+
track: number;
|
|
53
|
+
zIndex: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface LookSelection {
|
|
57
|
+
handle: string | null;
|
|
58
|
+
label: string;
|
|
59
|
+
tagName: string;
|
|
60
|
+
sourceFile: string;
|
|
61
|
+
box: { x: number; y: number; width: number; height: number };
|
|
62
|
+
text: string | null;
|
|
63
|
+
/** What this element will and will not accept, straight from Studio. */
|
|
64
|
+
can: {
|
|
65
|
+
editStyles: boolean;
|
|
66
|
+
move: boolean;
|
|
67
|
+
resize: boolean;
|
|
68
|
+
editText: boolean;
|
|
69
|
+
reasonIfDisabled: string | null;
|
|
70
|
+
};
|
|
71
|
+
animationCount: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Session-scoped response shape. There is intentionally no schema version:
|
|
76
|
+
* WebMCP consumers discover the current tool and schema when they connect
|
|
77
|
+
* rather than pinning a cached REST response contract.
|
|
78
|
+
*/
|
|
79
|
+
export interface StudioLook {
|
|
80
|
+
projectId: string | null;
|
|
81
|
+
compositionPath: string | null;
|
|
82
|
+
playhead: number;
|
|
83
|
+
duration: number;
|
|
84
|
+
isPlaying: boolean;
|
|
85
|
+
history: StudioLookSnapshot["history"];
|
|
86
|
+
selection: LookSelection | null;
|
|
87
|
+
elementCount: number;
|
|
88
|
+
elements: LookElement[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface StudioLookInput {
|
|
92
|
+
/** Case-insensitive substring match against label, tag, and handle. */
|
|
93
|
+
filter?: string;
|
|
94
|
+
/** Cap the returned list. The full count is always reported separately. */
|
|
95
|
+
limit?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DEFAULT_LIMIT = 200;
|
|
99
|
+
const MAX_FILTER_LENGTH = 128;
|
|
100
|
+
|
|
101
|
+
function describeElement(element: TimelineElement): LookElement {
|
|
102
|
+
return {
|
|
103
|
+
handle: mintElementHandle(timelineElementAddress(element)),
|
|
104
|
+
label: element.label ?? null,
|
|
105
|
+
tag: element.tag,
|
|
106
|
+
kind: element.kind ?? null,
|
|
107
|
+
start: element.start,
|
|
108
|
+
duration: element.duration,
|
|
109
|
+
track: element.track,
|
|
110
|
+
zIndex: element.zIndex ?? null,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function describeSelection(selection: DomEditSelection, animationCount: number): LookSelection {
|
|
115
|
+
const { capabilities } = selection;
|
|
116
|
+
return {
|
|
117
|
+
handle: mintElementHandle(patchTargetAddress(selection)),
|
|
118
|
+
label: selection.label,
|
|
119
|
+
tagName: selection.tagName,
|
|
120
|
+
sourceFile: selection.sourceFile,
|
|
121
|
+
box: selection.boundingBox,
|
|
122
|
+
text: selection.textContent,
|
|
123
|
+
can: {
|
|
124
|
+
editStyles: capabilities.canEditStyles,
|
|
125
|
+
move: capabilities.canMove || capabilities.canApplyManualOffset,
|
|
126
|
+
resize: capabilities.canResize || capabilities.canApplyManualSize,
|
|
127
|
+
editText: selection.textFields.length > 0,
|
|
128
|
+
reasonIfDisabled: capabilities.reasonIfDisabled ?? null,
|
|
129
|
+
},
|
|
130
|
+
animationCount,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function matchesFilter(element: LookElement, needle: string): boolean {
|
|
135
|
+
return (
|
|
136
|
+
(element.label?.toLowerCase().includes(needle) ?? false) ||
|
|
137
|
+
element.tag.toLowerCase().includes(needle) ||
|
|
138
|
+
(element.handle?.toLowerCase().includes(needle) ?? false)
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function buildStudioLook(
|
|
143
|
+
snapshot: StudioLookSnapshot,
|
|
144
|
+
input: StudioLookInput = {},
|
|
145
|
+
): ToolResult<StudioLook> {
|
|
146
|
+
const described = snapshot.elements.map(describeElement);
|
|
147
|
+
const needle = input.filter?.slice(0, MAX_FILTER_LENGTH).trim().toLowerCase();
|
|
148
|
+
const matched = needle
|
|
149
|
+
? described.filter((element) => matchesFilter(element, needle))
|
|
150
|
+
: described;
|
|
151
|
+
|
|
152
|
+
// Clamp rather than reject: a bad limit should not cost the agent a round trip
|
|
153
|
+
// when the answer it wants is right here.
|
|
154
|
+
const requested =
|
|
155
|
+
Number.isInteger(input.limit) && input.limit! > 0 ? input.limit! : DEFAULT_LIMIT;
|
|
156
|
+
const limit = Math.min(requested, DEFAULT_LIMIT);
|
|
157
|
+
|
|
158
|
+
return toolOk<StudioLook>({
|
|
159
|
+
projectId: snapshot.projectId,
|
|
160
|
+
compositionPath: snapshot.compositionPath,
|
|
161
|
+
playhead: snapshot.currentTime,
|
|
162
|
+
duration: snapshot.duration,
|
|
163
|
+
isPlaying: snapshot.isPlaying,
|
|
164
|
+
history: snapshot.history,
|
|
165
|
+
selection: snapshot.selection
|
|
166
|
+
? describeSelection(snapshot.selection, snapshot.selectionAnimationCount)
|
|
167
|
+
: null,
|
|
168
|
+
// The count is of everything that MATCHED, so a truncated list is visible
|
|
169
|
+
// as a truncated list rather than reading as "that is all there is".
|
|
170
|
+
elementCount: matched.length,
|
|
171
|
+
elements: matched.slice(0, limit),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const STUDIO_LOOK_INPUT_SCHEMA = {
|
|
176
|
+
type: "object",
|
|
177
|
+
properties: {
|
|
178
|
+
filter: {
|
|
179
|
+
type: "string",
|
|
180
|
+
maxLength: MAX_FILTER_LENGTH,
|
|
181
|
+
description: "Case-insensitive substring matched against element label, tag, and handle.",
|
|
182
|
+
},
|
|
183
|
+
limit: {
|
|
184
|
+
type: "integer",
|
|
185
|
+
minimum: 1,
|
|
186
|
+
maximum: DEFAULT_LIMIT,
|
|
187
|
+
description: `Cap the returned elements (default and max ${DEFAULT_LIMIT}). elementCount always reports the full match count.`,
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
additionalProperties: false,
|
|
191
|
+
} as const;
|
|
192
|
+
|
|
193
|
+
export const STUDIO_LOOK_DESCRIPTION = [
|
|
194
|
+
"Read HyperFrames Studio's live state in one call: the open project and composition,",
|
|
195
|
+
"the playhead and duration, what the human currently has selected (including what that",
|
|
196
|
+
"element will and will not accept), and the timeline's elements with a handle for each.",
|
|
197
|
+
"Pass a handle back to any tool that edits an element.",
|
|
198
|
+
"Returns an object with `ok: true`, or `ok: false` with `kind`, `reason` and often a `hint`.",
|
|
199
|
+
"`history.undoLabel` is worth checkpointing before a batch: if it later names something",
|
|
200
|
+
"you did not do, a human pressed undo and your earlier edits are gone.",
|
|
201
|
+
].join(" ");
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the WebMCP browser API that Studio uses.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the WebIDL in the W3C spec (`webmachinelearning/webmcp`, `index.bs`)
|
|
5
|
+
* as of 2026-08-26. Two things worth knowing before editing this file:
|
|
6
|
+
*
|
|
7
|
+
* - The API hangs off `document`, NOT `navigator`. `navigator.modelContext` is
|
|
8
|
+
* a polyfill compatibility shim, not a spec member, so feature-detecting it
|
|
9
|
+
* is wrong even where an article's sample "works".
|
|
10
|
+
* - The spec is pre-stable (Origin Trial). This file and `registrar.ts` are the
|
|
11
|
+
* only places that touch the API, so a spec change is a two-file edit. Re-read
|
|
12
|
+
* `index.bs` rather than trusting this transcription.
|
|
13
|
+
*
|
|
14
|
+
* Only the surface Studio registers against is declared. `getTools` and
|
|
15
|
+
* `executeTool` are the consumer side; Studio registers, it does not call.
|
|
16
|
+
*
|
|
17
|
+
* These stay hand-written rather than imported from `@mcp-b/webmcp-types`,
|
|
18
|
+
* which the polyfill pulls in. That package's `registerTool` is overloaded to
|
|
19
|
+
* infer argument types from a literal `inputSchema`, which is useful when you
|
|
20
|
+
* register one tool inline and actively hostile when you register a uniform
|
|
21
|
+
* list of them, as `registerStudioTools` does. Narrower is the safe operation
|
|
22
|
+
* here. It does mean this file can drift from the spec, hence the note above.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export interface ModelContextToolAnnotations {
|
|
26
|
+
/** The tool does not change state. Lets an agent decide when calling is free. */
|
|
27
|
+
readOnlyHint?: boolean;
|
|
28
|
+
/** The tool's output contains data the page's author does not vouch for. */
|
|
29
|
+
untrustedContentHint?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ToolExecuteCallbackOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Aborted when the caller cancels. Studio's commit path is not cancellable
|
|
35
|
+
* once dispatched, so tools check this BEFORE dispatching and document that a
|
|
36
|
+
* late abort does not unwind a write.
|
|
37
|
+
*/
|
|
38
|
+
signal: AbortSignal;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ModelContextTool {
|
|
42
|
+
/**
|
|
43
|
+
* Max 128 characters, ASCII alphanumeric plus `_`, `-`, `.`. Registering a
|
|
44
|
+
* name that already exists REJECTS with InvalidStateError; it does not
|
|
45
|
+
* replace.
|
|
46
|
+
*/
|
|
47
|
+
name: string;
|
|
48
|
+
title?: string;
|
|
49
|
+
/** Required and non-empty; an empty string rejects with InvalidStateError. */
|
|
50
|
+
description: string;
|
|
51
|
+
/** JSON Schema. Nothing in the platform validates input against it. */
|
|
52
|
+
inputSchema?: object;
|
|
53
|
+
/**
|
|
54
|
+
* The user agent JSON-serializes whatever this resolves with, so it must
|
|
55
|
+
* return an object. Returning `undefined` fails the serialization.
|
|
56
|
+
*
|
|
57
|
+
* A rejection is NOT a usable error channel: the spec discards the reason and
|
|
58
|
+
* rejects the caller with a bare UnknownError. Resolve with a tagged failure
|
|
59
|
+
* instead. See `toolResult.ts`.
|
|
60
|
+
*/
|
|
61
|
+
execute: (input: object, options: ToolExecuteCallbackOptions) => Promise<unknown>;
|
|
62
|
+
annotations?: ModelContextToolAnnotations;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ModelContextRegisterToolOptions {
|
|
66
|
+
exposedTo?: string[];
|
|
67
|
+
/** Aborting unregisters the tool. It does not cancel a running `execute`. */
|
|
68
|
+
signal?: AbortSignal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ModelContext {
|
|
72
|
+
registerTool(tool: ModelContextTool, options?: ModelContextRegisterToolOptions): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isModelContext(value: unknown): value is ModelContext {
|
|
76
|
+
if (typeof value !== "object" || value === null) return false;
|
|
77
|
+
return typeof Reflect.get(value, "registerTool") === "function";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The live WebMCP entry point, or null when this browser has not shipped it.
|
|
82
|
+
*
|
|
83
|
+
* Reads through a guard rather than augmenting the `Document` interface. The
|
|
84
|
+
* polyfill's typings already declare `Document.modelContext` globally, and a
|
|
85
|
+
* second, narrower declaration of the same property is a type error.
|
|
86
|
+
*/
|
|
87
|
+
export function getModelContext(doc: Document = document): ModelContext | null {
|
|
88
|
+
const candidate = Reflect.get(doc, "modelContext");
|
|
89
|
+
return isModelContext(candidate) ? candidate : null;
|
|
90
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { mountReactHarness } from "../hooks/domSelectionTestHarness";
|
|
5
|
+
import { writeStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
6
|
+
import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
|
|
7
|
+
import type { ModelContext, ModelContextRegisterToolOptions, ModelContextTool } from "./types";
|
|
8
|
+
import type { StudioLookSnapshot } from "./tools/lookTools";
|
|
9
|
+
|
|
10
|
+
const trackEvent = vi.hoisted(() => vi.fn());
|
|
11
|
+
vi.mock("../telemetry/client", () => ({ trackEvent }));
|
|
12
|
+
|
|
13
|
+
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
14
|
+
|
|
15
|
+
let cleanup: (() => void) | null = null;
|
|
16
|
+
|
|
17
|
+
function snapshot(overrides: Partial<StudioLookSnapshot> = {}): StudioLookSnapshot {
|
|
18
|
+
return {
|
|
19
|
+
projectId: "demo",
|
|
20
|
+
compositionPath: "index.html",
|
|
21
|
+
currentTime: 0,
|
|
22
|
+
duration: 10,
|
|
23
|
+
isPlaying: false,
|
|
24
|
+
elements: [],
|
|
25
|
+
selection: null,
|
|
26
|
+
selectionAnimationCount: 0,
|
|
27
|
+
history: { canUndo: false, canRedo: false, undoLabel: null, redoLabel: null },
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Install a fake `document.modelContext` and report what got registered. */
|
|
33
|
+
function installModelContext() {
|
|
34
|
+
const registered: ModelContextTool[] = [];
|
|
35
|
+
const registerTool = vi.fn(
|
|
36
|
+
async (tool: ModelContextTool, _options?: ModelContextRegisterToolOptions) => {
|
|
37
|
+
registered.push(tool);
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
const modelContext: ModelContext = { registerTool };
|
|
41
|
+
Object.defineProperty(document, "modelContext", {
|
|
42
|
+
value: modelContext,
|
|
43
|
+
configurable: true,
|
|
44
|
+
writable: true,
|
|
45
|
+
});
|
|
46
|
+
return { registered, registerTool };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function removeModelContext() {
|
|
50
|
+
Reflect.deleteProperty(document, "modelContext");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function mountTools(deps: StudioAgentToolsDeps) {
|
|
54
|
+
function Probe({ current }: { current: StudioAgentToolsDeps }) {
|
|
55
|
+
useStudioAgentTools(current);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const root = mountReactHarness(<Probe current={deps} />);
|
|
59
|
+
cleanup = () => act(() => root.unmount());
|
|
60
|
+
return {
|
|
61
|
+
rerenderWith(next: StudioAgentToolsDeps) {
|
|
62
|
+
act(() => root.render(<Probe current={next} />));
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
window.localStorage.clear();
|
|
69
|
+
trackEvent.mockReset();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
cleanup?.();
|
|
74
|
+
cleanup = null;
|
|
75
|
+
removeModelContext();
|
|
76
|
+
window.localStorage.clear();
|
|
77
|
+
vi.restoreAllMocks();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("useStudioAgentTools", () => {
|
|
81
|
+
it("registers the tool set once on mount", async () => {
|
|
82
|
+
const { registered } = installModelContext();
|
|
83
|
+
|
|
84
|
+
await act(async () => {
|
|
85
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(registered.map((tool) => tool.name)).toEqual(["studio_look"]);
|
|
89
|
+
expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not re-register when the deps object changes identity", async () => {
|
|
93
|
+
// The regression test for the whole design. The DomEdit actions object
|
|
94
|
+
// changes identity on nearly every interaction; if registration depended on
|
|
95
|
+
// it, the signal would abort and unregister the tools each time.
|
|
96
|
+
const { registerTool } = installModelContext();
|
|
97
|
+
|
|
98
|
+
let harness: ReturnType<typeof mountTools> | null = null;
|
|
99
|
+
await act(async () => {
|
|
100
|
+
harness = mountTools({ getSnapshot: () => snapshot() });
|
|
101
|
+
});
|
|
102
|
+
expect(registerTool).toHaveBeenCalledTimes(1);
|
|
103
|
+
|
|
104
|
+
await act(async () => {
|
|
105
|
+
harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 5 }) });
|
|
106
|
+
harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 6 }) });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(registerTool).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("executes against the LATEST deps, not the ones present at registration", async () => {
|
|
113
|
+
// The other half of the ref: registering once must not freeze the state the
|
|
114
|
+
// tools read, or every answer after the first render would be stale.
|
|
115
|
+
const { registered } = installModelContext();
|
|
116
|
+
|
|
117
|
+
let harness: ReturnType<typeof mountTools> | null = null;
|
|
118
|
+
await act(async () => {
|
|
119
|
+
harness = mountTools({ getSnapshot: () => snapshot({ currentTime: 1 }) });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 42 }) });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const look = registered[0];
|
|
127
|
+
if (!look) throw new Error("expected studio_look to be registered");
|
|
128
|
+
const result = (await look.execute({}, { signal: new AbortController().signal })) as {
|
|
129
|
+
ok: boolean;
|
|
130
|
+
playhead: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
expect(result.ok).toBe(true);
|
|
134
|
+
expect(result.playhead).toBe(42);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("unregisters on unmount by aborting the registration signal", async () => {
|
|
138
|
+
const { registerTool } = installModelContext();
|
|
139
|
+
|
|
140
|
+
await act(async () => {
|
|
141
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
142
|
+
});
|
|
143
|
+
const signal = registerTool.mock.calls[0]?.[1]?.signal;
|
|
144
|
+
expect(signal?.aborted).toBe(false);
|
|
145
|
+
|
|
146
|
+
cleanup?.();
|
|
147
|
+
cleanup = null;
|
|
148
|
+
|
|
149
|
+
expect(signal?.aborted).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("boots cleanly when the browser has no native WebMCP", async () => {
|
|
153
|
+
removeModelContext();
|
|
154
|
+
|
|
155
|
+
await act(async () => {
|
|
156
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// The assertion is that mounting did not throw; a browser without the
|
|
160
|
+
// native API must still boot Studio. The polyfill may install
|
|
161
|
+
// document.modelContext as a fallback — that is expected.
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("registers nothing when the preference is turned off", async () => {
|
|
165
|
+
writeStudioUiPreferences({ agentToolsEnabled: false });
|
|
166
|
+
const { registerTool } = installModelContext();
|
|
167
|
+
|
|
168
|
+
await act(async () => {
|
|
169
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(registerTool).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("registers when the preference is absent, because on is the default", async () => {
|
|
176
|
+
const { registerTool } = installModelContext();
|
|
177
|
+
|
|
178
|
+
await act(async () => {
|
|
179
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
expect(registerTool).toHaveBeenCalledTimes(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("reports a non-abort registration failure through production telemetry", async () => {
|
|
186
|
+
const { registerTool } = installModelContext();
|
|
187
|
+
registerTool.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
|
|
188
|
+
|
|
189
|
+
await act(async () => {
|
|
190
|
+
mountTools({ getSnapshot: () => snapshot() });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(trackEvent).toHaveBeenCalledWith("webmcp_registration_failed", {
|
|
194
|
+
error_name: "NotAllowedError",
|
|
195
|
+
tool_name: "studio_look",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reports a tool that throws as an internal failure instead of rejecting", async () => {
|
|
200
|
+
const { registered } = installModelContext();
|
|
201
|
+
|
|
202
|
+
await act(async () => {
|
|
203
|
+
mountTools({
|
|
204
|
+
getSnapshot: () => {
|
|
205
|
+
throw new TypeError("handler signature moved");
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
210
|
+
|
|
211
|
+
const look = registered[0];
|
|
212
|
+
if (!look) throw new Error("expected studio_look to be registered");
|
|
213
|
+
const result = (await look.execute({}, { signal: new AbortController().signal })) as {
|
|
214
|
+
ok: boolean;
|
|
215
|
+
kind: string;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
expect(result.ok).toBe(false);
|
|
219
|
+
expect(result.kind).toBe("internal");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { trackEvent } from "../telemetry/client";
|
|
3
|
+
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
|
|
4
|
+
import { makeStudioDebugLogger } from "../utils/studioDebug";
|
|
5
|
+
import { loadModelContextPolyfill } from "./polyfill";
|
|
6
|
+
import { registerStudioTools, type ToolRegistrationReport } from "./registrar";
|
|
7
|
+
import { runToolBody, type ToolResult } from "./toolResult";
|
|
8
|
+
import { getModelContext, type ModelContext, type ModelContextTool } from "./types";
|
|
9
|
+
import {
|
|
10
|
+
buildStudioLook,
|
|
11
|
+
STUDIO_LOOK_DESCRIPTION,
|
|
12
|
+
STUDIO_LOOK_INPUT_SCHEMA,
|
|
13
|
+
type StudioLook,
|
|
14
|
+
type StudioLookInput,
|
|
15
|
+
type StudioLookSnapshot,
|
|
16
|
+
} from "./tools/lookTools";
|
|
17
|
+
|
|
18
|
+
const log = makeStudioDebugLogger("webmcp");
|
|
19
|
+
|
|
20
|
+
function reportRegistration(report: ToolRegistrationReport, native: boolean): void {
|
|
21
|
+
log("registered", { native, ...report });
|
|
22
|
+
for (const failure of report.failed) {
|
|
23
|
+
trackEvent("webmcp_registration_failed", {
|
|
24
|
+
error_name: failure.name,
|
|
25
|
+
tool_name: failure.tool,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface StudioAgentToolsDeps {
|
|
31
|
+
/** Read Studio's current state. Called per tool invocation, never cached. */
|
|
32
|
+
getSnapshot: () => StudioLookSnapshot;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build the tool list once.
|
|
37
|
+
*
|
|
38
|
+
* Every `execute` reads `depsRef.current` at CALL time rather than closing over
|
|
39
|
+
* a snapshot, which is what lets the list be built once and still see live
|
|
40
|
+
* state. That is the whole point of the ref: see the registration note below.
|
|
41
|
+
*/
|
|
42
|
+
function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): ModelContextTool[] {
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
name: "studio_look",
|
|
46
|
+
title: "Look at the composition",
|
|
47
|
+
description: STUDIO_LOOK_DESCRIPTION,
|
|
48
|
+
inputSchema: STUDIO_LOOK_INPUT_SCHEMA,
|
|
49
|
+
annotations: {
|
|
50
|
+
readOnlyHint: true,
|
|
51
|
+
// The labels, text and ids come from the user's composition, which can
|
|
52
|
+
// contain anything. This is a hint to the agent, not a sanitiser.
|
|
53
|
+
untrustedContentHint: true,
|
|
54
|
+
},
|
|
55
|
+
execute: (input): Promise<ToolResult<StudioLook>> =>
|
|
56
|
+
runToolBody("studio_look", async () =>
|
|
57
|
+
buildStudioLook(depsRef.current.getSnapshot(), input as StudioLookInput),
|
|
58
|
+
),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register Studio's tools with the browser, exactly once per mount.
|
|
65
|
+
*
|
|
66
|
+
* The effect has an EMPTY dependency array on purpose, and the deps live in a
|
|
67
|
+
* ref that every render refreshes. The obvious alternative — depend on the
|
|
68
|
+
* handlers — re-runs on nearly every interaction, because the DomEdit actions
|
|
69
|
+
* object changes identity whenever the selection or the element list does.
|
|
70
|
+
* Re-running means the registration signal aborts and unregisters everything,
|
|
71
|
+
* `toolchange` fires constantly so a connected agent watches the tool list
|
|
72
|
+
* churn, and the spec warns that a quick unregister-then-reregister can apply
|
|
73
|
+
* an old call's arguments against the new schema.
|
|
74
|
+
*
|
|
75
|
+
* `useStudioTestHooks` carries a comment about the same class of bug already hit
|
|
76
|
+
* in this codebase, where effect teardown revoked a lease moments after it was
|
|
77
|
+
* taken because writing state changed the effect's dependency identities.
|
|
78
|
+
*
|
|
79
|
+
* Any fallback must be awaited inside this effect before registration and then
|
|
80
|
+
* re-read here. Installing one from a sibling effect would race this mount-only
|
|
81
|
+
* lookup. Hot-module replacement can still create a brief unregister/register
|
|
82
|
+
* window in development; production has one document-scoped registration.
|
|
83
|
+
*/
|
|
84
|
+
export function useStudioAgentTools(deps: StudioAgentToolsDeps): void {
|
|
85
|
+
const depsRef = useRef(deps);
|
|
86
|
+
depsRef.current = deps;
|
|
87
|
+
|
|
88
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (readStudioUiPreferences().agentToolsEnabled === false) {
|
|
91
|
+
log("skipped", { why: "disabled by preference" });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
|
|
97
|
+
void (async () => {
|
|
98
|
+
const native: ModelContext | null = getModelContext();
|
|
99
|
+
if (native) trackEvent("webmcp.native_present");
|
|
100
|
+
// Native browsers never download the polyfill.
|
|
101
|
+
const modelContext = native ?? (await loadModelContextPolyfill());
|
|
102
|
+
if (!modelContext) {
|
|
103
|
+
log("skipped", { why: "no model context, native or polyfilled" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
// The import is async, so the component may already be gone.
|
|
107
|
+
if (controller.signal.aborted) return;
|
|
108
|
+
|
|
109
|
+
const report = await registerStudioTools(
|
|
110
|
+
modelContext,
|
|
111
|
+
buildStudioTools(depsRef),
|
|
112
|
+
controller.signal,
|
|
113
|
+
);
|
|
114
|
+
reportRegistration(report, native !== null);
|
|
115
|
+
})();
|
|
116
|
+
|
|
117
|
+
return () => controller.abort();
|
|
118
|
+
}, []);
|
|
119
|
+
}
|