useWebMCPForm: isAgentActive stays true forever after an autosubmit call with a fast execute

#29 · closed · 0 comments

View on GitHub ↗

MrSunshyne

## Summary `useWebMCPForm()`'s `isAgentActive` latches **on permanently** after an agent calls a form that has `toolautosubmit`, when `execute` settles quickly. For an autosubmit form, Chrome dispatches `submit` **before** `toolactivated`, and never dispatches `toolcancel` at all. `onSubmit`'s `finally` therefore clears the flag *before* `onActivated` sets it, and nothing is left to clear it again. Any UI bound to `isAgentActive` (a class, the documented alternative to the `:tool-form-active` pseudo-class) stays stuck in the agent-active state for the life of the page. Non-autosubmit forms are unaffected — there the ordering is the expected one. **Environment:** `[email protected]` (from jsdelivr), Vue 3.5, Chrome 151.0.0.0 on macOS, WebMCP enabled. ## Observed event ordering Captured with capture-phase `submit` listeners on each form plus `window` listeners for `toolactivated` / `toolcancel`. Timestamps are from one page session: | case | `toolautosubmit` | event order | final `isAgentActive` | | | --- | --- | --- | --- | --- | | `autosubmit_sync` | yes | `submit` +11225 → `execute` +11227 → `toolactivated` +11228 | **`true`** | ✗ stuck | | `autosubmit_slow` | yes | `submit` +12132 → `execute` +12132 → `toolactivated` +12133 | `false` | ✓ | | `manual_sync` | no | `toolactivated` +26021 → `submit` +26723 → `execute` +26724 | `false` | ✓ | `toolcancel` never fired in any case. The only difference between the two autosubmit cases is that `autosubmit_slow`'s `execute` awaits 400 ms. Both are correct submissions returning correct results — only the flag differs. ## Why Two writers race for `isAgentActive`: - [`declarative.ts:166-168`](packages/vue-webmcp/src/declarative.ts#L166-L168) — `onSubmit`'s `finally` sets it `false` - [`declarative.ts:194-195`](packages/vue-webmcp/src/declarative.ts#L194-L195) — `onActivated` sets it `true` - [`declarative.ts:197-198`](packages/vue-webmcp/src/declarative.ts#L197-L198) — `onCancel` sets it `false`, and never runs here `onSubmit` reaches its `finally` after `await work` (line 160). With a synchronous or fast `execute`, `work` settles within the submit task's microtask drain, so the `finally` runs **before** the browser can dispatch `toolactivated` in a later task. Sequence: clear → set → nothing. With a slow `execute`, the `finally` lands after `toolactivated`, so the clear wins and the flag ends up correct by luck of timing. So the bug is timing-dependent: it reproduces reliably with a fast handler, and disappears with a handler that awaits anything real. That is probably why the test suite hasn't caught it — a stubbed `execute` that resolves immediately still goes through the *composable's* ordering, but the suite drives `onSubmit` directly rather than through a browser that dispatches `toolactivated` afterwards. ## Reproduction Self-contained; no build step. Serve it over `http://localhost` in Chrome 149+ with `chrome://flags/#enable-webmcp-testing`. ```html <!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <title>useWebMCPForm isAgentActive repro</title> <script type="importmap"> { "imports": { "vue": "https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.esm-browser.prod.js" } } </script> </head> <body> <div id="app"> <p>SubmitEvent.respondWith supported: {{ supported }}</p> <hr /> <div v-for="c in cases" :key="c.name"> <h3>{{ c.name }} — isAgentActive: <b>{{ c.active.value }}</b></h3> <form v-bind="c.attrs.value"> <input name="text" toolparamdescription="Anything" required /> <button type="submit">Submit</button> </form> </div> <hr /> <pre id="events">{{ events.join('\n') }}</pre> </div> <script type="module"> import { createApp, ref } from "https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.esm-browser.prod.js"; import { useWebMCPForm } from "https://cdn.jsdelivr.net/npm/[email protected]/dist/index.mjs"; const events = ref([]); const t0 = performance.now(); const at = () => `+${Math.round(performance.now() - t0)}ms`; for (const type of ["toolactivated", "toolcancel"]) { window.addEventListener(type, (e) => events.value.push(`${at()} ${type} ${e.toolName}`)); } createApp({ setup() { const make = (name, { autosubmit, delay }) => { const { attrs, isAgentActive } = useWebMCPForm({ name, description: `repro case ${name}`, autosubmit, async execute({ text }) { events.value.push(`${at()} execute:${name} (delay ${delay}ms)`); if (delay) await new Promise((r) => setTimeout(r, delay)); return `ok: ${text}`; }, }); return { name, attrs, active: isAgentActive }; }; return { supported: typeof SubmitEvent !== "undefined" && "respondWith" in SubmitEvent.prototype, events, cases: [ make("autosubmit_sync", { autosubmit: true, delay: 0 }), make("autosubmit_slow", { autosubmit: true, delay: 400 }), make("manual_sync", { autosubmit: false, delay: 0 }), ], }; }, }).mount("#app"); queueMicrotask(() => { for (const form of document.querySelectorAll("form")) { form.addEventListener("submit", () => events.value.push(`${at()} submit ${form.getAttribute("toolname")}`), true); } }); </script> </body> </html> ``` Then, from the page's console (or via the Tool Inspector extension), call each tool: ```js const run = async (name) => { const tool = (await document.modelContext.getTools()).find(t => t.name === name); try { return await document.modelContext.executeTool(tool, JSON.stringify({ text: 'x' })); } catch (e) { if (!(e instanceof TypeError)) throw e; return await document.modelContext.executeTool(tool, { text: 'x' }); } }; await run('autosubmit_sync'); // heading stays "isAgentActive: true" forever await run('autosubmit_slow'); // heading returns to false ``` `manual_sync` needs the button clicked to complete, as designed. ## Expected After an autosubmit submission completes, `isAgentActive` should be `false` — the agent is done with the form. ## Notes for whoever picks this up Some things worth deciding rather than assuming: - The straightforward reading is that a `toolactivated` arriving for a submission that has **already** been handled should not turn the flag back on. Sequencing the clear after activation, or ignoring an activation that arrives after this form's in-flight submission started, both fit — but they behave differently if Chrome ever changes the ordering, so it's worth picking deliberately rather than papering over the current order. - Whatever the fix, the ordering is Chromium's current behavior, not something the spec pins down. It may be worth a comment recording the observed order, so the next person doesn't "fix" it back. - A regression test needs a browser-realistic ordering (`submit`, then `toolactivated`, no `toolcancel`), which the current test double may not model. That may be the more valuable half of this change. - `manual_sync` in the repro is a useful control — it should keep working throughout. ## Where this was hit [MrSunshyne/webmcp-demos#2](https://github.com/MrSunshyne/webmcp-demos/pull/2) adds a declarative-forms demo with two forms. The read-only lookup form (`toolautosubmit`, fast handler) showed a permanently stuck agent-active outline, and that PR works around it by not binding agent-active styling on the autosubmit form. The workaround can come out once this is fixed.

Comments