diff --git a/packages/studio/src/webmcp/StudioAgentTools.tsx b/packages/studio/src/webmcp/StudioAgentTools.tsx index e26612c175..4fbf55e689 100644 --- a/packages/studio/src/webmcp/StudioAgentTools.tsx +++ b/packages/studio/src/webmcp/StudioAgentTools.tsx @@ -18,7 +18,12 @@ import type { StudioLookSnapshot } from "./tools/lookTools"; */ export function StudioAgentTools() { const { projectId, activeCompPath, editHistory } = useStudioShellContext(); - const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext(); + const { + domEditSelection, + selectedGsapAnimations, + gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, + } = useDomEditSelectionContext(); const { previewIframeRef, buildDomSelectionFromTarget, applyDomSelection } = useDomEditActionsContext(); @@ -71,6 +76,12 @@ export function StudioAgentTools() { } }, wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + getCurrentSelection: () => domEditSelection, + getGsapDiagnostics: () => ({ + animations: selectedGsapAnimations, + multipleTimelines: gsapMultipleTimelines, + unsupportedTimelinePattern: gsapUnsupportedTimelinePattern, + }), }), [ getSnapshot, @@ -79,6 +90,10 @@ export function StudioAgentTools() { applyDomSelection, projectId, activeCompPath, + domEditSelection, + selectedGsapAnimations, + gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, ], ); diff --git a/packages/studio/src/webmcp/tools/frameTools.test.ts b/packages/studio/src/webmcp/tools/frameTools.test.ts index e98d5c2e46..8ac0b5a432 100644 --- a/packages/studio/src/webmcp/tools/frameTools.test.ts +++ b/packages/studio/src/webmcp/tools/frameTools.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from "vitest"; import { studioFrame, type FrameToolDeps, type StudioFrameResult } from "./frameTools"; -import type { ToolFailure, ToolResult } from "../toolResult"; +import { expectFailure, expectOk } from "../webmcpTestUtils"; function frameDeps(overrides: Partial = {}): FrameToolDeps { return { @@ -15,16 +15,6 @@ function frameDeps(overrides: Partial = {}): FrameToolDeps { }; } -function expectOk(result: ToolResult): { ok: true } & T { - if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`); - return result; -} - -function expectFailure(result: ToolResult): ToolFailure { - if (result.ok) throw new Error(`expected failure, got ${JSON.stringify(result)}`); - return result; -} - describe("studioFrame", () => { it("returns a URL for the composition at the playhead", async () => { const result = await studioFrame(frameDeps()); diff --git a/packages/studio/src/webmcp/tools/inspectTools.test.ts b/packages/studio/src/webmcp/tools/inspectTools.test.ts new file mode 100644 index 0000000000..d59034a45f --- /dev/null +++ b/packages/studio/src/webmcp/tools/inspectTools.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; +import { studioInspect, type InspectToolDeps, type StudioInspectResult } from "./inspectTools"; +import { + expectFailure, + expectOk, + previewDoc, + previewElement, + selectionFor, +} from "../webmcpTestUtils"; + +function animation(overrides: Partial = {}): GsapAnimation { + return { + id: "anim-1", + targetSelector: "#headline", + method: "from", + position: 0, + properties: { y: -50, opacity: 0 }, + duration: 1, + ease: "power2.out", + ...overrides, + } as GsapAnimation; +} + +function inspectDeps(overrides: Partial = {}): InspectToolDeps { + return { + getPreviewDocument: () => null, + buildSelection: async (element) => selectionFor(element), + applySelection: () => undefined, + requestSeek: () => undefined, + readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }), + getCurrentSelection: () => null, + getGsapDiagnostics: () => ({ + animations: [], + multipleTimelines: false, + unsupportedTimelinePattern: false, + }), + ...overrides, + }; +} + +describe("studioInspect", () => { + it("returns the resolved styles, not the authored ones", async () => { + const element = previewElement('

Ship it

', "headline"); + const selection = selectionFor(element); + + const result = await studioInspect(inspectDeps({ getCurrentSelection: () => selection })); + + const ok = expectOk(result); + // The authored value is a clamp(); the resolved one is what actually renders. + expect(ok.styles["font-size"]).toBe("42.7px"); + expect(ok.inlineStyles.color).toBe("red"); + expect(ok.box.width).toBe(880); + }); + + it("reports capabilities and the disabled reason verbatim", async () => { + const element = previewElement('

Ship it

', "headline"); + const locked = selectionFor(element, { + capabilities: { + canSelect: true, + canEditStyles: false, + canCrop: false, + canMove: false, + canResize: false, + canApplyManualOffset: false, + canApplyManualSize: false, + canApplyManualRotation: false, + reasonIfDisabled: "Element is inside a locked composition", + }, + }); + + const result = await studioInspect(inspectDeps({ getCurrentSelection: () => locked })); + + const ok = expectOk(result); + expect(ok.can.editStyles).toBe(false); + expect(ok.can.move).toBe(false); + expect(ok.can.reasonIfDisabled).toBe("Element is inside a locked composition"); + }); + + it("lists the animations on the current selection", async () => { + const element = previewElement('

Ship it

', "headline"); + + const result = await studioInspect( + inspectDeps({ + getCurrentSelection: () => selectionFor(element), + getGsapDiagnostics: () => ({ + animations: [animation()], + multipleTimelines: false, + unsupportedTimelinePattern: false, + }), + }), + ); + + const ok = expectOk(result); + expect(ok.animations).toHaveLength(1); + expect(ok.animations[0]?.animationId).toBe("anim-1"); + expect(ok.animations[0]?.ease).toBe("power2.out"); + expect(ok.animationEditingBlocked).toBeNull(); + }); + + it("says WHY animation editing is unavailable, so a write is not attempted", async () => { + const element = previewElement('

Ship it

', "headline"); + const base = { + getCurrentSelection: () => selectionFor(element), + }; + + const multiple = await studioInspect( + inspectDeps({ + ...base, + getGsapDiagnostics: () => ({ + animations: [], + multipleTimelines: true, + unsupportedTimelinePattern: false, + }), + }), + ); + const unsupported = await studioInspect( + inspectDeps({ + ...base, + getGsapDiagnostics: () => ({ + animations: [], + multipleTimelines: false, + unsupportedTimelinePattern: true, + }), + }), + ); + + expect(expectOk(multiple).animationEditingBlocked).toMatch( + /multiple GSAP timelines/, + ); + expect(expectOk(unsupported).animationEditingBlocked).toMatch( + /not editable/, + ); + }); + + it("does not attribute the selection's animations to a different element", async () => { + // Studio only parses animations for the CURRENT selection. Reporting them + // against another element would report the wrong element's motion. + const headline = previewElement('

A

B

', "headline"); + const doc = headline.ownerDocument; + + const result = await studioInspect( + inspectDeps({ + getPreviewDocument: () => doc, + getCurrentSelection: () => selectionFor(headline), + getGsapDiagnostics: () => ({ + animations: [animation()], + multipleTimelines: false, + unsupportedTimelinePattern: false, + }), + }), + { handle: "dom:body" }, + ); + + const ok = expectOk(result); + expect(ok.isCurrentSelection).toBe(false); + expect(ok.animations).toEqual([]); + expect(ok.animationEditingBlocked).toMatch(/only readable for the current selection/); + }); + + it("inspects a handle without changing what is selected", async () => { + const doc = previewDoc('

A

'); + const applySelection = vi.fn(); + + const result = await studioInspect( + inspectDeps({ getPreviewDocument: () => doc, applySelection }), + { handle: "dom:headline" }, + ); + + expect(result.ok).toBe(true); + // Inspecting is a read. It must not steal the human's selection. + expect(applySelection).not.toHaveBeenCalled(); + }); + + it("fails rather than returning an empty result when nothing is selected", async () => { + const result = expectFailure(await studioInspect(inspectDeps())); + + // An empty result would assert "this element has nothing", a different and + // false claim from "you did not say which element". + expect(result.kind).toBe("invalid"); + expect(result.reason).toMatch(/nothing is selected/); + expect(result.hint).toMatch(/studio_select/); + }); + + it("reports an unknown handle distinctly from an unmounted preview", async () => { + const notMounted = expectFailure(await studioInspect(inspectDeps(), { handle: "dom:x" })); + expect(notMounted.kind).toBe("blocked"); + + const doc = previewDoc('

A

'); + const unknown = expectFailure( + await studioInspect(inspectDeps({ getPreviewDocument: () => doc }), { handle: "dom:x" }), + ); + expect(unknown.kind).toBe("invalid"); + expect(unknown.reason).not.toBe(notMounted.reason); + }); +}); diff --git a/packages/studio/src/webmcp/tools/inspectTools.ts b/packages/studio/src/webmcp/tools/inspectTools.ts new file mode 100644 index 0000000000..f31b9fc834 --- /dev/null +++ b/packages/studio/src/webmcp/tools/inspectTools.ts @@ -0,0 +1,208 @@ +/** + * `studio_inspect`: everything about one element, in one call. + * + * The point is to prevent a failed write. Every field here either tells the + * agent what it can change (`can`, with `reasonIfDisabled` verbatim) or what it + * would be changing (the resolved styles, the text fields, the animations). + * An agent that reads this first should never attempt an edit the element will + * refuse. + * + * The GSAP diagnostics are here for the same reason: `multipleTimelines` and + * `unsupportedTimelinePattern` are states where animation editing is off, and + * learning that from a read is cheaper than learning it from a failed write. + */ + +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; +import type { DomEditSelection } from "../../components/editor/domEditingTypes"; +import { mintElementHandle, patchTargetAddress, resolveElementHandle } from "../handles"; +import { toolFailure, toolOk, type ToolResult } from "../toolResult"; +import type { SelectionToolDeps } from "./selectionTools"; + +export interface InspectToolDeps extends SelectionToolDeps { + /** What the human currently has selected, used when no handle is given. */ + getCurrentSelection: () => DomEditSelection | null; + getGsapDiagnostics: () => { + animations: readonly GsapAnimation[]; + multipleTimelines: boolean; + unsupportedTimelinePattern: boolean; + }; +} + +interface InspectAnimation { + animationId: string; + method: string; + target: string; + position: number | string; + duration: number | null; + ease: string | null; + properties: Record; + hasKeyframes: boolean; + hasArcPath: boolean; +} + +interface InspectTextField { + key: string; + label: string; + value: string; + tagName: string; +} + +export interface StudioInspectResult { + handle: string | null; + label: string; + tagName: string; + sourceFile: string; + box: { x: number; y: number; width: number; height: number }; + text: string | null; + textFields: InspectTextField[]; + /** The styles Studio itself surfaces, resolved, not as authored. */ + styles: Record; + inlineStyles: Record; + dataAttributes: Record; + can: { + editStyles: boolean; + move: boolean; + resize: boolean; + rotate: boolean; + crop: boolean; + editText: boolean; + reasonIfDisabled: string | null; + }; + animations: InspectAnimation[]; + /** Present only when animation editing is unavailable, with the reason. */ + animationEditingBlocked: string | null; + /** True when this element is the one the human currently has selected. */ + isCurrentSelection: boolean; +} + +export interface StudioInspectInput { + /** Omit to inspect the current selection. */ + handle?: string; +} + +function describeAnimation(animation: GsapAnimation): InspectAnimation { + return { + animationId: animation.id, + method: animation.method, + target: animation.targetSelector, + position: animation.position, + duration: animation.duration ?? null, + ease: animation.ease ?? null, + properties: animation.properties, + hasKeyframes: animation.keyframes !== undefined, + hasArcPath: animation.arcPath !== undefined, + }; +} + +function describe( + selection: DomEditSelection, + deps: InspectToolDeps, + isCurrentSelection: boolean, +): ToolResult { + const { capabilities } = selection; + const gsap = deps.getGsapDiagnostics(); + + // Only the CURRENT selection's animations are parsed by Studio. Reporting + // them for some other element would be reporting the wrong element's motion, + // which is worse than reporting none. + const animations = isCurrentSelection ? gsap.animations.map(describeAnimation) : []; + + let animationEditingBlocked: string | null = null; + if (!isCurrentSelection) { + animationEditingBlocked = "animations are only readable for the current selection"; + } else if (gsap.multipleTimelines) { + animationEditingBlocked = "this composition has multiple GSAP timelines"; + } else if (gsap.unsupportedTimelinePattern) { + animationEditingBlocked = "this composition's timeline pattern is not editable by Studio"; + } + + return toolOk({ + handle: mintElementHandle(patchTargetAddress(selection)), + label: selection.label, + tagName: selection.tagName, + sourceFile: selection.sourceFile, + box: selection.boundingBox, + text: selection.textContent, + textFields: selection.textFields.map((field) => ({ + key: field.key, + label: field.label, + value: field.value, + tagName: field.tagName, + })), + styles: selection.computedStyles, + inlineStyles: selection.inlineStyles, + dataAttributes: selection.dataAttributes, + can: { + editStyles: capabilities.canEditStyles, + move: capabilities.canMove || capabilities.canApplyManualOffset, + resize: capabilities.canResize || capabilities.canApplyManualSize, + rotate: capabilities.canApplyManualRotation, + crop: capabilities.canCrop, + editText: selection.textFields.length > 0, + reasonIfDisabled: capabilities.reasonIfDisabled ?? null, + }, + animations, + animationEditingBlocked, + isCurrentSelection, + }); +} + +export async function studioInspect( + deps: InspectToolDeps, + input: StudioInspectInput = {}, +): Promise> { + const current = deps.getCurrentSelection(); + + if (!input.handle) { + // An empty result here would assert "this element has nothing", which is a + // different and false claim from "you did not tell me which element". + if (!current) { + return toolFailure( + "invalid", + "nothing is selected and no handle was given", + "Pass a handle from studio_look, or call studio_select first.", + ); + } + return describe(current, deps, true); + } + + const doc = deps.getPreviewDocument(); + if (!doc) return toolFailure("blocked", "the preview is not mounted yet"); + + const element = resolveElementHandle(doc, input.handle); + if (!element) { + return toolFailure( + "invalid", + `no element matches handle ${input.handle}`, + "Call studio_look for current handles.", + ); + } + + const selection = await deps.buildSelection(element); + if (!selection) { + return toolFailure("blocked", `${input.handle} resolved to an element Studio cannot inspect`); + } + + return describe(selection, deps, current?.element === element); +} + +export const STUDIO_INSPECT_INPUT_SCHEMA = { + type: "object", + properties: { + handle: { + type: "string", + description: "An element handle from studio_look. Omit to inspect the current selection.", + }, + }, + additionalProperties: false, +} as const; + +export const STUDIO_INSPECT_DESCRIPTION = [ + "Everything about one element: its resolved styles, its text fields, its box,", + "its GSAP animations, and crucially what it will and will not accept.", + "Read this BEFORE editing. `can` tells you which edits are possible and", + "`can.reasonIfDisabled` says why one is not, so you can avoid a write that would be refused.", + "Animations are only readable for the CURRENT selection; `animationEditingBlocked` says when", + "and why animation editing is unavailable.", + "Returns `ok: true`, or `ok: false` with `kind`, `reason` and a `hint`.", +].join(" "); diff --git a/packages/studio/src/webmcp/tools/selectionTools.test.ts b/packages/studio/src/webmcp/tools/selectionTools.test.ts index c6ec5fdd2a..07168529ae 100644 --- a/packages/studio/src/webmcp/tools/selectionTools.test.ts +++ b/packages/studio/src/webmcp/tools/selectionTools.test.ts @@ -1,6 +1,5 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from "vitest"; -import type { DomEditSelection } from "../../components/editor/domEditingTypes"; import { studioSeek, studioSelect, @@ -8,46 +7,7 @@ import { type StudioSeekResult, type StudioSelectResult, } from "./selectionTools"; -import type { ToolFailure, ToolResult } from "../toolResult"; - -function previewDoc(html: string): Document { - const iframe = document.createElement("iframe"); - document.body.append(iframe); - const doc = iframe.contentDocument; - if (!doc) throw new Error("expected iframe document"); - doc.body.innerHTML = html; - return doc; -} - -function selectionFor(element: HTMLElement): DomEditSelection { - return { - id: element.id || undefined, - hfId: element.getAttribute("data-hf-id") ?? undefined, - element, - label: "Headline", - tagName: element.tagName.toLowerCase(), - sourceFile: "index.html", - compositionPath: "index.html", - isCompositionHost: false, - isInsideLockedComposition: false, - boundingBox: { x: 40, y: 12, width: 880, height: 96 }, - textContent: element.textContent, - dataAttributes: {}, - inlineStyles: {}, - computedStyles: {}, - textFields: [], - capabilities: { - canSelect: true, - canEditStyles: true, - canCrop: true, - canMove: true, - canResize: true, - canApplyManualOffset: true, - canApplyManualSize: true, - canApplyManualRotation: true, - }, - }; -} +import { expectFailure, expectOk, previewDoc, selectionFor } from "../webmcpTestUtils"; function selectionDeps(overrides: Partial = {}): SelectionToolDeps { return { @@ -60,16 +20,6 @@ function selectionDeps(overrides: Partial = {}): SelectionToo }; } -function expectFailure(result: ToolResult): ToolFailure { - if (result.ok) throw new Error(`expected failure, got ${JSON.stringify(result)}`); - return result; -} - -function expectOk(result: ToolResult): { ok: true } & T { - if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`); - return result; -} - describe("studioSelect", () => { it("applies the selection a click would produce and reports it back", async () => { const doc = previewDoc('

Ship it

'); diff --git a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx index d9c7dd4081..3a9a3ee988 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx +++ b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx @@ -42,6 +42,12 @@ function deps(overrides: Partial = {}): StudioAgentToolsDe getCompositionPath: () => "index.html", probeFrame: async () => ({ ok: true, status: 200 }), wait: async () => undefined, + getCurrentSelection: () => null, + getGsapDiagnostics: () => ({ + animations: [], + multipleTimelines: false, + unsupportedTimelinePattern: false, + }), ...overrides, }; } @@ -107,6 +113,7 @@ describe("useStudioAgentTools", () => { "studio_select", "studio_seek", "studio_frame", + "studio_inspect", ]); expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present"); }); @@ -121,14 +128,14 @@ describe("useStudioAgentTools", () => { await act(async () => { harness = mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(4); + expect(registerTool).toHaveBeenCalledTimes(5); await act(async () => { harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) })); harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) })); }); - expect(registerTool).toHaveBeenCalledTimes(4); + expect(registerTool).toHaveBeenCalledTimes(5); }); it("executes against the LATEST deps, not the ones present at registration", async () => { @@ -201,7 +208,7 @@ describe("useStudioAgentTools", () => { mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(4); + expect(registerTool).toHaveBeenCalledTimes(5); }); it("reports a non-abort registration failure through production telemetry", async () => { diff --git a/packages/studio/src/webmcp/useStudioAgentTools.ts b/packages/studio/src/webmcp/useStudioAgentTools.ts index 9af5b8cc61..c5e1a444eb 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.ts +++ b/packages/studio/src/webmcp/useStudioAgentTools.ts @@ -33,6 +33,14 @@ import { type StudioFrameInput, type StudioFrameResult, } from "./tools/frameTools"; +import { + studioInspect, + STUDIO_INSPECT_DESCRIPTION, + STUDIO_INSPECT_INPUT_SCHEMA, + type InspectToolDeps, + type StudioInspectInput, + type StudioInspectResult, +} from "./tools/inspectTools"; const log = makeStudioDebugLogger("webmcp"); @@ -46,7 +54,7 @@ function reportRegistration(report: ToolRegistrationReport, native: boolean): vo } } -export interface StudioAgentToolsDeps extends SelectionToolDeps, FrameToolDeps { +export interface StudioAgentToolsDeps extends SelectionToolDeps, FrameToolDeps, InspectToolDeps { /** Read Studio's current state. Called per tool invocation, never cached. */ getSnapshot: () => StudioLookSnapshot; } @@ -107,6 +115,17 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): execute: (input): Promise> => runToolBody("studio_frame", () => studioFrame(depsRef.current, input as StudioFrameInput)), }, + { + name: "studio_inspect", + title: "Inspect one element", + description: STUDIO_INSPECT_DESCRIPTION, + inputSchema: STUDIO_INSPECT_INPUT_SCHEMA, + annotations: { readOnlyHint: true, untrustedContentHint: true }, + execute: (input): Promise> => + runToolBody("studio_inspect", () => + studioInspect(depsRef.current, input as StudioInspectInput), + ), + }, ]; } diff --git a/packages/studio/src/webmcp/webmcpTestUtils.ts b/packages/studio/src/webmcp/webmcpTestUtils.ts new file mode 100644 index 0000000000..8f4f5e96b0 --- /dev/null +++ b/packages/studio/src/webmcp/webmcpTestUtils.ts @@ -0,0 +1,91 @@ +/** + * Shared fixtures for the WebMCP tool tests. + * + * Not a `.test` file so vitest does not collect it as a suite. Mirrors the + * existing `hooks/domSelectionTestHarness.ts` convention. + */ + +import { expect } from "vitest"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import type { ToolFailure, ToolResult } from "./toolResult"; + +/** + * An element inside a real iframe, which is where Studio's chrome expects to + * find preview elements. The separate realm matters: a preview element is not + * an instance of Studio's own `HTMLElement`. + */ +export function previewDoc(html: string): Document { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const doc = iframe.contentDocument; + if (!doc) throw new Error("expected iframe document"); + doc.body.innerHTML = html; + return doc; +} + +export function previewElement(html: string, id: string): HTMLElement { + const doc = previewDoc(html); + const element = doc.getElementById(id); + const HTMLElementCtor = doc.defaultView?.HTMLElement; + if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) { + throw new Error(`expected preview element #${id}`); + } + return element; +} + +export function selectionFor( + element: HTMLElement, + overrides: Partial = {}, +): DomEditSelection { + return { + id: element.id || undefined, + hfId: element.getAttribute("data-hf-id") ?? undefined, + element, + label: "Headline", + tagName: element.tagName.toLowerCase(), + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 40, y: 12, width: 880, height: 96 }, + textContent: element.textContent, + dataAttributes: { "data-role": "title" }, + inlineStyles: { color: "red" }, + computedStyles: { "font-size": "42.7px", color: "rgb(255, 0, 0)" }, + textFields: [ + { + key: "self", + label: "Text", + value: element.textContent ?? "", + tagName: element.tagName.toLowerCase(), + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + }; +} + +export function expectOk(result: ToolResult): { ok: true } & T { + expect(result.ok, `expected ok, got ${JSON.stringify(result)}`).toBe(true); + if (!result.ok) throw new Error("unreachable"); + return result; +} + +export function expectFailure(result: ToolResult): ToolFailure { + expect(result.ok, `expected failure, got ${JSON.stringify(result)}`).toBe(false); + if (result.ok) throw new Error("unreachable"); + return result; +}