Skip to content

Commit 0903e01

Browse files
committed
fix(studio): observe and retry WebMCP fallback
1 parent 10888aa commit 0903e01

4 files changed

Lines changed: 59 additions & 16 deletions

File tree

packages/studio/src/webmcp/polyfill.test.ts

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
// @vitest-environment jsdom
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3-
import { loadModelContextPolyfill, resetModelContextPolyfillForTest } from "./polyfill";
43
import type { ModelContext } from "./types";
54

65
// The real package defines `document.modelContext` as an import side effect.
76
// A mock cannot do that, so tests stand the object up themselves to represent
87
// the import having happened.
8+
const trackEvent = vi.hoisted(() => vi.fn());
99
vi.mock("@mcp-b/global", () => ({}));
10+
vi.mock("../telemetry/client", () => ({ trackEvent }));
11+
12+
let loadModelContextPolyfill: typeof import("./polyfill").loadModelContextPolyfill;
1013

1114
function installModelContext(): ModelContext {
1215
const modelContext: ModelContext = { registerTool: vi.fn().mockResolvedValue(undefined) };
@@ -18,8 +21,10 @@ function installModelContext(): ModelContext {
1821
return modelContext;
1922
}
2023

21-
beforeEach(() => {
22-
resetModelContextPolyfillForTest();
24+
beforeEach(async () => {
25+
vi.resetModules();
26+
({ loadModelContextPolyfill } = await import("./polyfill"));
27+
trackEvent.mockReset();
2328
});
2429

2530
afterEach(() => {
@@ -32,6 +37,7 @@ describe("loadModelContextPolyfill", () => {
3237
const modelContext = installModelContext();
3338

3439
await expect(loadModelContextPolyfill()).resolves.toBe(modelContext);
40+
expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_loaded");
3541
});
3642

3743
it("shares one load between callers that race", async () => {
@@ -57,16 +63,36 @@ describe("loadModelContextPolyfill", () => {
5763

5864
it("returns null when the package loads but defines nothing", async () => {
5965
// Studio must still boot. A missing agent surface is not a broken editor.
60-
await expect(loadModelContextPolyfill()).resolves.toBeNull();
66+
const first = loadModelContextPolyfill();
67+
await expect(first).resolves.toBeNull();
68+
69+
expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_failed", {
70+
error_name: "ModelContextMissingError",
71+
});
72+
const retry = loadModelContextPolyfill();
73+
expect(retry).not.toBe(first);
74+
await expect(retry).resolves.toBeNull();
6175
});
6276

63-
it("starts a fresh load after the test seam resets it", async () => {
64-
installModelContext();
65-
const first = loadModelContextPolyfill();
66-
await first;
77+
it("reports a polyfill failure and lets a later mount retry", async () => {
78+
const failure = new TypeError("blocked by policy");
79+
Object.defineProperty(document, "modelContext", {
80+
configurable: true,
81+
get: () => {
82+
throw failure;
83+
},
84+
});
6785

68-
resetModelContextPolyfillForTest();
86+
const first = loadModelContextPolyfill();
87+
await expect(first).resolves.toBeNull();
88+
expect(trackEvent).toHaveBeenCalledWith("webmcp.polyfill_failed", {
89+
error_name: "TypeError",
90+
});
6991

70-
expect(loadModelContextPolyfill()).not.toBe(first);
92+
Reflect.deleteProperty(document, "modelContext");
93+
const modelContext = installModelContext();
94+
const retry = loadModelContextPolyfill();
95+
expect(retry).not.toBe(first);
96+
await expect(retry).resolves.toBe(modelContext);
7197
});
7298
});

packages/studio/src/webmcp/polyfill.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414

1515
import { makeStudioDebugLogger } from "../utils/studioDebug";
16+
import { trackEvent } from "../telemetry/client";
1617
import { getModelContext, type ModelContext } from "./types";
1718

1819
const log = makeStudioDebugLogger("webmcp");
@@ -30,21 +31,30 @@ async function importPolyfill(): Promise<ModelContext | null> {
3031
if (!modelContext) {
3132
// The package loaded but did not define what it promises to define.
3233
log("polyfill", { loaded: true, modelContext: false });
34+
trackEvent("webmcp.polyfill_failed", { error_name: "ModelContextMissingError" });
35+
} else {
36+
trackEvent("webmcp.polyfill_loaded");
3337
}
3438
return modelContext;
3539
} catch (error) {
3640
// A missing agent surface must never break Studio's boot.
3741
log("polyfill", { failed: error instanceof Error ? error.message : String(error) });
42+
trackEvent("webmcp.polyfill_failed", {
43+
error_name: error instanceof Error ? error.name : "NonError",
44+
});
3845
return null;
3946
}
4047
}
4148

4249
export function loadModelContextPolyfill(): Promise<ModelContext | null> {
43-
pending ??= importPolyfill();
44-
return pending;
45-
}
50+
if (pending) return pending;
4651

47-
/** Test seam. Nothing in production resets this. */
48-
export function resetModelContextPolyfillForTest(): void {
49-
pending = null;
52+
const attempt = importPolyfill();
53+
pending = attempt;
54+
// A transient chunk/CSP failure must not disable WebMCP for the rest of the
55+
// tab. Concurrent callers still share this attempt; a later mount may retry.
56+
void attempt.then((modelContext) => {
57+
if (modelContext === null && pending === attempt) pending = null;
58+
});
59+
return pending;
5060
}

packages/studio/src/webmcp/useStudioAgentTools.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgent
77
import type { ModelContext, ModelContextRegisterToolOptions, ModelContextTool } from "./types";
88
import type { StudioLookSnapshot } from "./tools/lookTools";
99

10+
const trackEvent = vi.hoisted(() => vi.fn());
11+
vi.mock("../telemetry/client", () => ({ trackEvent }));
12+
1013
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
1114

1215
let cleanup: (() => void) | null = null;
@@ -64,6 +67,7 @@ function mountTools(deps: StudioAgentToolsDeps) {
6467

6568
beforeEach(() => {
6669
window.localStorage.clear();
70+
trackEvent.mockReset();
6771
});
6872

6973
afterEach(() => {
@@ -83,6 +87,7 @@ describe("useStudioAgentTools", () => {
8387
});
8488

8589
expect(registered.map((tool) => tool.name)).toEqual(["studio_look"]);
90+
expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present");
8691
});
8792

8893
it("does not re-register when the deps object changes identity", async () => {

packages/studio/src/webmcp/useStudioAgentTools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useRef } from "react";
22
import { readStudioUiPreferences } from "../utils/studioUiPreferences";
33
import { makeStudioDebugLogger } from "../utils/studioDebug";
4+
import { trackEvent } from "../telemetry/client";
45
import { loadModelContextPolyfill } from "./polyfill";
56
import { registerStudioTools } from "./registrar";
67
import { runToolBody, type ToolResult } from "./toolResult";
@@ -80,6 +81,7 @@ export function useStudioAgentTools(deps: StudioAgentToolsDeps): void {
8081

8182
void (async () => {
8283
const native: ModelContext | null = getModelContext();
84+
if (native) trackEvent("webmcp.native_present");
8385
// Native browsers never download the polyfill.
8486
const modelContext = native ?? (await loadModelContextPolyfill());
8587
if (!modelContext) {

0 commit comments

Comments
 (0)