diff --git a/README.md b/README.md index 8bd317f..548509d 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Initial discovery waits up to 500 ms for existing frames. Requests use `MessageC ## Implementation status -The target is `webmcp-types@0.1.9`: registration, discovery, execution, cancellation, and `toolchange`, including frame exposure and origin filtering. Declarative forms and `toolactivated`/`toolcancel` are not implemented. Browser agent integration requires browser support. +The target is `webmcp-types@0.1.10`: registration, discovery, execution, cancellation, `toolchange`, and the `toolactivated`/`toolcancel` lifecycle events, including frame exposure and origin filtering. Declarative forms are not implemented. Browser agent integration requires browser support. Native contexts do not join the polyfill's channels. See [TESTING.md](https://github.com/webmachinelearning/webmcp-polyfill/blob/main/TESTING.md) for policy and frame limitations, results, commands, and tracked revisions. diff --git a/TESTING.md b/TESTING.md index 316b960..8ea8b4f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2,7 +2,7 @@ ## Results -**223 browser tests pass** across Chromium 153.0.8010.12, Firefox 155.0, and +**238 browser tests pass** across Chromium 153.0.8010.12, Firefox 155.0, and Playwright WebKit 26.6. Package checks also pass: imports, types, SSR, and tarball contents. CI runs these checks plus WPT in Chrome, Firefox, and actual Safari. Safari runs on `macos-26`; Playwright WebKit is a separate build. @@ -12,19 +12,19 @@ in Chrome and Firefox: | Subtest result | Count | | --- | ---: | -| PASS | 92 | -| Expected FAIL | 52 | -| Expected TIMEOUT | 26 | -| Expected NOTRUN | 20 | +| PASS | 116 | +| Expected FAIL | 33 | +| Expected TIMEOUT | 25 | +| Expected NOTRUN | 16 | Tested with Chrome Canary 157.0.8080.0 and Firefox Nightly 159.0a1 (20260930214513). Safari has not yet run at this pin; none of the expectations are browser-specific. -At the file level: 43 OK, 26 expected timeouts, one expected error. +At the file level: 44 OK, 25 expected timeouts, one expected error. Expected failures are still failures. `NOTRUN` means an earlier timeout prevented -the test from running, including three abort cases. Passing declarative checks only -cover rejection or absence of tools. Of the 38 pinned IDL checks, the 16 for lifecycle -event handlers and interfaces fail. This is not full conformance. +the test from running, including one abort case. Passing declarative checks only +cover rejection or absence of tools. All 38 pinned IDL checks pass. This is not +full conformance. ## Run locally @@ -66,13 +66,15 @@ other non-testharness files are outside this suite. ## Draft alignment and limitations Checked against [draft `d61d0e6`](https://github.com/webmachinelearning/webmcp/blob/d61d0e6d297ddb6bff3510b1330dbb215c6ef43c/index.bs) -and `webmcp-types@0.1.9`. +and `webmcp-types@0.1.10`. -- **Missing APIs:** declarative forms, CSS states, and lifecycle events - (`toolactivated`/`toolcancel`, their handlers, and `ToolActivatedEvent`/`ToolCancelEvent`) - are not implemented. +- **Missing APIs:** declarative forms and CSS states are not implemented. - **Draft differences:** results are JSON-serialized; some pinned tests expect raw strings. Omitted or `undefined` input becomes `{}`; `null` and primitives reject. +- **Lifecycle events:** `toolactivated` fires before the callback is invoked, as the + draft specifies; the pinned `executeTool-abort` test and Chromium fire it after the + callback starts. Script-dispatched events cannot be + [trusted](https://dom.spec.whatwg.org/#dom-event-istrusted), so `isTrusted` is false. - **Timing:** MessagePorts approximate native task ordering. Aborting before dispatch skips the callback; the draft dispatches and then aborts its signal. Delegated permission checks are asynchronous, so argument errors can precede diff --git a/package.json b/package.json index af40b46..c20b947 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "lint": "oxlint --deny-warnings" }, "dependencies": { - "webmcp-types": "^0.1.9" + "webmcp-types": "^0.1.10" }, "devDependencies": { "@playwright/test": "^1.55.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd5e2e8..d64a300 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: webmcp-types: - specifier: ^0.1.9 - version: 0.1.9 + specifier: ^0.1.10 + version: 0.1.10 devDependencies: '@playwright/test': specifier: ^1.55.0 @@ -344,8 +344,8 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - webmcp-types@0.1.9: - resolution: {integrity: sha512-ibdkppgRDkQ/nvH541YS48kVt96OiuDr+d9JHnirAtI5k0coaFdo2QqCipbXyfwrteDC6xXwPoUGC9uS5ZGwfw==} + webmcp-types@0.1.10: + resolution: {integrity: sha512-U4GQ1RpJ0tqCUt2ALMV9Pnhi+hsy8L31f/Q1v4mLVhW64r5sYhh+2+SjP/Lq4/9jP33vZgNkuRZARJl65A/ZIg==} snapshots: @@ -553,4 +553,4 @@ snapshots: undici-types@7.18.2: {} - webmcp-types@0.1.9: {} + webmcp-types@0.1.10: {} diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..dc2138a --- /dev/null +++ b/src/events.ts @@ -0,0 +1,83 @@ +/*! + * Copyright (c) 2026 WebMCP polyfill contributors + * SPDX-License-Identifier: MIT + */ + +import type { WebMCP } from "webmcp-types"; + +/** + * Dispatched at a `ModelContext` when the execution of a tool begins. + * + * @see https://webmachinelearning.github.io/webmcp/#tool-activated-event + */ +export class ToolActivatedEvent extends Event implements WebMCP.ToolActivatedEvent { + readonly #toolName: string; + + // A defaulted second parameter keeps Web IDL's required-argument count in function.length. + constructor(type: string, eventInitDict: WebMCP.ToolActivatedEventInit = {}) { + requireEventType(arguments.length); + // Event converts type, then bubbles, cancelable and composed; toolName sorts after them. + super(type, eventInitDict); + this.#toolName = readToolName(eventInitDict); + } + + get toolName(): string { + return this.#toolName; + } +} + +/** + * Dispatched at a `ModelContext` when the execution of a tool is cancelled. + * + * @see https://webmachinelearning.github.io/webmcp/#tool-cancel-event + */ +export class ToolCancelEvent extends Event implements WebMCP.ToolCancelEvent { + readonly #toolName: string; + + constructor(type: string, eventInitDict: WebMCP.ToolCancelEventInit = {}) { + requireEventType(arguments.length); + super(type, eventInitDict); + this.#toolName = readToolName(eventInitDict); + } + + get toolName(): string { + return this.#toolName; + } +} + +// Web IDL interface objects keep their names through minification and expose enumerable +// attributes and a class string on the prototype. +for (const [eventInterface, name] of [ + [ToolActivatedEvent, "ToolActivatedEvent"], + [ToolCancelEvent, "ToolCancelEvent"], +] as const) { + Object.defineProperty(eventInterface, "name", { value: name }); + Object.defineProperties(eventInterface.prototype, { + toolName: { enumerable: true }, + [Symbol.toStringTag]: { value: name, configurable: true }, + }); +} + +// An explicit undefined type is converted to "undefined"; only a missing one throws. +function requireEventType(argumentCount: number): void { + if (argumentCount < 1) { + throw new TypeError("1 argument required, but only 0 present"); + } +} + +// Event has already rejected an eventInitDict that is neither an object nor nullish. +function readToolName(eventInitDict: unknown): string { + if (eventInitDict == null) { + return ""; + } + // SAFETY: Event's dictionary conversion above proved this is an object. + const toolName = (eventInitDict as Record).toolName; + if (toolName === undefined) { + return ""; + } + // https://webidl.spec.whatwg.org/#es-DOMString + if (typeof toolName === "symbol") { + throw new TypeError("Cannot convert a Symbol to a string"); + } + return String(toolName); +} diff --git a/src/index.ts b/src/index.ts index 4b7a150..f4974bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ */ import type { WebMCP } from "webmcp-types"; +import { ToolActivatedEvent, ToolCancelEvent } from "./events.js"; import { FrameBridge, activeWindow, @@ -54,20 +55,25 @@ export function installWebMCP(): void { const documentPrototype = Document.prototype; const getDefaultView = Object.getOwnPropertyDescriptor(documentPrototype, "defaultView")!.get!; - const constructorDescriptor = Object.getOwnPropertyDescriptor(globalThis, "ModelContext"); + const interfaceObjects = { + ModelContext: modelContextConstructor, + ToolActivatedEvent, + ToolCancelEvent, + }; + const canDefineInterface = (name: string): boolean => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + return descriptor ? descriptor.configurable === true : Object.isExtensible(globalThis); + }; if ( !Object.isExtensible(documentPrototype) || - (constructorDescriptor && !constructorDescriptor.configurable) || - (!constructorDescriptor && !Object.isExtensible(globalThis)) + !Object.keys(interfaceObjects).every(canDefineInterface) ) { throw new TypeError("Cannot install WebMCP on this realm"); } - Object.defineProperty(globalThis, "ModelContext", { - value: modelContextConstructor, - configurable: true, - writable: true, - }); + for (const [name, value] of Object.entries(interfaceObjects)) { + Object.defineProperty(globalThis, name, { value, configurable: true, writable: true }); + } // A method is non-constructible; defaultView supplies the native Document brand check. const { getModelContext } = { @@ -96,14 +102,20 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { readonly #tools = new Map(); // Only a document that was active when its context was created has a bridge. readonly #frames?: FrameBridge; - #toolchangeHandler: WebMCP.ModelContext["ontoolchange"] = null; - readonly #toolchangeListener = (event: Event): void => { - const handler = this.#toolchangeHandler; + readonly #eventHandlers: EventHandlers = { + ontoolchange: null, + ontoolactivated: null, + ontoolcancel: null, + }; + // One listener serves every handler, so replacing a handler keeps its listener position. + readonly #eventHandlerListener = (event: Event): void => { + const name = eventHandlerName(event.type); + const handler: unknown = name ? this.#eventHandlers[name] : null; // An EventHandler keeps a non-callable object but never invokes it. if (typeof handler !== "function") { return; } - const result = Reflect.apply(handler, this, [event]); + const result: unknown = Reflect.apply(handler, this, [event]); if (result === false) { Event.prototype.preventDefault.call(event); } @@ -127,20 +139,27 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { } get ontoolchange(): WebMCP.ModelContext["ontoolchange"] { - return this.#toolchangeHandler; + return this.#eventHandlers.ontoolchange; } set ontoolchange(handler: WebMCP.ModelContext["ontoolchange"]) { - // [LegacyTreatNonObjectAsNull]: only a non-object becomes null. - const nextHandler = isObject(handler) ? handler : null; - // Replacing a handler preserves its listener position; clearing it removes that position. - if (!this.#toolchangeHandler && nextHandler) { - this.addEventListener("toolchange", this.#toolchangeListener); - } - if (this.#toolchangeHandler && !nextHandler) { - this.removeEventListener("toolchange", this.#toolchangeListener); - } - this.#toolchangeHandler = nextHandler; + this.#setEventHandler("ontoolchange", handler); + } + + get ontoolactivated(): WebMCP.ModelContext["ontoolactivated"] { + return this.#eventHandlers.ontoolactivated; + } + + set ontoolactivated(handler: WebMCP.ModelContext["ontoolactivated"]) { + this.#setEventHandler("ontoolactivated", handler); + } + + get ontoolcancel(): WebMCP.ModelContext["ontoolcancel"] { + return this.#eventHandlers.ontoolcancel; + } + + set ontoolcancel(handler: WebMCP.ModelContext["ontoolcancel"]) { + this.#setEventHandler("ontoolcancel", handler); } // Default parameters preserve Web IDL's required-argument counts in function.length. @@ -289,22 +308,26 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { return new Promise((resolve, reject) => { callerSignal?.throwIfAborted(); const callbackController = new AbortController(); - // A callback that already finished must not be aborted by a late cancellation. - let callbackFinished = false; + // The draft's local pending tool execution: it exists from invocation until the callback + // settles or the call is cancelled, and a late cancellation must not abort or report it. + let callbackPending = false; const onCallerAbort = (): void => { reject(callerSignal!.reason); // Reject the caller first; the running callback receives a default AbortError. queueTask(() => { - if (!callbackFinished) { - callbackController.abort(); + if (!callbackPending) { + return; } + callbackPending = false; + callbackController.abort(); + this.dispatchEvent(new ToolCancelEvent("toolcancel", { toolName: name })); }); }; const rejectExecution = (): void => { - callbackFinished = true; + callbackPending = false; queueTask(() => { callerSignal?.removeEventListener("abort", onCallerAbort); reject(executionError()); @@ -312,7 +335,7 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { }; const completeExecution = (value: unknown): void => { - callbackFinished = true; + callbackPending = false; // A cancelled call must not run the author's toJSON during serialization. if (callerSignal?.aborted) { return; @@ -352,6 +375,10 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { return; } + // Listener exceptions are reported, not thrown, and the callback still runs. + this.dispatchEvent(new ToolActivatedEvent("toolactivated", { toolName: name })); + callbackPending = true; + // The callback runs without the registration object as its receiver. const execute = storedTool.execute; const callbackResult = execute(input, { signal: callbackController.signal }); @@ -395,6 +422,29 @@ class ModelContextPolyfill extends EventTarget implements WebMCP.ModelContext { }); }); } + + #setEventHandler(name: Name, handler: EventHandlers[Name]): void { + const type = name.slice("on".length); + const previousHandler = this.#eventHandlers[name]; + // [LegacyTreatNonObjectAsNull]: only a non-object becomes null. + const nextHandler = isObject(handler) ? handler : null; + // Replacing a handler preserves its listener position; clearing it removes that position. + if (!previousHandler && nextHandler) { + this.addEventListener(type, this.#eventHandlerListener); + } + if (previousHandler && !nextHandler) { + this.removeEventListener(type, this.#eventHandlerListener); + } + this.#eventHandlers[name] = nextHandler; + } +} + +const eventHandlerNames = ["ontoolchange", "ontoolactivated", "ontoolcancel"] as const; +type EventHandlerName = (typeof eventHandlerNames)[number]; +type EventHandlers = Pick; + +function eventHandlerName(type: string): EventHandlerName | undefined { + return eventHandlerNames.find((name) => name === `on${type}`); } function isExposedTo(tool: StoredTool, ownerOrigin: string, callerOrigin: string): boolean { @@ -419,6 +469,8 @@ Object.defineProperties(ModelContextPolyfill.prototype, { getTools: { enumerable: true }, executeTool: { enumerable: true }, ontoolchange: { enumerable: true }, + ontoolactivated: { enumerable: true }, + ontoolcancel: { enumerable: true }, }); // Web IDL reads and converts dictionary members in lexicographical order. diff --git a/tests/events.test.ts b/tests/events.test.ts new file mode 100644 index 0000000..08bcae3 --- /dev/null +++ b/tests/events.test.ts @@ -0,0 +1,215 @@ +import { test, expect } from "@playwright/test"; + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + const hasNativeContext = await page.evaluate(() => "modelContext" in document); + expect(hasNativeContext).toBe(false); + await page.addScriptTag({ url: "/auto.js" }); +}); + +test("event interfaces convert their arguments like Web IDL constructors", async ({ page }) => { + const outcome = await page.evaluate(() => { + const nameOf = (error: unknown): string => (error instanceof Error ? error.name : "none"); + return [ToolActivatedEvent, ToolCancelEvent].map((EventInterface) => { + const global = Object.getOwnPropertyDescriptor(window, EventInterface.name)!; + const toolName = Object.getOwnPropertyDescriptor(EventInterface.prototype, "toolName")!; + const errors = [ + () => Reflect.construct(EventInterface, []), + () => Reflect.apply(EventInterface, undefined, ["x"]), + () => toolName.get!.call(new Event("x")), + () => Reflect.construct(EventInterface, ["x", { toolName: Symbol("name") }]), + ].map((operation) => { + try { + operation(); + return "none"; + } catch (error) { + return nameOf(error); + } + }); + + const reads: PropertyKey[] = []; + const init = new Proxy( + { toolName: 1, bubbles: true }, + { + get(target, key, receiver) { + reads.push(key); + return Reflect.get(target, key, receiver); + }, + }, + ); + const converted: ToolActivatedEvent | ToolCancelEvent = Reflect.construct(EventInterface, [ + undefined, + init, + ]); + const plain = new EventInterface("plain"); + + return { + name: EventInterface.name, + length: EventInterface.length, + global: [global.writable, global.enumerable, global.configurable], + parent: Object.getPrototypeOf(EventInterface) === Event, + getter: [typeof toolName.get, toolName.set, toolName.enumerable, toolName.configurable], + brand: Object.prototype.toString.call(plain), + errors, + plain: [ + plain.type, + plain.toolName, + plain.bubbles, + plain.cancelable, + plain.composed, + plain.isTrusted, + ], + converted: [converted.type, converted.toolName, converted.bubbles], + reads, + }; + }); + }); + + expect(outcome).toEqual( + ["ToolActivatedEvent", "ToolCancelEvent"].map((name) => ({ + name, + length: 1, + global: [true, false, true], + parent: true, + getter: ["function", undefined, true, true], + brand: `[object ${name}]`, + errors: ["TypeError", "TypeError", "TypeError", "TypeError"], + plain: ["plain", "", false, false, false, false], + converted: ["undefined", "1", true], + reads: ["bubbles", "cancelable", "composed", "toolName"], + })), + ); +}); + +test("lifecycle event handlers keep EventHandler semantics", async ({ page }) => { + const outcome = await page.evaluate(() => { + const context = document.modelContext!; + return (["ontoolactivated", "ontoolcancel"] as const).map((name) => { + const type = name.slice("on".length); + const initial = context[name]; + const receivers: unknown[] = []; + context[name] = function (this: unknown) { + receivers.push(this); + return false; + }; + const event = new Event(type, { cancelable: true }); + context.dispatchEvent(event); + const coerced = Reflect.set(context, name, 1) && context[name]; + context.dispatchEvent(new Event(type)); + return { + initial, + calls: receivers.length, + receiver: receivers[0] === context, + defaultPrevented: event.defaultPrevented, + coerced, + }; + }); + }); + + expect(outcome).toEqual( + Array(2).fill({ + initial: null, + calls: 1, + receiver: true, + defaultPrevented: true, + coerced: null, + }), + ); +}); + +test("toolactivated precedes the callback and toolcancel follows its aborted signal", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + const order: string[] = []; + const events: Event[] = []; + const { promise: cancelled, resolve: observeCancel } = Promise.withResolvers(); + context.addEventListener("toolactivated", (event) => { + events.push(event); + order.push(event.type); + }); + context.addEventListener("toolcancel", (event) => { + events.push(event); + order.push(event.type); + observeCancel(); + }); + + const { promise: started, resolve: enter } = Promise.withResolvers(); + await context.registerTool({ + name: "pending", + description: "Pending", + execute(_input, { signal }) { + order.push("callback"); + signal.addEventListener("abort", () => order.push("callback aborted"), { once: true }); + enter(); + return new Promise(() => {}); + }, + }); + + const tool = (await context.getTools())[0]!; + const controller = new AbortController(); + const rejected = context + .executeTool(tool, {}, { signal: controller.signal }) + .catch(() => order.push("rejected")); + await started; + controller.abort(); + await Promise.all([rejected, cancelled]); + + return { + order, + events: events.map((event) => [ + event.constructor.name, + event instanceof ToolActivatedEvent || event instanceof ToolCancelEvent + ? event.toolName + : null, + event.target === context, + event.bubbles, + event.cancelable, + event.composed, + ]), + }; + }); + + expect(outcome).toEqual({ + order: ["toolactivated", "callback", "rejected", "callback aborted", "toolcancel"], + events: [ + ["ToolActivatedEvent", "pending", true, false, false, false], + ["ToolCancelEvent", "pending", true, false, false, false], + ], + }); +}); + +test("no lifecycle event fires for a call that never starts or has already settled", async ({ + page, +}) => { + const outcome = await page.evaluate(async () => { + const context = document.modelContext!; + const events: string[] = []; + context.ontoolactivated = (event) => events.push(`${event.type}:${event.toolName}`); + context.ontoolcancel = (event) => events.push(`${event.type}:${event.toolName}`); + await context.registerTool({ name: "done", description: "Done", execute: () => "done" }); + const done = (await context.getTools())[0]!; + const missing = { ...done, name: "missing" }; + + // Aborted before the dispatch task. + const early = new AbortController(); + const skipped = context.executeTool(done, {}, { signal: early.signal }).catch(() => null); + early.abort(); + await skipped; + + // Rejected before invocation. + await context.executeTool(missing, {}).catch(() => null); + + // Aborted after the callback settled. + const late = new AbortController(); + await context.executeTool(done, {}, { signal: late.signal }); + late.abort(); + + // The cancellation task was queued before this discovery resolves. + await context.getTools(); + return events; + }); + + expect(outcome).toEqual(["toolactivated:done"]); +}); diff --git a/tests/frames.test.ts b/tests/frames.test.ts index af6cf6c..0bf39ba 100644 --- a/tests/frames.test.ts +++ b/tests/frames.test.ts @@ -508,6 +508,53 @@ test("remote cancellation preserves the caller's reason and ignores late seriali expect(await owner.locator("body").getAttribute("data-serialized")).toBeNull(); }); +test("lifecycle events fire at the context of the frame that owns the tool", async ({ page }) => { + const owner = await addFrame(page.mainFrame(), "owner", { origin: remoteOrigin }); + await owner.evaluate(async (origin) => { + const context = document.modelContext!; + const events: string[] = []; + const record = (event: ToolActivatedEvent | ToolCancelEvent): void => { + events.push(`${event.type}:${event.toolName}`); + document.body.dataset.events = events.join(","); + }; + context.addEventListener("toolactivated", record); + context.addEventListener("toolcancel", record); + await context.registerTool( + { + name: "pending", + description: "Pending", + execute() { + document.body.dataset.state = "started"; + return new Promise(() => {}); + }, + }, + { exposedTo: [origin] }, + ); + }, localOrigin); + + const execution = await page.evaluateHandle(async (origin) => { + const context = document.modelContext!; + const events: string[] = []; + context.addEventListener("toolactivated", (event) => events.push(event.type)); + context.addEventListener("toolcancel", (event) => events.push(event.type)); + const tool = (await context.getTools({ fromOrigins: [origin] }))[0]!; + const controller = new AbortController(); + const result = context.executeTool(tool, {}, { signal: controller.signal }).catch(() => null); + return { controller, events, result }; + }, remoteOrigin); + + await expect(owner.locator("body")).toHaveAttribute("data-state", "started"); + await execution.evaluate(async ({ controller, result }) => { + controller.abort(); + await result; + }); + await expect(owner.locator("body")).toHaveAttribute( + "data-events", + "toolactivated:pending,toolcancel:pending", + ); + expect(await execution.evaluate(({ events }) => events)).toEqual([]); +}); + test("same-document navigation preserves a remote invocation and its callback signal", async ({ page, }) => { diff --git a/tests/index.test.ts b/tests/index.test.ts index e57d7ca..ed11e65 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -436,7 +436,14 @@ test("installs once, exposes only standard members, and keeps document identity" }); expect(result).toEqual({ same: true, - members: ["executeTool", "getTools", "ontoolchange", "registerTool"], + members: [ + "executeTool", + "getTools", + "ontoolactivated", + "ontoolcancel", + "ontoolchange", + "registerTool", + ], own: [], brand: "[object ModelContext]", instance: true, diff --git a/tests/package.test.ts b/tests/package.test.ts index 8d4c03c..676ec3b 100644 --- a/tests/package.test.ts +++ b/tests/package.test.ts @@ -42,6 +42,8 @@ try { "README.md", "dist/auto.d.ts", "dist/auto.js", + "dist/events.d.ts", + "dist/events.js", "dist/frames.d.ts", "dist/frames.js", "dist/index.d.ts", @@ -101,6 +103,16 @@ try { await context.executeTool(tool, '{}'); return [result, omitted, explicitUndefined]; } + + function observe(context: WebMCP.ModelContext): ToolCancelEvent { + context.addEventListener('toolactivated', (event) => { + const activated: string = event.toolName; + }); + context.ontoolcancel = (event) => { + const cancelled: string = event.toolName; + }; + return new ToolCancelEvent('toolcancel', { toolName: 'typed' }); + } `, ); for (const [module, moduleResolution] of [ diff --git a/wpt/metadata/webmcp/idlharness.https.window.js.ini b/wpt/metadata/webmcp/idlharness.https.window.js.ini deleted file mode 100644 index 262c744..0000000 --- a/wpt/metadata/webmcp/idlharness.https.window.js.ini +++ /dev/null @@ -1,35 +0,0 @@ -# Lifecycle events are unsupported: ModelContext has no ontoolactivated/ontoolcancel -# handlers, and ToolActivatedEvent/ToolCancelEvent are not defined. -[idlharness.https.window.html] - [ModelContext interface: attribute ontoolactivated] - expected: FAIL - [ModelContext interface: attribute ontoolcancel] - expected: FAIL - [ToolActivatedEvent interface: existence and properties of interface object] - expected: FAIL - [ToolActivatedEvent interface object length] - expected: FAIL - [ToolActivatedEvent interface object name] - expected: FAIL - [ToolActivatedEvent interface: existence and properties of interface prototype object] - expected: FAIL - [ToolActivatedEvent interface: existence and properties of interface prototype object's "constructor" property] - expected: FAIL - [ToolActivatedEvent interface: existence and properties of interface prototype object's @@unscopables property] - expected: FAIL - [ToolActivatedEvent interface: attribute toolName] - expected: FAIL - [ToolCancelEvent interface: existence and properties of interface object] - expected: FAIL - [ToolCancelEvent interface object length] - expected: FAIL - [ToolCancelEvent interface object name] - expected: FAIL - [ToolCancelEvent interface: existence and properties of interface prototype object] - expected: FAIL - [ToolCancelEvent interface: existence and properties of interface prototype object's "constructor" property] - expected: FAIL - [ToolCancelEvent interface: existence and properties of interface prototype object's @@unscopables property] - expected: FAIL - [ToolCancelEvent interface: attribute toolName] - expected: FAIL diff --git a/wpt/metadata/webmcp/imperative/executeTool-abort.https.html.ini b/wpt/metadata/webmcp/imperative/executeTool-abort.https.html.ini index 306283a..786f77c 100644 --- a/wpt/metadata/webmcp/imperative/executeTool-abort.https.html.ini +++ b/wpt/metadata/webmcp/imperative/executeTool-abort.https.html.ini @@ -1,14 +1,15 @@ -# The first case expects a raw string result; the second waits for unsupported -# toolactivated/toolcancel events. Later cases do not run. +# The first case expects a raw string result. The second expects toolactivated +# after the callback starts; the draft dispatches it before invoking the callback. +# The third aborts synchronously after executeTool(); the polyfill rejects before +# dispatching to the callback (see TESTING.md "Timing"), so the callback never +# resolves the promise the test awaits. The last case does not run. [executeTool-abort.https.html] expected: TIMEOUT [executeTool() provides a non-aborted default AbortSignal when called without options] expected: FAIL [caller abort propagates to tool callback options.signal] - expected: TIMEOUT - [executeTool() with already-aborted tool synchronously rejects] - expected: NOTRUN + expected: FAIL [executeTool() promise remains rejected even if tool callback resolves after abort] - expected: NOTRUN + expected: TIMEOUT [concurrent executions of the same tool have independent AbortSignals] expected: NOTRUN diff --git a/wpt/metadata/webmcp/imperative/executeTool-events.https.html.ini b/wpt/metadata/webmcp/imperative/executeTool-events.https.html.ini index 727e315..0a63206 100644 --- a/wpt/metadata/webmcp/imperative/executeTool-events.https.html.ini +++ b/wpt/metadata/webmcp/imperative/executeTool-events.https.html.ini @@ -1,10 +1,10 @@ -# Lifecycle events are unsupported. The first case waits for a toolactivated event -# that is never dispatched, so the later cases do not run. +# The first case requires isTrusted, which script-dispatched events cannot have +# (https://dom.spec.whatwg.org/#dom-event-istrusted). The other cases use +# /common/blank.html frames, where the polyfill is not installed. [executeTool-events.https.html] - expected: TIMEOUT [toolactivated and toolcancel events are dispatched on document.modelContext during tool execution and cancellation] - expected: TIMEOUT + expected: FAIL [toolactivated is dispatched at the ModelContext of the tool target document] - expected: NOTRUN + expected: FAIL [toolcancel is dispatched at the ModelContext of the tool target document] - expected: NOTRUN + expected: FAIL diff --git a/wpt/metadata/webmcp/tool-activated-event.https.html.ini b/wpt/metadata/webmcp/tool-activated-event.https.html.ini deleted file mode 100644 index 04d625a..0000000 --- a/wpt/metadata/webmcp/tool-activated-event.https.html.ini +++ /dev/null @@ -1,11 +0,0 @@ -# Lifecycle events are unsupported: ToolActivatedEvent is not defined and executeTool() -# dispatches no toolactivated event. -[tool-activated-event.https.html] - [ToolActivatedEvent constructor with default event init dictionary] - expected: FAIL - [ToolActivatedEvent constructor with an event init dictionary] - expected: FAIL - [ontoolactivated is invoked for synthetic toolactivated events dispatched at ModelContext] - expected: FAIL - [ontoolactivated is invoked when a tool is activated] - expected: FAIL diff --git a/wpt/metadata/webmcp/tool-cancel-event.https.html.ini b/wpt/metadata/webmcp/tool-cancel-event.https.html.ini index dd8a582..5e4f649 100644 --- a/wpt/metadata/webmcp/tool-cancel-event.https.html.ini +++ b/wpt/metadata/webmcp/tool-cancel-event.https.html.ini @@ -1,12 +1,7 @@ -# Lifecycle events are unsupported: ToolCancelEvent is not defined, and the last case -# waits for a toolcancel event that is never dispatched. +# The last case aborts synchronously after executeTool(). The polyfill rejects +# before dispatching to the callback (see TESTING.md "Timing"), so the tool never +# starts and no toolcancel event is dispatched. [tool-cancel-event.https.html] expected: TIMEOUT - [ToolCancelEvent constructor with default event init dictionary] - expected: FAIL - [ToolCancelEvent constructor with an event init dictionary] - expected: FAIL - [ontoolcancel is invoked for synthetic toolcancel events dispatched at ModelContext] - expected: FAIL [ontoolcancel is invoked when a tool invocation is canceled] expected: TIMEOUT