From 356268920866137ce025fa249c843be1bb0cd4ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Beaufort?= Date: Thu, 1 Oct 2026 10:59:09 +0200 Subject: [PATCH] Add ToolActivatedEvent and ToolCancelEvent Follow webmachinelearning/webmcp#245 (draft d61d0e6): define the ToolActivatedEvent and ToolCancelEvent interfaces, add the ontoolactivated and ontoolcancel handlers to ModelContext, and dispatch the events at the ModelContext of the document that owns the tool. toolactivated fires before the callback is invoked, as the draft specifies; toolcancel fires when a caller aborts a callback that has started and not settled. Bump webmcp-types to 0.1.10 for the declarations. All pinned IDL checks and the ToolActivatedEvent tests now pass. Record the remaining expected failures: isTrusted is false for script-dispatched events, the pinned executeTool-abort test expects toolactivated after the callback starts, blank.html frames have no polyfill, and aborting before dispatch skips the callback. Validation: 238 browser tests and packed consumer checks pass. WPT in Chrome Canary 157.0.8080.0 and Firefox Nightly 159.0a1: 70 files, 190 subtests, zero unexpected results. --- README.md | 2 +- TESTING.md | 28 +-- package.json | 2 +- pnpm-lock.yaml | 10 +- src/events.ts | 83 +++++++ src/index.ts | 110 ++++++--- tests/events.test.ts | 215 ++++++++++++++++++ tests/frames.test.ts | 47 ++++ tests/index.test.ts | 9 +- tests/package.test.ts | 12 + .../webmcp/idlharness.https.window.js.ini | 35 --- .../executeTool-abort.https.html.ini | 13 +- .../executeTool-events.https.html.ini | 12 +- .../tool-activated-event.https.html.ini | 11 - .../webmcp/tool-cancel-event.https.html.ini | 11 +- 15 files changed, 484 insertions(+), 116 deletions(-) create mode 100644 src/events.ts create mode 100644 tests/events.test.ts delete mode 100644 wpt/metadata/webmcp/idlharness.https.window.js.ini delete mode 100644 wpt/metadata/webmcp/tool-activated-event.https.html.ini 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