diff --git a/CHANGELOG.md b/CHANGELOG.md index 6036cbf..b48159d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ reconstruct them from git history. ## [Unreleased] +- Preserve the original execution callback options and signal through the TS/JS + result guards; retain the existing null/undefined guard and single-flight behavior. +- Add caller cancellation to the event bridge, including preabort without dispatch, + cooperative handler signalling, unknown-outcome errors, and listener/timer cleanup. + Registration disposal remains separate; application tools are never retried automatically. +- Date current-draft/Chrome compatibility guidance, type callback options and + ModelContext activation/cancellation events plus the debugging annotation, and + distinguish serialized native results from callback values and simulation evidence. +- Align Workbench imperative simulation with execution cancellation, omitted input + and serialized returns; add JS/transpiled-TS regressions. Simulation is not proof + of current native Chrome behavior. +- Correct the README release callout to describe v0.7.0's existing mutation journal. + ## [0.7.0] — 2026-09-19 - Added a dependency-free host-side mutation journal for verification runners. diff --git a/README.md b/README.md index 79aa9e0..1f65ba4 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,10 @@ npx skills add TueJon/webmcpify # once ``` > [!TIP] -> **New in [v0.6.0](https://github.com/TueJon/webmcpify/releases/tag/v0.6.0):** -> evidence-aware resume — changed app files, contracts or browsers invalidate the -> verification they affect — plus scoped browser access and independent checks for -> every mutation. +> **New in [v0.7.0](https://github.com/TueJon/webmcpify/releases/tag/v0.7.0):** +> a host-side mutation journal records dispatch and settlement durably, serializes +> cooperating verification runners with an OS lock, and retains interrupted +> `started` entries for reconciliation. It does not guarantee exactly-once effects. ## How it works diff --git a/skills/webmcpify/references/integrate.md b/skills/webmcpify/references/integrate.md index 37bca8b..179aa19 100644 --- a/skills/webmcpify/references/integrate.md +++ b/skills/webmcpify/references/integrate.md @@ -78,7 +78,7 @@ Use the vendored runtime (`runtime.md`). Tools live in a dedicated module per ap (e.g. `src/webmcp/tools.ts`), decoupled from components: ```ts -import { createToolScope, dispatchAndWait } from './webmcpify'; +import { createToolScope, dispatchAndWait, singleFlight } from './webmcpify'; export const searchTicketsTool = { name: 'search_tickets', @@ -91,14 +91,20 @@ export const searchTicketsTool = { required: ['query'], }, annotations: { readOnlyHint: true, untrustedContentHint: true, consequentialHint: false }, - async execute(input: Record) { + execute: singleFlight(async (input: Record, options?: ToolExecuteCallbackOptions) => { const q = String(input.query ?? '').trim(); if (!q) return 'ERROR: `query` must be a non-empty string.'; - return dispatchAndWait('webmcp:search_tickets', { query: q }); - }, + return dispatchAndWait('webmcp:search_tickets', { query: q }, 10000, options?.signal); + }), }; ``` +Execution options must reach the bridge unchanged through wrappers; use +`options?.signal` for legacy browsers that omit the second argument. Registration +abort removes availability; it is separate from execution cancellation. Cancelled +or timed-out actions have an unknown outcome: inspect state before any manual retry, +and never automatically retry an application tool. See [dated compatibility](native-compatibility.md). + > **Native I/O compat** — `getTools()` may return `inputSchema` as a string on older Chrome or an object on current implementations — handle both (`typeof === 'string' ? JSON.parse : id`). Current `executeTool` takes an object; Chrome 150 needs `JSON.stringify(args)`, so use the capability-probe adapter from the verification template rather than retrying a real tool. For `validate:true`, register with `inputSchema` only. Runner LLM envelope: `const raw=t.inputSchema; const schema=typeof raw==='string'?JSON.parse(raw):raw??{type:'object',properties:{}}; const llmTool={function:{parameters:schema}}` — never pass `parameters` through WebMCP. Key rules: diff --git a/skills/webmcpify/references/native-compatibility.md b/skills/webmcpify/references/native-compatibility.md new file mode 100644 index 0000000..93943af --- /dev/null +++ b/skills/webmcpify/references/native-compatibility.md @@ -0,0 +1,88 @@ +# Native compatibility — checked 2026-10-05 + +WebMCP is evolving. These are separate evidence levels; simulation results are +not native browser acceptance. + +## Current primary documentation + +The [CG draft](https://webmachinelearning.github.io/webmcp/) specifies callback +options containing an execution signal, optional input defaulting to `{}`, and +JSON serialization of successful imperative results. A callback returning `"done"` +therefore produces the JSON string text `"done"` at `executeTool`, including the +quotes; a callback object produces serialized JSON. Callback values and native +client results are different boundaries. Browser-owned `null` for declarative +navigation is separate from the runtime guard against absent imperative results. + +The [Chrome imperative guide](https://developer.chrome.com/docs/ai/webmcp/imperative-api) +(updated 2026-09-21) documents execution cancellation via callback `{ signal }`, +Chrome 153 preserving in-flight executions when tools are unregistered, Chrome 155 +deprecating JSON-string input in favor of object input, and the `debugging` +annotation from Chrome 156. These version claims are documentation, not proof +from the installed Chrome 150 run. + +The [Chrome declarative guide](https://developer.chrome.com/docs/ai/webmcp/declarative-api) +(updated 2026-09-25) documents `agentInvoked` and `respondWith` after +`preventDefault()`. Activation/cancellation events target `document.modelContext`; +window event handlers are unsupported from Chrome 156. Declarative submission and +navigation are not covered by imperative simulation. + +## Accepted native evidence: Chrome 150 + +Governor report: `/tmp/webmcpify-native-browser-governor-report.md`, run +2026-10-05T05:31:43.012Z, Chrome 150.0.7871.186, headed with WebMCP and +WebMCPTesting, temporary synthetic loopback fixture only: + +| Probe | Observed result | +|---|---| +| Callback options | Second argument absent; no execution signal received. | +| Caller abort | Caller rejected with AbortError; delayed side effect still ran. | +| Registration abort during execution | Tool disappeared; caller rejected with UnknownError; callback continued and side effect ran. | +| Omitted execute input | TypeError requiring two arguments; callback was not invoked. | + +These observations do not establish modern cancellation, omitted-input defaults, +or current-draft result serialization in Chrome 150. Use the existing side-effect-free +input capability probe to select object versus legacy JSON-string input; never +retry a real application tool to discover compatibility. + +## Accepted native evidence: Chrome Beta 156 + +Governor evidence: `/tmp/webmcpify-beta-governor-evidence.json`, run +2026-10-05T05:38:51.840Z, Chrome Beta 156.0.8078.4, headless with WebMCP and +WebMCPTesting, temporary synthetic loopback fixture only. All five probes pass: + +| Probe | Observed result | +|---|---| +| Input shape | Object input invoked the callback exactly once. | +| Callback options | Second argument contained an AbortSignal. | +| Caller abort | Started callback received the signal; caller rejected with AbortError; cooperative handler suppressed the delayed side effect. | +| Registration abort during execution | Tool disappeared; already-started callback completed, side effect ran, and caller fulfilled. | +| Omitted execute input | Callback invoked once with `{}`. | + +Successful imperative results arrived as serialized JSON strings: the callback +object `{ accepted: true }` produced the string `{"accepted":true}`. `unregisterTool` +was absent; removal was tested only through the registration AbortSignal, not a +distinct unregister operation. These five probes do not establish native WPT, +declarative submission/navigation, or debugging-annotation conformance. + +## Local runtime and verification boundaries + +Forward `options?.signal` unchanged through wrappers to the fourth argument: + +```ts +execute: singleFlight((input, options?: ToolExecuteCallbackOptions) => + dispatchAndWait('webmcp:save', input, 10000, options?.signal)), +``` + +The bridge handles preabort without dispatch, cooperative cancellation and cleanup. +Handlers must pass the dispatched signal to asynchronous work and check it before +committing state. Cancellation/timeout leaves an unknown outcome when handlers or +servers continue; inspect state before a manual retry. Registration disposal removes +availability and is not an execution-cancellation mechanism. + +`tests/parity.test.mjs` compares JS and transpiled TS client runtime behavior; +`tests/workbench.test.mjs` tests imperative simulation (default input, serialized +returns and execution/registration separation). `tests/workbench-browser.mjs` tests +the Workbench UI using simulation, not native API conformance. Mutation-journal +checks in `tests/manifest-lock.test.mjs` cover host-side durable dispatch/settlement, +locking and interrupted entries; neither journaling nor cancellation guarantees +exactly-once effects. No continuation-token support is implemented here. diff --git a/skills/webmcpify/references/runtime.md b/skills/webmcpify/references/runtime.md index ff1e206..399c14d 100644 --- a/skills/webmcpify/references/runtime.md +++ b/skills/webmcpify/references/runtime.md @@ -25,8 +25,8 @@ What it provides: |---|---| | `getModelContext()` | The ONLY place `document.modelContext` / deprecated `navigator.modelContext` is referenced — spec churn stays a one-file fix | | `isWebMCPAvailable()` | Feature detection — the app must work identically without WebMCP | -| `createToolScope(key, tools, options?)` | Registers a tool set under one AbortController; returns a **callable dispose handle** carrying `ready: Promise` (true = all registrations committed; false = no WebMCP / duplicate key / failure / disposed first — never rejects). Missing WebMCP remains a safe no-op, but development builds warn once with secure-origin/Chrome/flag diagnostics. Validates contracts BEFORE registering; wraps every imperative `execute()` so bare `null`/`undefined` becomes a structured error; **rolls back the whole scope** on any registration failure, including sync-throwing legacy `registerTool` (reported via `options.onError`, default `console.error` — NOT called when disposed before settling). An already-active key returns a no-op handle — safe under React StrictMode | -| `dispatchAndWait(event, detail?, timeoutMs?)` | Bridges `execute()` to the app's own event/state flow. The dispatched detail carries `requestId` plus `signal` — an AbortSignal aborted on timeout; pass it to `fetch()` and skip state commits once aborted. Resolves only after the component confirms with an explicit **boolean** `ok`; a completion with missing/non-boolean `ok` **fails closed** to an `"ERROR: ..."` string, as do timeouts and `ok: false` (self-correction convention — never rejects). For tools whose confirmation involves a network round-trip (mailers, slow APIs), pass an explicit `timeoutMs` (e.g. `20_000`) instead of relying on the 10 s default | +| `createToolScope(key, tools, options?)` | Registers a tool set under one AbortController; returns a **callable dispose handle** carrying `ready: Promise` (true = all registrations committed; false = no WebMCP / duplicate key / failure / disposed first — never rejects). Missing WebMCP remains a safe no-op, but development builds warn once with secure-origin/Chrome/flag diagnostics. Validates contracts BEFORE registering; forwards execution options unchanged and wraps every imperative `execute()` so bare `null`/`undefined` becomes a structured error; **rolls back the whole scope** on any registration failure, including sync-throwing legacy `registerTool` (reported via `options.onError`, default `console.error` — NOT called when disposed before settling). An already-active key returns a no-op handle — safe under React StrictMode | +| `dispatchAndWait(event, detail?, timeoutMs?, callerSignal?)` | Bridges `execute()` to the app's own event/state flow. The dispatched detail carries `requestId` plus `signal` — an AbortSignal aborted on timeout or caller cancellation; pass it to `fetch()` and skip state commits once aborted. Resolves only after the component confirms with an explicit **boolean** `ok`; a completion with missing/non-boolean `ok` **fails closed** to an `"ERROR: ..."` string, as do timeouts and `ok: false` (self-correction convention — never rejects). For tools whose confirmation involves a network round-trip (mailers, slow APIs), pass an explicit `timeoutMs` (e.g. `20_000`) instead of relying on the 10 s default | | `singleFlight(fn, busyMessage?)` | Serializes a tool's `execute`: while one call is in flight, further calls resolve immediately to a busy `"ERROR: ..."` string instead of racing shared UI state | Validation note: budget checks auto-enable when the bundler substitutes @@ -35,13 +35,21 @@ isn't `'production'`; unbundled projects default to off — pass `{ validate: tr during development. Result note: application tools must return a JSON-safe value. Bare `null` and -`undefined` are forbidden for imperative tools because current Chrome clients can +`undefined` are forbidden for imperative tools because browser clients can surface them as ambiguous success, the string `"null"`, or an execution-context failure. The runtime converts an accidental absent result to `{ ok: false, error: "…" }` (and logs it in development), but integrations should return the intended structured result themselves. A browser-owned `null` from a navigating declarative form is a different execution path and remains valid. +Cancellation note (2026-10-05): pass `options?.signal` as the fourth bridge +argument, including inside `singleFlight`. A preaborted caller dispatches nothing. +Every settlement removes the completion/caller listeners and timeout. Cancellation +signals cooperative handlers and reports an unknown outcome; it cannot prevent side +effects in handlers that ignore the signal. Never automatically retry. Scope disposal +only aborts registration; the current draft and Chrome guide (153+) describe preserving started invocations, +but installed Chrome 150 does not prove that behavior. See [compatibility](native-compatibility.md). + ## The completion contract (the part integrators get wrong) `dispatchAndWait` resolves when the component fires `tool-completion-` @@ -53,7 +61,7 @@ screen; a completion fired early produces false greens. Hardened component bridge (React example — adapt per framework). Five clauses: **(1)** completion fires from an effect observing the committed state, **(2)** -availability gate, **(3)** single-flight, **(4)** timeout coordination via +availability gate, **(3)** single-flight, **(4)** timeout/caller-cancellation coordination via `detail.signal`, **(5)** unmount cancellation. ```tsx @@ -72,8 +80,8 @@ useEffect(() => { if (inFlight) return fail('A search is already running.'); // (3) single-flight inFlight = true; try { - const found = await runSearch(query, { signal }); // (4) the runtime aborts this signal on timeout - if (signal?.aborted) return; // (4) timed out — runtime already answered; no late commits + const found = await runSearch(query, { signal }); // (4) the runtime aborts this signal on timeout or caller cancellation + if (signal?.aborted) return; // (4) cancelled/timed out — runtime already answered; no late commits pending.current = { requestId, count: found.length }; setResults(found); // commit → the effect below confirms } catch (err) { diff --git a/skills/webmcpify/templates/webmcp-workbench.js b/skills/webmcpify/templates/webmcp-workbench.js index 87c67ad..ad21e3f 100644 --- a/skills/webmcpify/templates/webmcp-workbench.js +++ b/skills/webmcpify/templates/webmcp-workbench.js @@ -65,6 +65,7 @@ throw new TypeError('Simulated WebMCP tools need a name and execute function.'); } if (tools.has(tool.name)) throw new Error(`Tool "${tool.name}" is already registered.`); + if (options.signal?.aborted) throw options.signal.reason; tools.set(tool.name, { public: { ...tool, execute: undefined }, execute: tool.execute }); options.signal?.addEventListener('abort', () => { if (tools.delete(tool.name)) notify(); @@ -74,11 +75,42 @@ async getTools() { return Array.from(tools.values(), ({ public: tool }) => ({ ...tool })); }, - async executeTool(tool, input) { + // Simulation of current imperative cancellation, not native browser evidence. + async executeTool(tool, input = {}, options = {}) { + if (options.signal?.aborted) throw options.signal.reason; const registered = tools.get(tool?.name); if (!registered) throw new Error(`Tool "${tool?.name ?? 'unknown'}" is not registered.`); const args = typeof input === 'string' ? JSON.parse(input) : input; - return registered.execute(args ?? {}); + const execution = new AbortController(); + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => options.signal?.removeEventListener('abort', onAbort); + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + execution.abort(options.signal.reason); + reject(options.signal.reason); + }; + options.signal?.addEventListener('abort', onAbort, { once: true }); + Promise.resolve().then(() => { + if (settled) return; + return registered.execute(args ?? {}, { signal: execution.signal }); + }).then((result) => { + if (settled) return; + // Native executeTool returns the JSON serialization, including strings. + const serialized = JSON.stringify(result); + if (serialized === undefined) throw new TypeError('Tool result is not JSON serializable.'); + settled = true; + cleanup(); + resolve(serialized); + }).catch((error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }); + }); }, }; return context; diff --git a/skills/webmcpify/templates/webmcp.d.ts b/skills/webmcpify/templates/webmcp.d.ts index 34247ce..e5bc8c2 100644 --- a/skills/webmcpify/templates/webmcp.d.ts +++ b/skills/webmcpify/templates/webmcp.d.ts @@ -35,6 +35,8 @@ */ interface ModelContextToolAnnotations { + /** Developer tooling hint; never enforcement. */ + debugging?: boolean; readOnlyHint?: boolean; untrustedContentHint?: boolean; /** Significant real-world or non-reversible effect; a hint, never enforcement. */ @@ -48,7 +50,34 @@ type ModelContextToolResult = | Record | unknown[]; +/** Current CG callback options. Legacy Chrome 150 may omit the options argument. */ +interface ToolExecuteCallbackOptions { + signal: AbortSignal; +} + +interface ToolActivatedEvent extends Event { readonly toolName: string; } +interface ToolCancelEvent extends Event { readonly toolName: string; } +interface ModelContextEventMap { + toolchange: Event; + toolactivated: ToolActivatedEvent; + toolcancel: ToolCancelEvent; +} + interface ModelContext extends EventTarget { + ontoolactivated: ((this: ModelContext, ev: ToolActivatedEvent) => unknown) | null; + ontoolcancel: ((this: ModelContext, ev: ToolCancelEvent) => unknown) | null; + addEventListener( + type: K, listener: (this: ModelContext, ev: ModelContextEventMap[K]) => unknown, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions): void; + removeEventListener( + type: K, listener: (this: ModelContext, ev: ModelContextEventMap[K]) => unknown, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null, + options?: boolean | EventListenerOptions): void; registerTool( tool: ModelContextTool, options?: { signal?: AbortSignal; exposedTo?: string[] }, @@ -61,13 +90,15 @@ interface ModelContext extends EventTarget { getTools(options?: { fromOrigins?: string[] }): Promise; /** * Agent/test execution surface in the CG draft. Current Chrome accepts an - * object; Chrome 150's JSON-string input remains in the verification adapter. + * object and returns its serialized result; navigation null is retained for + * Chrome/declarative compatibility even though the 2026-10-02 draft IDL lists + * DOMString. Chrome 150's JSON-string input remains in the verification adapter. */ executeTool?( tool: RegisteredTool, inputObject?: unknown, options?: { signal?: AbortSignal }, - ): Promise; + ): Promise; /** Internal simulation capability used by the vendored Workbench. */ __webmcpStubObjectMode?: boolean; } @@ -82,13 +113,12 @@ interface ModelContextTool { /** JSON Schema for the tool's input */ inputSchema?: object; /** - * Only `input` is passed — there is no client/session argument in the IDL. - * IDL: `Promise` — WebIDL auto-wraps synchronous returns (and throws) in - * a promise, so a sync implementation still fulfills this type at runtime; - * declare it async for type fidelity. + * Current draft passes options with a required signal; second argument is optional + * here to represent legacy Chrome 150. Forward options unchanged; registration + * signals only control availability. Return JSON-safe results, never bare absence. + * Native executeTool serializes this callback result (checked 2026-10-05). */ - /** JSON-safe result; bare null/undefined are intentionally excluded. */ - execute(input: Record): Promise; + execute(input: Record, options?: ToolExecuteCallbackOptions): Promise; annotations?: ModelContextToolAnnotations; } diff --git a/skills/webmcpify/templates/webmcpify.js b/skills/webmcpify/templates/webmcpify.js index 812cae0..90839c9 100644 --- a/skills/webmcpify/templates/webmcpify.js +++ b/skills/webmcpify/templates/webmcpify.js @@ -150,15 +150,15 @@ export function createToolScope(key, tools, options) { /** * Imperative tools must never hand Chrome an ambiguous absent result. - * @param {object & { name: string, execute: (input: Record) => unknown }} tool + * @param {object & { name: string, execute: (input: Record, options?: { signal: AbortSignal }) => unknown }} tool * @param {boolean} reportInDevelopment * @returns {object} */ function guardToolResult(tool, reportInDevelopment) { return { ...tool, - async execute(input) { - const result = await tool.execute(input); + async execute(input, options) { + const result = await tool.execute(input, options); if (result !== null && result !== undefined) return result; const error = `Tool "${tool.name}" returned ${result === null ? 'null' : 'undefined'}. ` + @@ -172,7 +172,7 @@ function guardToolResult(tool, reportInDevelopment) { /** * Bridge execute() to the app's own event/state flow. The dispatched detail * carries `{ ...detail, requestId, signal }` — `signal` is an AbortSignal aborted - * on timeout; handlers should pass it to fetch() and skip state commits and the + * on timeout or caller cancellation; handlers should pass it to fetch() and skip state commits and the * completion dispatch once aborted. Resolves only after the component confirms * the outcome by dispatching `tool-completion-` with * `detail: { ok: boolean, message?: string, error?: string }` — and it must do so @@ -188,25 +188,38 @@ function guardToolResult(tool, reportInDevelopment) { * @param {string} eventName * @param {Record} [detail] * @param {number} [timeoutMs] + * @param {AbortSignal} [callerSignal] * @returns {Promise} */ -export function dispatchAndWait(eventName, detail = {}, timeoutMs = 10000) { +export function dispatchAndWait(eventName, detail = {}, timeoutMs = 10000, callerSignal) { return new Promise((resolve) => { const requestId = Math.random().toString(36).slice(2, 12); const completionEvent = `tool-completion-${requestId}`; const abort = new AbortController(); + let settled = false; const cleanup = () => { clearTimeout(timer); window.removeEventListener(completionEvent, onDone); + callerSignal?.removeEventListener('abort', onCancel); }; - const timer = setTimeout(() => { + const cancel = (message, reason) => { + if (settled) return; + settled = true; cleanup(); - abort.abort(); - resolve( - 'ERROR: The interface did not confirm this action in time. The request was signalled to cancel but may still be processing — check the current page state before retrying.', - ); - }, timeoutMs); + // Remove the completion listener before abort: handlers may answer synchronously. + abort.abort(reason); + resolve(message); + }; + const onCancel = () => cancel( + 'ERROR: This action was cancelled. The outcome is unknown and may still be processing — check the current page state before retrying.', + callerSignal?.reason, + ); + const timer = setTimeout(() => cancel( + 'ERROR: The interface did not confirm this action in time. The request was signalled to cancel but may still be processing — check the current page state before retrying.', + ), timeoutMs); const onDone = (event) => { + if (settled) return; + settled = true; cleanup(); const result = event.detail ?? {}; if (result.ok === true) { @@ -219,10 +232,19 @@ export function dispatchAndWait(eventName, detail = {}, timeoutMs = 10000) { ); } }; + if (callerSignal?.aborted) { + onCancel(); + return; + } + callerSignal?.addEventListener('abort', onCancel, { once: true }); window.addEventListener(completionEvent, onDone); - window.dispatchEvent( - new CustomEvent(eventName, { detail: { ...detail, requestId, signal: abort.signal } }), - ); + try { + window.dispatchEvent( + new CustomEvent(eventName, { detail: { ...detail, requestId, signal: abort.signal } }), + ); + } catch { + cancel('ERROR: The interface could not dispatch this action. The outcome is unknown — check the current page state before retrying.'); + } }); } @@ -231,7 +253,8 @@ export function dispatchAndWait(eventName, detail = {}, timeoutMs = 10000) { * resolve immediately to a busy ERROR string instead of racing shared UI state. * * ```js - * execute: singleFlight(async (input) => dispatchAndWait('webmcp:save', input)), + * execute: singleFlight(async (input, options) => + * dispatchAndWait('webmcp:save', input, 10000, options?.signal)), * ``` * * @template {unknown[]} A diff --git a/skills/webmcpify/templates/webmcpify.ts b/skills/webmcpify/templates/webmcpify.ts index f7f91ac..35bca41 100644 --- a/skills/webmcpify/templates/webmcpify.ts +++ b/skills/webmcpify/templates/webmcpify.ts @@ -178,8 +178,8 @@ export function createToolScope( function guardToolResult(tool: ModelContextTool, reportInDevelopment: boolean): ModelContextTool { return { ...tool, - async execute(input: Record): Promise { - const result = await tool.execute(input); + async execute(input: Record, options?: ToolExecuteCallbackOptions): Promise { + const result = await tool.execute(input, options); if (result !== null && result !== undefined) return result; const error = `Tool "${tool.name}" returned ${result === null ? 'null' : 'undefined'}. ` + @@ -193,7 +193,7 @@ function guardToolResult(tool: ModelContextTool, reportInDevelopment: boolean): /** * Bridge execute() to the app's own event/state flow. The dispatched detail * carries `{ ...detail, requestId, signal }` — `signal` is an AbortSignal aborted - * on timeout; handlers should pass it to fetch() and skip state commits and the + * on timeout or caller cancellation; handlers should pass it to fetch() and skip state commits and the * completion dispatch once aborted. Resolves only after the component confirms * the outcome by dispatching `tool-completion-` with * `detail: { ok: boolean, message?: string, error?: string }` — and it must do so @@ -210,23 +210,36 @@ export function dispatchAndWait( eventName: string, detail: Record = {}, timeoutMs = 10000, + callerSignal?: AbortSignal, ): Promise { return new Promise((resolve) => { const requestId = Math.random().toString(36).slice(2, 12); const completionEvent = `tool-completion-${requestId}`; const abort = new AbortController(); + let settled = false; const cleanup = () => { clearTimeout(timer); window.removeEventListener(completionEvent, onDone as EventListener); + callerSignal?.removeEventListener('abort', onCancel); }; - const timer = setTimeout(() => { + const cancel = (message: string, reason?: unknown) => { + if (settled) return; + settled = true; cleanup(); - abort.abort(); - resolve( - 'ERROR: The interface did not confirm this action in time. The request was signalled to cancel but may still be processing — check the current page state before retrying.', - ); - }, timeoutMs); + // Remove the completion listener before abort: handlers may answer synchronously. + abort.abort(reason); + resolve(message); + }; + const onCancel = () => cancel( + 'ERROR: This action was cancelled. The outcome is unknown and may still be processing — check the current page state before retrying.', + callerSignal?.reason, + ); + const timer = setTimeout(() => cancel( + 'ERROR: The interface did not confirm this action in time. The request was signalled to cancel but may still be processing — check the current page state before retrying.', + ), timeoutMs); const onDone = (event: Event) => { + if (settled) return; + settled = true; cleanup(); const result = (event as CustomEvent<{ ok?: unknown; message?: string; error?: string }>).detail ?? {}; @@ -240,10 +253,19 @@ export function dispatchAndWait( ); } }; + if (callerSignal?.aborted) { + onCancel(); + return; + } + callerSignal?.addEventListener('abort', onCancel, { once: true }); window.addEventListener(completionEvent, onDone as EventListener); - window.dispatchEvent( - new CustomEvent(eventName, { detail: { ...detail, requestId, signal: abort.signal } }), - ); + try { + window.dispatchEvent( + new CustomEvent(eventName, { detail: { ...detail, requestId, signal: abort.signal } }), + ); + } catch { + cancel('ERROR: The interface could not dispatch this action. The outcome is unknown — check the current page state before retrying.'); + } }); } @@ -252,7 +274,8 @@ export function dispatchAndWait( * resolve immediately to a busy ERROR string instead of racing shared UI state. * * ```ts - * execute: singleFlight(async (input) => dispatchAndWait('webmcp:save', input)), + * execute: singleFlight(async (input, options) => + * dispatchAndWait('webmcp:save', input, 10000, options?.signal)), * ``` */ export function singleFlight( diff --git a/tests/native-compatibility-browser.mjs b/tests/native-compatibility-browser.mjs new file mode 100644 index 0000000..3f524d3 --- /dev/null +++ b/tests/native-compatibility-browser.mjs @@ -0,0 +1,272 @@ +import { createServer } from 'node:http'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { writeFile } from 'node:fs/promises'; + +import { chromium } from '@playwright/test'; +const executable = process.env.CHROME_BIN || '/usr/bin/google-chrome'; +const reportPath = process.env.NATIVE_REPORT || '/tmp/webmcpify-modern-report.md'; +const evidencePath = process.env.NATIVE_EVIDENCE || '/tmp/webmcpify-modern-evidence.json'; +const timeoutMs = 8000; +const requiredChecks = ['input_shape_probe', 'options_signal', 'caller_abort_cooperative_side_effect', 'registration_abort_started_invocation', 'omitted_input']; +const evidence = { + runAt: new Date().toISOString(), + fixture: 'temporary synthetic loopback only; no application tools invoked', + browser: { executable }, checks: {}, +}; +let server; +let browser; +let page; + +function settle(status, details = {}) { return { ...details, status }; } +async function bounded(promise, label) { + let timer; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); }), + ]); + } finally { clearTimeout(timer); } +} +async function evaluate(name, body, argument) { + try { evidence.checks[name] = settle('observed', await bounded(page.evaluate(body, argument), name)); } + catch (error) { evidence.checks[name] = settle('fail', { error: String(error?.stack || error) }); } +} + +try { + const { stdout } = await promisify(execFile)(executable, ['--version'], { timeout: timeoutMs }); + evidence.browser.shellObservedVersion = stdout.trim(); + evidence.browser.shellVersionCommand = { executable, args: ['--version'] }; + evidence.browser.flags = ['WebMCP', 'WebMCPTesting']; + server = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('Native WebMCP proof fixturesynthetic'); + }); + await bounded(new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }), 'loopback server start'); + browser = await bounded(chromium.launch({ + headless: true, executablePath: executable, + args: ['--enable-features=WebMCP,WebMCPTesting', '--disable-background-networking', '--no-first-run', '--no-default-browser-check'], + }), 'browser launch'); + evidence.browser.version = await browser.version(); + evidence.browser.headless = true; + page = await browser.newPage(); + await bounded(page.goto(`http://127.0.0.1:${server.address().port}/`), 'fixture navigation'); + evidence.nativeSurface = await page.evaluate(() => ({ + context: typeof document.modelContext, + registerTool: typeof document.modelContext?.registerTool, + getTools: typeof document.modelContext?.getTools, + executeTool: typeof document.modelContext?.executeTool, + unregisterTool: typeof document.modelContext?.unregisterTool, + })); + if (evidence.nativeSurface.context !== 'object' || evidence.nativeSurface.registerTool !== 'function' || evidence.nativeSurface.getTools !== 'function' || evidence.nativeSurface.executeTool !== 'function') { + for (const check of [...requiredChecks, 'unregisterTool_api']) { + evidence.checks[check] = settle('unsupported', { reason: 'required native WebMCP surface unavailable' }); + } + } else { + await evaluate('input_shape_probe', async () => { + let calls = 0; + let received; + const name = `native_probe_${crypto.randomUUID().replaceAll('-', '')}`; + const controller = new AbortController(); + await document.modelContext.registerTool({ + name, description: 'synthetic input shape capability probe', inputSchema: { type: 'object', properties: {} }, + async execute(input) { calls++; received = { type: typeof input, value: input }; return { accepted: true, call: calls }; }, + }, { signal: controller.signal }); + const tool = (await document.modelContext.getTools()).find(item => item.name === name); + let outcome; + let inputMode = 'object'; + const attempts = []; + const input = { probe: 'synthetic' }; + try { outcome = { fulfilled: true, value: await document.modelContext.executeTool(tool, input) }; } + catch (error) { outcome = { fulfilled: false, error: String(error), errorName: error.name }; } + attempts.push({ inputMode, calls, outcome }); + // Retry only this side-effect-free synthetic probe, and only if the object + // input was rejected before its callback. Never retry a started callback. + if (!outcome.fulfilled && calls === 0) { + inputMode = 'legacy-json'; + try { outcome = { fulfilled: true, value: await document.modelContext.executeTool(tool, JSON.stringify(input)) }; } + catch (error) { outcome = { fulfilled: false, error: String(error), errorName: error.name }; } + attempts.push({ inputMode, calls, outcome }); + } + controller.abort(); + return { calls, received, outcome, inputMode, attempts, serializedResult: JSON.stringify(outcome.value) }; + }); + const probe = evidence.checks.input_shape_probe; + if (probe.calls !== 1 || !probe.outcome?.fulfilled || !['object', 'legacy-json'].includes(probe.inputMode)) { + throw new Error('synthetic probe could not select a usable input mode'); + } + const inputMode = probe.inputMode; + + await evaluate('options_signal', async inputMode => { + let calls = 0; + let observed; + const name = `native_signal_${crypto.randomUUID().replaceAll('-', '')}`; + const controller = new AbortController(); + await document.modelContext.registerTool({ + name, description: 'synthetic second callback argument probe', inputSchema: { type: 'object', properties: {} }, + async execute(input, options) { + calls++; + observed = { input, optionsPresent: options !== undefined, optionsType: typeof options, + signalPresent: options?.signal !== undefined, + signalIsAbortSignal: options?.signal instanceof AbortSignal }; + return { accepted: true }; + }, + }, { signal: controller.signal }); + const tool = (await document.modelContext.getTools()).find(item => item.name === name); + let outcome; + try { outcome = { fulfilled: true, value: await document.modelContext.executeTool(tool, inputMode === 'legacy-json' ? JSON.stringify({ probe: 'signal' }) : { probe: 'signal' }) }; } + catch (error) { outcome = { fulfilled: false, error: String(error), errorName: error.name }; } + controller.abort(); + return { calls, observed, outcome, serializedResult: JSON.stringify(outcome.value) }; + }, inputMode); + + await evaluate('caller_abort_cooperative_side_effect', async inputMode => { + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + const bounded = (promise, label) => { + let timer; + return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(label + ' timed out')), 6000); })]).finally(() => clearTimeout(timer)); + }; + let calls = 0; + let started = false; + let signalSeen = false; + let sideEffect = 0; + const name = `native_caller_abort_${crypto.randomUUID().replaceAll('-', '')}`; + const registration = new AbortController(); + await document.modelContext.registerTool({ + name, description: 'synthetic cooperative cancellation proof', inputSchema: { type: 'object', properties: {} }, + async execute(_input, options) { + calls++; started = true; signalSeen = options?.signal instanceof AbortSignal; + await delay(160); + if (!options?.signal?.aborted) sideEffect++; + return { sideEffect, aborted: !!options?.signal?.aborted }; + }, + }, { signal: registration.signal }); + const tool = (await document.modelContext.getTools()).find(item => item.name === name); + const caller = new AbortController(); + const pending = document.modelContext.executeTool(tool, inputMode === 'legacy-json' ? '{}' : {}, { signal: caller.signal }) + .then(value => ({ fulfilled: true, value }), error => ({ fulfilled: false, error: String(error), errorName: error.name })); + await bounded((async () => { while (!started && calls === 0) await delay(5); })(), 'synthetic callback start'); + const callbackStartedBeforeAbort = started; + caller.abort(); + const outcome = await bounded(pending, 'caller-aborted invocation'); + await delay(200); + registration.abort(); + return { calls, callbackStartedBeforeAbort, signalSeen, callerSignalAborted: caller.signal.aborted, + sideEffect, outcome, serializedResult: JSON.stringify(outcome.value) }; + }, inputMode); + + await evaluate('registration_abort_started_invocation', async inputMode => { + const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + const bounded = (promise, label) => { + let timer; + return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(label + ' timed out')), 6000); })]).finally(() => clearTimeout(timer)); + }; + let calls = 0; + let started = false; + let sideEffect = 0; + const name = `native_registration_abort_${crypto.randomUUID().replaceAll('-', '')}`; + const registration = new AbortController(); + await document.modelContext.registerTool({ + name, description: 'synthetic registration abort proof', inputSchema: { type: 'object', properties: {} }, + async execute() { calls++; started = true; await delay(160); sideEffect++; return { completed: true }; }, + }, { signal: registration.signal }); + const tool = (await document.modelContext.getTools()).find(item => item.name === name); + const pending = document.modelContext.executeTool(tool, inputMode === 'legacy-json' ? '{}' : {}) + .then(value => ({ fulfilled: true, value }), error => ({ fulfilled: false, error: String(error), errorName: error.name })); + await bounded((async () => { while (!started && calls === 0) await delay(5); })(), 'registration callback start'); + const callbackStartedBeforeAbort = started; + registration.abort(); + const unavailableAfterAbort = !(await document.modelContext.getTools()).some(item => item.name === name); + const outcome = await bounded(pending, 'registration-aborted invocation'); + return { calls, callbackStartedBeforeAbort, registrationSignalAborted: registration.signal.aborted, + unavailableAfterAbort, sideEffect, outcome, serializedResult: JSON.stringify(outcome.value) }; + }, inputMode); + + await evaluate('omitted_input', async () => { + let calls = 0; + let received = { state: 'not-called' }; + const name = `native_omitted_${crypto.randomUUID().replaceAll('-', '')}`; + const registration = new AbortController(); + await document.modelContext.registerTool({ + name, description: 'synthetic omitted-input probe', inputSchema: { type: 'object', properties: {} }, + async execute(input) { calls++; received = input === undefined ? { type: 'undefined' } : { type: typeof input, value: input }; return { invoked: true }; }, + }, { signal: registration.signal }); + const tool = (await document.modelContext.getTools()).find(item => item.name === name); + let outcome; + try { outcome = { fulfilled: true, value: await document.modelContext.executeTool(tool) }; } + catch (error) { outcome = { fulfilled: false, error: String(error), errorName: error.name }; } + registration.abort(); + return { calls, received, outcome, serializedResult: JSON.stringify(outcome.value) }; + }); + + evidence.checks.unregisterTool_api = evidence.nativeSurface.unregisterTool === 'function' + ? settle('supported', { note: 'unregisterTool is exposed; no substituted operation was used' }) + : settle('unsupported', { reason: 'unregisterTool is not exposed; registration AbortSignal was tested separately' }); + } +} catch (error) { + evidence.runnerError = String(error?.stack || error); + for (const name of [...requiredChecks, 'unregisterTool_api']) { + evidence.checks[name] ??= settle('unsupported', { reason: 'runner could not reach native check', detail: String(error) }); + } +} finally { + await browser?.close().catch(() => {}); + if (server?.listening) await new Promise(resolve => server.close(resolve)); +} + +function classify() { + const probe = evidence.checks.input_shape_probe; + if (probe?.status === 'observed') { + if (probe.calls !== 1 || !probe.outcome?.fulfilled || !probe.received || !['object', 'string'].includes(probe.received.type)) { + evidence.checks.input_shape_probe = settle('fail', { ...probe, reason: 'synthetic probe did not invoke exactly once with a recognized input shape' }); + } else evidence.checks.input_shape_probe = settle('pass', { ...probe, note: 'synthetic capability probe only; no application tool retry' }); + } + const signal = evidence.checks.options_signal; + if (signal?.status === 'observed') { + evidence.checks.options_signal = signal.calls === 1 && signal.observed?.optionsPresent === true && signal.observed.signalIsAbortSignal === true && signal.outcome?.fulfilled + ? settle('pass', signal) : settle('fail', { ...signal, reason: 'callback did not prove a present AbortSignal in its second argument and successful invocation' }); + } + const caller = evidence.checks.caller_abort_cooperative_side_effect; + if (caller?.status === 'observed') { + evidence.checks.caller_abort_cooperative_side_effect = caller.calls === 1 && caller.callbackStartedBeforeAbort === true && caller.signalSeen === true && caller.callerSignalAborted === true && caller.sideEffect === 0 && caller.outcome?.fulfilled === false && caller.outcome.errorName === 'AbortError' + ? settle('pass', caller) : settle('fail', { ...caller, reason: 'started callback, forwarded signal, AbortError rejection, or side-effect suppression was not fully proved' }); + } + const registration = evidence.checks.registration_abort_started_invocation; + if (registration?.status === 'observed') { + evidence.checks.registration_abort_started_invocation = registration.calls === 1 && registration.callbackStartedBeforeAbort === true && registration.registrationSignalAborted === true && registration.unavailableAfterAbort === true && registration.sideEffect === 1 && registration.outcome?.fulfilled + ? settle('pass', registration) : settle('fail', { ...registration, reason: 'registration abort did not prove removal plus successful completion of an already-started callback' }); + } + const omitted = evidence.checks.omitted_input; + if (omitted?.status === 'observed') { + evidence.checks.omitted_input = omitted.calls !== 1 || omitted.received?.type === 'not-called' + ? settle('fail', { ...omitted, reason: 'omitted-input callback was not invoked exactly once' }) + : omitted.received?.type === 'object' && omitted.received.value && Object.keys(omitted.received.value).length === 0 && omitted.outcome?.fulfilled + ? settle('pass', { ...omitted, note: 'native omitted input arrived as empty object {}' }) + : settle('unsupported', { ...omitted, reason: 'native omitted input did not arrive as {}; callback observation preserved' }); + } +} + +classify(); +evidence.requiredProofsPassed = !evidence.runnerError && requiredChecks.every(name => evidence.checks[name]?.status === 'pass'); +evidence.finishedAt = new Date().toISOString(); +await writeFile(evidencePath, JSON.stringify(evidence, null, 2) + '\n'); +const lines = [ + '# Native WebMCP compatibility proof', '', + `Run: ${evidence.runAt}`, `Finished: ${evidence.finishedAt}`, + `Browser: ${evidence.browser.version ?? 'unavailable'} (${evidence.browser.executable ?? executable})`, + `Fixture: ${evidence.fixture}`, '', + 'All invocations used newly registered synthetic tools. No application tool was invoked or retried.', '', + '| Check | Status | Summary |', '|---|---|---|', +]; +for (const [name, result] of Object.entries(evidence.checks)) { + const summary = result.reason ?? result.note ?? result.error ?? JSON.stringify(result); + lines.push(`| ${name} | ${result.status} | ${String(summary).replaceAll('|', '\\|')} |`); +} +if (evidence.runnerError) lines.push('', `Runner error: ${evidence.runnerError.split('\n')[0]}`); +await writeFile(reportPath, lines.join('\n') + '\n'); +console.log(JSON.stringify({ reportPath, evidencePath, browser: evidence.browser, checks: Object.fromEntries(Object.entries(evidence.checks).map(([key, value]) => [key, value.status])), runnerError: evidence.runnerError }, null, 2)); + +// API presence is informational; every required behavioral proof must pass. +process.exitCode = evidence.requiredProofsPassed ? 0 : 1; diff --git a/tests/parity.test.mjs b/tests/parity.test.mjs index fcc4be7..062da83 100644 --- a/tests/parity.test.mjs +++ b/tests/parity.test.mjs @@ -349,3 +349,115 @@ test('singleFlight: custom busy message is used', async () => { assert.deepEqual(results[0], results[1]); assert.equal(results[0], 'ERROR: custom busy'); }); + +for (const [tag, mod] of variants) { + test(`${tag}: result guard preserves input/options identity, singleFlight varargs and absent-result guard`, async () => { + freshWindow(); + let registered; + setModelContext({ registerTool: async tool => { registered = tool; } }); + const input = { q: 'identity' }; + const options = { signal: new AbortController().signal }; + let finish, calls = 0; + const scope = mod.createToolScope(`options-${tag}`, [{ + name: 'identity', description: 'Tests forwarding.', + execute: mod.singleFlight(async (actualInput, actualOptions) => { + calls++; + assert.equal(actualInput, input); + assert.equal(actualOptions, options); + assert.equal(actualOptions.signal, options.signal); + return new Promise(resolve => { finish = resolve; }); + }), + }], { validate: false }); + assert.equal(await scope.ready, true); + const pending = registered.execute(input, options); + assert.equal(await registered.execute(input, options), BUSY); + assert.equal(calls, 1); + finish('done'); + assert.equal(await pending, 'done'); + scope(); + for (const value of [null, undefined, false, 0, '', { ok: true }]) { + const handle = mod.createToolScope(`guard-${tag}`, [{ + name: 'guard', description: 'Tests guard.', async execute(_, actualOptions) { + assert.equal(actualOptions, options); return value; + }, + }], { validate: false }); + await handle.ready; + const result = await registered.execute(input, options); + if (value == null) { assert.equal(result.ok, false); assert.match(result.error, /returned (null|undefined)/); } + else assert.equal(result, value); + handle(); + } + }); + + test(`${tag}: cooperative bridge cancellation prevents delayed commit; disposal only removes availability`, async () => { + const win = freshWindow(); + let registered, registrationSignal, signal, commits = 0; + setModelContext({ async registerTool(tool, options) { + registered = tool; registrationSignal = options.signal; + } }); + win.addEventListener('parity:save', event => { + signal = event.detail.signal; + const timer = setTimeout(() => { + if (!signal.aborted) { + commits++; + win.dispatchEvent(new CustomEvent(`tool-completion-${event.detail.requestId}`, { detail: { ok: true } })); + } + }, 20); + signal.addEventListener('abort', () => clearTimeout(timer), { once: true }); + }); + const handle = mod.createToolScope(`cancel-${tag}`, [{ + name: 'save', description: 'Synthetic save.', + execute: mod.singleFlight((input, options) => mod.dispatchAndWait('parity:save', input, 1000, options?.signal)), + }], { validate: false }); + await handle.ready; + const caller = new AbortController(); + const pending = registered.execute({}, { signal: caller.signal }); + handle(); + assert.equal(registrationSignal.aborted, true); + assert.equal(signal.aborted, false, 'scope disposal does not abort the active execution'); + caller.abort('user cancelled'); + assert.match(await pending, /cancelled.*outcome is unknown/); + assert.equal(signal.aborted, true); + assert.equal(signal.reason, 'user cancelled'); + await new Promise(resolve => setTimeout(resolve, 30)); + assert.equal(commits, 0); + }); + + test(`${tag}: bridge preabort avoids dispatch and cleans timers/listeners on every settlement`, async () => { + for (const outcome of ['preabort', 'cancel', 'success', 'failure', 'malformed', 'timeout', 'dispatch-error']) { + const win = freshWindow(); + const caller = new AbortController(); + let dispatches = 0, timers = 0; + const completionListeners = new Set(), callerListeners = new Set(); + const add = win.addEventListener.bind(win), remove = win.removeEventListener.bind(win); + win.addEventListener = (name, ...args) => { if (name.startsWith('tool-completion-')) completionListeners.add(args[0]); return add(name, ...args); }; + win.removeEventListener = (name, ...args) => { if (name.startsWith('tool-completion-')) completionListeners.delete(args[0]); return remove(name, ...args); }; + const callerAdd = caller.signal.addEventListener.bind(caller.signal), callerRemove = caller.signal.removeEventListener.bind(caller.signal); + caller.signal.addEventListener = (...args) => { callerListeners.add(args[1]); return callerAdd(...args); }; + caller.signal.removeEventListener = (...args) => { callerListeners.delete(args[1]); return callerRemove(...args); }; + let handlerSignal; + add('parity:cleanup', event => { + dispatches++; handlerSignal = event.detail.signal; + if (outcome === 'cancel') caller.abort(); + if (['success', 'failure', 'malformed'].includes(outcome)) win.dispatchEvent(new CustomEvent(`tool-completion-${event.detail.requestId}`, { + detail: outcome === 'malformed' ? {} : { ok: outcome === 'success' }, + })); + }); + if (outcome === 'preabort') caller.abort(); + if (outcome === 'dispatch-error') win.dispatchEvent = () => { throw new Error('dispatch failed'); }; + const originalSet = globalThis.setTimeout, originalClear = globalThis.clearTimeout; + const active = new Set(); + globalThis.setTimeout = (fn, delay) => { const id = originalSet(fn, delay); active.add(id); timers++; return id; }; + globalThis.clearTimeout = id => { if (active.delete(id)) timers--; originalClear(id); }; + try { + const result = await mod.dispatchAndWait('parity:cleanup', {}, 5, caller.signal); + assert.equal(timers, 0, outcome); + assert.equal(completionListeners.size, 0, outcome); + assert.equal(callerListeners.size, 0, outcome); + assert.equal(dispatches, ['preabort', 'dispatch-error'].includes(outcome) ? 0 : 1); + if (outcome === 'success') { assert.equal(result, 'Action completed successfully.'); caller.abort(); assert.equal(handlerSignal.aborted, false); } + else assert.match(result, /^ERROR:/); + } finally { globalThis.setTimeout = originalSet; globalThis.clearTimeout = originalClear; } + } + }); +} diff --git a/tests/workbench.test.mjs b/tests/workbench.test.mjs index 91f903b..1b0227c 100644 --- a/tests/workbench.test.mjs +++ b/tests/workbench.test.mjs @@ -73,7 +73,7 @@ test('simulated context registers, enumerates, executes and aborts tools', async const [tool] = await context.getTools(); assert.equal(tool.name, 'search_tickets'); assert.equal(tool.execute, undefined, 'public simulated shape mirrors native RegisteredTool'); - assert.deepEqual(await context.executeTool(tool, { q: 'bug' }), { count: 3 }); + assert.equal(await context.executeTool(tool, { q: 'bug' }), '{"count":3}'); controller.abort(); assert.equal((await context.getTools()).length, 0); assert.deepEqual(events, ['toolchange', 'toolchange']); @@ -131,3 +131,45 @@ test('stopping before DOM readiness removes the pending simulation context', asy assert.equal(document.modelContext, undefined); assert.equal(listeners.has('DOMContentLoaded'), false); }); + + +test('simulation separates registration lifetime from active invocation cancellation', async () => { + const context = loadApi().createSimulationContext({ dispatchEvent() {} }); + const registration = new AbortController(); + const caller = new AbortController(); + let signal, finish, started; + const began = new Promise(resolve => { started = resolve; }); + await context.registerTool({ name: 'delayed', execute(input, options) { + assert.equal(JSON.stringify(input), '{}'); + signal = options.signal; + started(); + return new Promise(resolve => { finish = resolve; }); + } }, { signal: registration.signal }); + const [tool] = await context.getTools(); + const pending = context.executeTool(tool, undefined, { signal: caller.signal }); + await began; + registration.abort(); + assert.equal((await context.getTools()).length, 0); + assert.equal(signal.aborted, false); + const rejected = assert.rejects(pending, error => error === caller.signal.reason); + caller.abort(); + await rejected; + assert.equal(signal.aborted, true); + finish({ ok: true }); // late completion cannot turn cancellation into success +}); + +test('simulation preabort skips execute; default input and serialized strings match current contract', async () => { + const context = loadApi().createSimulationContext({ dispatchEvent() {} }); + let calls = 0; + await context.registerTool({ name: 'one', async execute(input, options) { + calls++; + assert.equal(JSON.stringify(input), '{}'); + assert.ok(options.signal instanceof AbortSignal); + return 'done'; + } }); + const [tool] = await context.getTools(); + const caller = new AbortController(); caller.abort(); + await assert.rejects(context.executeTool(tool, {}, { signal: caller.signal }), error => error === caller.signal.reason); + assert.equal(calls, 0); + assert.equal(await context.executeTool(tool), '"done"'); +});