From 1bdd2a257647720ea3733c9b7455192ef5323352 Mon Sep 17 00:00:00 2001 From: budet_b Date: Thu, 5 Nov 2026 17:12:38 +0100 Subject: [PATCH 1/2] feat(panel): let the agent clear a browser panel's shared context The agent can read what a browser panel shares (mobile_read_panel_context) but not clear it, so notes it already acted on stay in the panel and are read again. Add mobile_clear_panel_context: the server forgets the tabs' context and counts the clear per tab; the page polls that count (the server keeps no idle stream to push on) and reports it to the panel in the host context, where PanelContext drops the notes it still shows. --- CONTRIBUTING.md | 7 ++- apps/claude/skills/mobile-dev/SKILL.md | 4 +- packages/core/src/browser-panel.ts | 39 ++++++++++++-- packages/core/test/browser-panel.test.ts | 46 ++++++++++++++++- packages/mcp-app/src/browser-panel.ts | 32 ++++++++++-- packages/shared/src/panel-target.ts | 6 +++ packages/ui/src/model-context.ts | 13 +++++ packages/ui/test/context-clears.test.ts | 66 ++++++++++++++++++++++++ packages/ui/test/log-context.test.ts | 1 + 9 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 packages/ui/test/context-clears.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e706ff34..8c2d84f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -410,7 +410,12 @@ sends each `ui/update-model-context` to its own session's `context` endpoint, wh replaces that tab's text and images (up to 16 MiB) and drops them when the tab's session closes. The agent reads them with `mobile_read_panel_context`, most recent tab first, so it sees the user's devices and the logs, screenshots and notes they -add to chat. The opening tool's result points the agent there. Chat messages stay +add to chat. The opening tool's result points the agent there. Once it has acted on +them, the agent calls `mobile_clear_panel_context`: the server forgets them and +counts the clear, the page polls that count from its `context` endpoint every two +seconds (the server keeps no idle stream to push on) and hands it to the panel in the +host context under `mobile-dev/contextClears`, where `PanelContext.hostChanged` drops +the notes the panel still shows. Chat messages stay hidden until the host provides a supported bridge. MCP request/response traffic is bidirectional, but sampling requests an LLM completion rather than appending a user message to the current conversation. Sampling is also diff --git a/apps/claude/skills/mobile-dev/SKILL.md b/apps/claude/skills/mobile-dev/SKILL.md index 05df7fdf..85d62f7e 100644 --- a/apps/claude/skills/mobile-dev/SKILL.md +++ b/apps/claude/skills/mobile-dev/SKILL.md @@ -37,7 +37,9 @@ beside the chat without the user clicking Open. Open only that link: the `baseUr `127.0.0.1` addresses belong to Mobile Dev's backends, not to the panel, so never open or show them. Without a browser, give the user the link. Then read `mobile_read_panel_context` for the devices the user selected there, and again when -they refer to something they added from the panel. +they refer to something they added from the panel. Once you have acted on what they added, +call `mobile_clear_panel_context` so it is not read again; it leaves their device +selection alone. The first open downloads the panel's runtime once, which can take a minute. If it fails, tell the user what the result says. In the Claude desktop app's Code tab you diff --git a/packages/core/src/browser-panel.ts b/packages/core/src/browser-panel.ts index 632816ad..ff81b22d 100644 --- a/packages/core/src/browser-panel.ts +++ b/packages/core/src/browser-panel.ts @@ -19,8 +19,8 @@ export const BROWSER_CONTEXT_MAX_BYTES = 16 * 1024 * 1024; const NO_CONTEXT = "No Mobile Dev browser panel has shared context. Open Mobile Dev, or ask the user to select a device in the panel."; type PanelContent = TextContent | ImageContent; -type BrowserSession = { transport: StreamableHTTPServerTransport; touched: number; requests: number; context?: { content: PanelContent[]; updated: number } }; -type PanelPage = { url(tool: string, target: PanelTarget): string; context(): PanelContent[]; close(): Promise }; +type BrowserSession = { transport: StreamableHTTPServerTransport; touched: number; requests: number; clears: number; context?: { content: PanelContent[]; updated: number } }; +type PanelPage = { url(tool: string, target: PanelTarget): string; context(): PanelContent[]; clear(): number; close(): Promise }; // What a panel would otherwise give an MCP Apps host's model context. Hosts mark // audience and other annotations; the agent reads only text and images. @@ -65,6 +65,17 @@ export function browserPanel({ template = PAGE, always = false }: BrowserPanelOp const content = opened?.context() ?? []; return { content: content.length ? content : [{ type: "text", text: NO_CONTEXT }] }; }); + // The agent cannot reach into the page, so it asks the server to forget what it read. + // The page learns of it by polling the server's count of clears. + server.registerTool("mobile_clear_panel_context", { + title: "Clear Mobile Dev panel context", + description: "Clear the logs, screenshots and screen notes the user added to chat in a Mobile Dev panel opened in a browser, and remove them from the panel. Call it once you have acted on them, so they are not read again. It leaves the user's selected devices alone.", + inputSchema: {}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + }, async () => { + const opened = await page?.catch(() => undefined); + const cleared = opened?.clear() ?? 0; + return { content: [{ type: "text", text: cleared ? `Cleared the shared context of ${cleared} Mobile Dev browser tab${cleared === 1 ? "" : "s"}.` : NO_CONTEXT }] }; + }); return async () => { const opened = await page?.catch(() => undefined); await opened?.close(); @@ -123,12 +134,19 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet reply(response, 404); } - // The page replaces its tab's context, as ui/update-model-context does in a chat host. + // The page replaces its tab's context, as ui/update-model-context does in a chat host, + // and reads how many times the agent has cleared it, so it can drop what it still shows. async function context(request: IncomingMessage, response: ServerResponse) { - if (request.method !== "PUT") return reply(response, 405, { allow: "PUT" }); + if (request.method !== "PUT" && request.method !== "GET") return reply(response, 405, { allow: "GET, PUT" }); const sessionId = request.headers["mcp-session-id"]; const session = typeof sessionId === "string" ? sessions.get(sessionId) : undefined; if (session === undefined) return reply(response, 404); + if (request.method === "GET") { + session.touched = Date.now(); + response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + response.end(JSON.stringify({ clears: session.clears })); + return; + } if (request.headers["content-type"]?.startsWith("application/json") !== true) return reply(response, 415); const body = await readBody(request, BROWSER_CONTEXT_MAX_BYTES); if (body === undefined) { @@ -164,7 +182,7 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet // Per-request SSE supports associated server requests and releases completed streams. sessionIdGenerator: randomUUID, onsessioninitialized: id => { - sessions.set(id, { transport, touched: Date.now(), requests: 0 }); + sessions.set(id, { transport, touched: Date.now(), requests: 0, clears: 0 }); recordBrowserPanelSessions(sessions.size); }, }); @@ -199,6 +217,17 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet return [heading, ...context.content]; }); }, + clear() { + // A clear also counts for a tab with nothing stored: its page may hold notes the + // server never received, and drops them all the same. + let cleared = 0; + for (const session of sessions.values()) { + session.context = undefined; + session.clears++; + cleared++; + } + return cleared; + }, async close() { closing = true; clearInterval(expiry); diff --git a/packages/core/test/browser-panel.test.ts b/packages/core/test/browser-panel.test.ts index 25a705ea..da92f650 100644 --- a/packages/core/test/browser-panel.test.ts +++ b/packages/core/test/browser-panel.test.ts @@ -331,7 +331,10 @@ test("a browser tab's model context reaches the agent and leaves with the tab", assert.equal((await putContext(endpoint, first, "not json")).status, 400); assert.equal((await putContext(endpoint, first, JSON.stringify({ content: [{ type: "image", data: "x", mimeType: "text/html" }] }))).status, 400); assert.equal((await fetch(endpoint, { method: "PUT", body: "{}", headers: { "content-type": "application/json", "mcp-session-id": first, origin: "https://attacker.example" } })).status, 403); - assert.equal((await fetch(endpoint, { headers: { "mcp-session-id": first } })).status, 405); + assert.equal((await fetch(endpoint, { method: "POST", headers: { "mcp-session-id": first } })).status, 405); + assert.equal((await fetch(endpoint)).status, 404); + const clears = async (id: string) => (await (await fetch(endpoint, { headers: { "mcp-session-id": id } })).json() as { clears: number }).clears; + assert.equal(await clears(first), 0); const device = "Active iOS simulator: iPhone 17. Device ID: 11111111-1111-1111-1111-111111111111."; const stored = await putContext(endpoint, first, JSON.stringify({ content: [{ type: "text", text: device, annotations: { audience: ["assistant"] } }] })); @@ -356,6 +359,47 @@ test("a browser tab's model context reaches the agent and leaves with the tab", for (const client of clients) await client.close(); }); +test("the agent clears a browser tab's context and the tab learns of it", async t => { + const directory = await mkdtemp(join(tmpdir(), "browser-panel-")); + const page = join(directory, "browser-panel.html"); + await writeFile(page, ""); + const fake = await fakeBaguette(); + const [hostSide, serverSide] = InMemoryTransport.createLinkedPair(); + const shared = new SharedTransport(serverSide); + const plugin = await createTestPlugin("", { panel: createPanel({ baguette: new Baguette(fake.url), simulatorInput: fakeSimulatorInput() }), features: [browserPanel({ template: pathToFileURL(page) })], connections: shared }); + const host = new Client({ name: "claude-code", version: "1" }); + await plugin.server.connect(shared); + await host.connect(hostSide); + t.after(async () => { await host.close(); await plugin.close(); await fake.close(); await rm(directory, { recursive: true, force: true }); }); + const text = async (name: string) => ((await host.callTool({ name, arguments: {} })).content as Array<{ text: string }>)[0].text; + + assert.match(await text("mobile_clear_panel_context"), /No Mobile Dev browser panel/); + const opened = await host.callTool({ name: "mobile_open_simulator", arguments: {} }); + const link = new URL((opened.content as Array<{ text: string }>)[1].text.match(/http:\/\/127\.0\.0\.1:\d+\/[\w-]+\/\?\S+/)![0]); + const endpoint = new URL("context", link); + const tab = new StreamableHTTPClientTransport(new URL("mcp", link)); + const client = new Client({ name: "tab", version: "1" }); + await client.connect(tab); + const id = tab.sessionId!; + const clears = async () => (await (await fetch(endpoint, { headers: { "mcp-session-id": id } })).json() as { clears: number }).clears; + + const note = "Note on the login screen."; + assert.equal((await putContext(endpoint, id, JSON.stringify({ content: [{ type: "text", text: note }] }))).status, 204); + assert.equal(await clears(), 0); + assert.match(await text("mobile_clear_panel_context"), /Cleared the shared context of 1 Mobile Dev browser tab\./); + assert.match(await text("mobile_read_panel_context"), /No Mobile Dev browser panel/); + assert.equal(await clears(), 1); + // A tab that stored nothing is told as well: its page may still show notes. + await text("mobile_clear_panel_context"); + assert.equal(await clears(), 2); + // Notes added after a clear are kept. + assert.equal((await putContext(endpoint, id, JSON.stringify({ content: [{ type: "text", text: note }] }))).status, 204); + assert.equal(await clears(), 2); + assert.equal((await host.callTool({ name: "mobile_read_panel_context", arguments: {} })).content!.length, 2); + await tab.terminateSession(); + await client.close(); +}); + test("an oversized browser context update is refused without storing it", async t => { const { server, shared } = await sharedServer(); const directory = await mkdtemp(join(tmpdir(), "browser-panel-")); diff --git a/packages/mcp-app/src/browser-panel.ts b/packages/mcp-app/src/browser-panel.ts index 3b8c6847..d58958ad 100644 --- a/packages/mcp-app/src/browser-panel.ts +++ b/packages/mcp-app/src/browser-panel.ts @@ -3,9 +3,10 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { PLUGIN_VERSION } from "@mobile-dev/shared/version.ts"; -import { PANEL_PAGE_META } from "@mobile-dev/shared/panel-target.ts"; +import { CONTEXT_CLEARS, PANEL_PAGE_META } from "@mobile-dev/shared/panel-target.ts"; const dark = matchMedia("(prefers-color-scheme: dark)"); +const CLEARS_POLL_MS = 2000; const DISCONNECTED = "Mobile Dev disconnected from its chat session. Ask the agent to open Mobile Dev again."; const EXPIRED = "Mobile Dev's browser connection expired. Reload this page to reconnect."; @@ -35,15 +36,20 @@ async function open() { }); let bridge: AppBridge | undefined; let heartbeat: ReturnType | undefined; + let clearsPoll: ReturnType | undefined; + let polling = false; let startup: ReturnType | undefined; let disposed = false; let checking = false; let contextChanged = () => {}; + // How many times the agent has cleared this tab's context, as the server counts them. + let clears = 0; function dispose() { if (disposed) return; disposed = true; clearInterval(heartbeat); + clearInterval(clearsPoll); clearTimeout(startup); dark.removeEventListener("change", contextChanged); removeEventListener("pagehide", hide); @@ -70,6 +76,22 @@ async function open() { stopped(expired ? EXPIRED : DISCONNECTED); } finally { checking = false; } } + // The server cannot push to the page (it keeps no idle stream), so the page asks whether the + // agent cleared the context it read. A failed poll is left to the heartbeat to report. + async function pollClears() { + const sessionId = transport.sessionId; + if (disposed || polling || sessionId === undefined) return; + polling = true; + try { + const response = await fetch(new URL("context", location.href), { headers: { "mcp-session-id": sessionId } }); + if (response.status === 404) return stopped(EXPIRED); + if (response.ok === false) return; + const body: unknown = await response.json(); + const count = typeof body === "object" && body !== null && "clears" in body ? body.clears : undefined; + if (typeof count === "number" && count !== clears) { clears = count; contextChanged(); } + } catch { /* the heartbeat reports a stopped server */ } + finally { polling = false; } + } client.onerror = error => { if (error instanceof TypeError) stopped(DISCONNECTED); if (error instanceof StreamableHTTPError && error.code === 404) stopped(EXPIRED); @@ -79,6 +101,7 @@ async function open() { try { await client.connect(transport); heartbeat = setInterval(() => { void check(); }, 30_000); + clearsPoll = setInterval(() => { void pollClears(); }, CLEARS_POLL_MS); const { tools } = await client.listTools(); const tool = tools.find(candidate => candidate.name === toolName); // A panel that does not render in the chat names its page under its own key. @@ -101,7 +124,10 @@ async function open() { const host = document.documentElement.dataset.telemetryHost ?? "other"; // Advertise only capabilities this browser can fulfill. Chat messages need a // host-owned bridge; model context goes to the server, where the agent reads it. - const hostContext = context(); + // Passed to the panel with every host context, so a view can tell a clear from the agent + // from its own state and drop the notes it still shows. + const withClears = (hostContext: McpUiHostContext): McpUiHostContext => ({ ...hostContext, [CONTEXT_CLEARS]: clears }); + const hostContext = withClears(context()); const capabilities = { serverTools: {}, serverResources: {}, openLinks: {}, updateModelContext: { text: {}, image: {} } }; bridge = new AppBridge(client, { name: host, version: PLUGIN_VERSION }, capabilities, { hostContext }); bridge.onupdatemodelcontext = async ({ content }) => { @@ -125,7 +151,7 @@ async function open() { }; bridge.onrequestdisplaymode = async () => ({ mode: "inline" }); contextChanged = () => { - const updated = context(); + const updated = withClears(context()); bridge?.setHostContext(updated); }; dark.addEventListener("change", contextChanged); diff --git a/packages/shared/src/panel-target.ts b/packages/shared/src/panel-target.ts index 0acd009e..cb4faa42 100644 --- a/packages/shared/src/panel-target.ts +++ b/packages/shared/src/panel-target.ts @@ -23,3 +23,9 @@ export function panelTargetPlatform(target: PanelTarget): "ios" | "android" | un * `{ resourceUri }` that MCP Apps hosts ignore and the browser panel reads. */ export const PANEL_PAGE_META = "mobile-dev/panel"; + +/** + * The host context key under which a browser panel reports how many times the agent has + * cleared its context, so the view can drop the notes it still shows. + */ +export const CONTEXT_CLEARS = "mobile-dev/contextClears"; diff --git a/packages/ui/src/model-context.ts b/packages/ui/src/model-context.ts index 3f3983d5..1250d90f 100644 --- a/packages/ui/src/model-context.ts +++ b/packages/ui/src/model-context.ts @@ -1,5 +1,6 @@ import { chatMessage } from "./chat-message.ts"; import type { HostAttachmentsSource, HostBridge } from "./host-bridge.ts"; +import { CONTEXT_CLEARS } from "@mobile-dev/shared/panel-target.ts"; import type { SimulatorDevice } from "@mobile-dev/shared/protocol.ts"; import type { StackedLog } from "@mobile-dev/shared/logs.ts"; import { formatLogContext, logKey } from "@mobile-dev/shared/logs.ts"; @@ -42,6 +43,7 @@ export class PanelContext { private pendingAnnotations = new Set(); private resuming?: Promise; private selectionKey?: string; + private seenClears?: number; private readonly app: HostBridge; private readonly attachments: HostAttachmentsSource; private contextReady = false; @@ -215,6 +217,17 @@ export class PanelContext { hostChanged() { if (!this.usesHostAttachments) { + // The agent cleared what it read: drop the same here, so the panel and the server agree. + const clears = this.app.getHostContext()?.[CONTEXT_CLEARS]; + if (typeof clears === "number" && clears !== this.seenClears) { + const first = this.seenClears === undefined; + this.seenClears = clears; + // The first value is the state the panel opened in, not a clear made while it was open. + if (!first && this.attachmentCount) { + this.attached = undefined; this.screenshots = []; this.annotations = []; this.pendingAnnotations.clear(); + this.revision++; this.changed(); + } + } const ready = this.canAttach; if (ready && !this.contextReady) void this.publish().catch(() => {}); this.contextReady = ready; diff --git a/packages/ui/test/context-clears.test.ts b/packages/ui/test/context-clears.test.ts new file mode 100644 index 00000000..3ea28065 --- /dev/null +++ b/packages/ui/test/context-clears.test.ts @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { App } from "@modelcontextprotocol/ext-apps"; +import type { ScreenAnnotation } from "../../shared/src/screen-annotations.ts"; +import { CONTEXT_CLEARS } from "../../shared/src/panel-target.ts"; +import { PanelContext } from "../src/model-context.ts"; + +const annotation: ScreenAnnotation = { + id: "note", number: 1, text: "Make this smaller.", + simulator: { udid: "test-device", name: "iPhone", runtime: "iOS 26", state: "Booted", platform: "ios" }, + component: { name: "Title", depth: 1, bounds: { x: 0, y: 0, width: 10, height: 10 } }, + point: { x: 1, y: 1 }, screen: { width: 402, height: 874, units: "points" }, + screenshot: { id: "capture", data: "local-only", capturedAt: "2026-10-01T10:00:00Z" }, +}; + +// A browser panel: no host attachments, model context goes to the server. +function browser() { + let hostContext: Record = {}; + const updates: Parameters[0][] = []; + const context = new PanelContext({ + getHostCapabilities: () => ({ updateModelContext: { text: {}, image: {} } }), + getHostContext: () => hostContext, + async updateModelContext(params: Parameters[0]) { updates.push(params); return {}; }, + } as unknown as App); + return { context, updates, host(clears?: number) { hostContext = clears === undefined ? {} : { [CONTEXT_CLEARS]: clears }; context.hostChanged(); } }; +} + +test("a clear from the agent drops the notes a browser panel still shows", async () => { + const f = browser(); + f.host(0); + await f.context.attachAnnotation(annotation); + assert.equal(f.context.attachmentCount, 1); + f.host(0); + assert.equal(f.context.attachmentCount, 1, "an unchanged count keeps the notes"); + f.host(1); + assert.equal(f.context.attachmentCount, 0); + assert.equal(f.context.screenAnnotations.length, 0); +}); + +test("the count a panel opens with is not a clear", async () => { + const f = browser(); + f.host(3); + await f.context.attachAnnotation(annotation); + assert.equal(f.context.attachmentCount, 1); + f.host(3); + assert.equal(f.context.attachmentCount, 1); + f.host(4); + assert.equal(f.context.attachmentCount, 0); +}); + +test("notes added after a clear are kept", async () => { + const f = browser(); + f.host(0); + f.host(1); + await f.context.attachAnnotation(annotation); + f.host(1); + assert.equal(f.context.attachmentCount, 1); +}); + +test("a host without the count leaves the notes alone", async () => { + const f = browser(); + f.host(); + await f.context.attachAnnotation(annotation); + f.host(); + assert.equal(f.context.attachmentCount, 1); +}); diff --git a/packages/ui/test/log-context.test.ts b/packages/ui/test/log-context.test.ts index 34e05770..79c57ace 100644 --- a/packages/ui/test/log-context.test.ts +++ b/packages/ui/test/log-context.test.ts @@ -345,6 +345,7 @@ test("standard context supports logs, local removal and complete chat messages", const messages: Parameters[0][] = []; const context = new PanelContext({ getHostCapabilities: () => ({ message: { text: {} }, updateModelContext: { text: {}, structuredContent: {} } }), + getHostContext: () => ({}), async updateModelContext(params) { updates.push(params); return {}; }, async sendMessage(params) { messages.push(params); return {}; }, } as App, () => undefined); From 9bc02b8fb8de931913ffac7e0a73fda6aa6834b2 Mon Sep 17 00:00:00 2001 From: budet_b Date: Mon, 9 Nov 2026 18:36:12 +0100 Subject: [PATCH 2/2] fix(panel): clear only read context and keep the shared devices mobile_clear_panel_context destroyed notes the agent never saw and left the device selection unreadable until the user changed it: - The server forgets a tab's context only when it was read since its last store; newer notes are kept and the result says so. A clear with no connected tab or no shared content says there is nothing to clear instead of reusing the read tool's guidance. - Storing context returns the count it was stored under; the page polls only while its tab has stored content and ignores counts its content was stored under, so a clear racing a fresh note no longer drops it. - The panel republishes after dropping cleared notes, so the device selection survives the clear, and host context updates wait for the bridge to connect. - The tool no longer advertises idempotence, the server counts cleared context updates and the panel counts ui.context.agent_clear. Co-Authored-By: Claude Fable 5 --- CONTRIBUTING.md | 14 ++++-- apps/claude/skills/mobile-dev/SKILL.md | 3 +- packages/core/src/browser-panel.ts | 42 ++++++++++++----- packages/core/src/telemetry.ts | 4 +- packages/core/test/browser-panel.test.ts | 60 +++++++++++++++++++----- packages/mcp-app/src/browser-panel.ts | 27 +++++++++-- packages/ui/src/model-context.ts | 12 +++-- packages/ui/test/context-clears.test.ts | 19 ++++++++ tooling/hosts/browser-panel.spec.ts | 15 ++++++ 9 files changed, 156 insertions(+), 40 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c2d84f9..5417c62c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -411,11 +411,15 @@ replaces that tab's text and images (up to 16 MiB) and drops them when the tab's session closes. The agent reads them with `mobile_read_panel_context`, most recent tab first, so it sees the user's devices and the logs, screenshots and notes they add to chat. The opening tool's result points the agent there. Once it has acted on -them, the agent calls `mobile_clear_panel_context`: the server forgets them and -counts the clear, the page polls that count from its `context` endpoint every two -seconds (the server keeps no idle stream to push on) and hands it to the panel in the -host context under `mobile-dev/contextClears`, where `PanelContext.hostChanged` drops -the notes the panel still shows. Chat messages stay +them, the agent calls `mobile_clear_panel_context`: the server forgets what the agent +has read — a tab whose context was stored or changed since the last read keeps it — +and counts the clear. Storing context returns the current count, and while a tab has +stored content its page polls that count from its `context` endpoint every two +seconds (the server keeps no idle stream to push on), ignoring counts its content was +stored under, and hands it to the panel in the host context under +`mobile-dev/contextClears`, where `PanelContext.hostChanged` drops the notes the +panel still shows and shares the devices again, so the user's selection survives the +clear. Chat messages stay hidden until the host provides a supported bridge. MCP request/response traffic is bidirectional, but sampling requests an LLM completion rather than appending a user message to the current conversation. Sampling is also diff --git a/apps/claude/skills/mobile-dev/SKILL.md b/apps/claude/skills/mobile-dev/SKILL.md index 85d62f7e..50447e92 100644 --- a/apps/claude/skills/mobile-dev/SKILL.md +++ b/apps/claude/skills/mobile-dev/SKILL.md @@ -38,7 +38,8 @@ beside the chat without the user clicking Open. Open only that link: the `baseUr or show them. Without a browser, give the user the link. Then read `mobile_read_panel_context` for the devices the user selected there, and again when they refer to something they added from the panel. Once you have acted on what they added, -call `mobile_clear_panel_context` so it is not read again; it leaves their device +call `mobile_clear_panel_context` so it is not read again; it only forgets what you +have read — notes added since your last read stay — and leaves their device selection alone. The first open downloads the panel's runtime once, which can take a minute. If it diff --git a/packages/core/src/browser-panel.ts b/packages/core/src/browser-panel.ts index ff81b22d..ade8b9a5 100644 --- a/packages/core/src/browser-panel.ts +++ b/packages/core/src/browser-panel.ts @@ -19,8 +19,8 @@ export const BROWSER_CONTEXT_MAX_BYTES = 16 * 1024 * 1024; const NO_CONTEXT = "No Mobile Dev browser panel has shared context. Open Mobile Dev, or ask the user to select a device in the panel."; type PanelContent = TextContent | ImageContent; -type BrowserSession = { transport: StreamableHTTPServerTransport; touched: number; requests: number; clears: number; context?: { content: PanelContent[]; updated: number } }; -type PanelPage = { url(tool: string, target: PanelTarget): string; context(): PanelContent[]; clear(): number; close(): Promise }; +type BrowserSession = { transport: StreamableHTTPServerTransport; touched: number; requests: number; clears: number; stores: number; read?: number; context?: { content: PanelContent[]; updated: number } }; +type PanelPage = { url(tool: string, target: PanelTarget): string; context(): PanelContent[]; clear(): { cleared: number; kept: number }; close(): Promise }; // What a panel would otherwise give an MCP Apps host's model context. Hosts mark // audience and other annotations; the agent reads only text and images. @@ -69,12 +69,18 @@ export function browserPanel({ template = PAGE, always = false }: BrowserPanelOp // The page learns of it by polling the server's count of clears. server.registerTool("mobile_clear_panel_context", { title: "Clear Mobile Dev panel context", - description: "Clear the logs, screenshots and screen notes the user added to chat in a Mobile Dev panel opened in a browser, and remove them from the panel. Call it once you have acted on them, so they are not read again. It leaves the user's selected devices alone.", - inputSchema: {}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + description: "Clear the logs, screenshots and screen notes the user added to chat in a Mobile Dev panel opened in a browser, and remove them from the panel. Call it once you have acted on them, so they are not read again. It only forgets what mobile_read_panel_context has returned: notes added or changed since your last read stay. It leaves the user's selected devices alone.", + inputSchema: {}, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, }, async () => { const opened = await page?.catch(() => undefined); - const cleared = opened?.clear() ?? 0; - return { content: [{ type: "text", text: cleared ? `Cleared the shared context of ${cleared} Mobile Dev browser tab${cleared === 1 ? "" : "s"}.` : NO_CONTEXT }] }; + const { cleared, kept } = opened?.clear() ?? { cleared: 0, kept: 0 }; + const tabs = (count: number) => `${count} Mobile Dev browser tab${count === 1 ? "" : "s"}`; + const text = cleared + ? `Cleared the shared context of ${tabs(cleared)}.${kept ? ` Kept ${tabs(kept)} whose notes changed since your last read; call mobile_read_panel_context to see them.` : ""}` + : kept + ? `Nothing cleared: the shared context of ${tabs(kept)} changed since your last read. Call mobile_read_panel_context to see it, then clear again.` + : "No Mobile Dev browser tab has shared context, so there is nothing to clear."; + return { content: [{ type: "text", text }] }; }); return async () => { const opened = await page?.catch(() => undefined); @@ -161,9 +167,12 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet return reply(response, 400); } session.context = { content: parsed.data.content, updated: Date.now() }; + session.stores++; session.touched = Date.now(); recordBrowserPanelContext("stored", body.byteLength); - reply(response, 204); + // The count the content was stored under: a clear up to it cannot have dropped this content. + response.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }); + response.end(JSON.stringify({ clears: session.clears })); } async function mcp(request: IncomingMessage, response: ServerResponse) { @@ -182,7 +191,7 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet // Per-request SSE supports associated server requests and releases completed streams. sessionIdGenerator: randomUUID, onsessioninitialized: id => { - sessions.set(id, { transport, touched: Date.now(), requests: 0, clears: 0 }); + sessions.set(id, { transport, touched: Date.now(), requests: 0, clears: 0, stores: 0 }); recordBrowserPanelSessions(sessions.size); }, }); @@ -209,7 +218,12 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet }, context() { // The most recently updated tab comes first; each open tab keeps its own. - const shared = Array.from(sessions.values()).flatMap(session => session.context ? [session.context] : []); + const shared = Array.from(sessions.values()).flatMap(session => { + if (session.context === undefined) return []; + // clear() forgets only what the agent has read; mark this snapshot as read. + session.read = session.stores; + return [session.context]; + }); shared.sort((a, b) => b.updated - a.updated); return shared.flatMap((context, index) => { if (context.content.length === 0) return []; @@ -218,15 +232,19 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet }); }, clear() { - // A clear also counts for a tab with nothing stored: its page may hold notes the - // server never received, and drops them all the same. + // Forget only what the agent has read: a tab whose notes were stored or changed + // since the last read holds content the agent never saw, so it keeps them. let cleared = 0; + let kept = 0; for (const session of sessions.values()) { + if (session.context === undefined || session.context.content.length === 0) continue; + if (session.read !== session.stores) { kept++; continue; } session.context = undefined; session.clears++; cleared++; + recordBrowserPanelContext("cleared"); } - return cleared; + return { cleared, kept }; }, async close() { closing = true; diff --git a/packages/core/src/telemetry.ts b/packages/core/src/telemetry.ts index 6886f723..280552ea 100644 --- a/packages/core/src/telemetry.ts +++ b/packages/core/src/telemetry.ts @@ -26,8 +26,8 @@ export function recordBrowserPanelSessions(active: number) { Sentry.metrics.gauge("browser_panel.sessions", active, { attributes: { component: "server" } }); } -/** Counts browser panel context updates by outcome; sizes only for stored updates, never their content. */ -export function recordBrowserPanelContext(outcome: "stored" | "invalid" | "too_large", bytes?: number) { +/** Counts browser panel context updates by outcome, cleared-by-the-agent included; sizes only for stored updates, never their content. */ +export function recordBrowserPanelContext(outcome: "stored" | "invalid" | "too_large" | "cleared", bytes?: number) { if (process.env.MOBILE_DEV_TELEMETRY === "off") return; const attributes = { component: "server", outcome }; Sentry.metrics.count("browser_panel.context_update", 1, { attributes }); diff --git a/packages/core/test/browser-panel.test.ts b/packages/core/test/browser-panel.test.ts index da92f650..43f0d510 100644 --- a/packages/core/test/browser-panel.test.ts +++ b/packages/core/test/browser-panel.test.ts @@ -338,12 +338,14 @@ test("a browser tab's model context reaches the agent and leaves with the tab", const device = "Active iOS simulator: iPhone 17. Device ID: 11111111-1111-1111-1111-111111111111."; const stored = await putContext(endpoint, first, JSON.stringify({ content: [{ type: "text", text: device, annotations: { audience: ["assistant"] } }] })); - assert.equal(stored.status, 204); + assert.equal(stored.status, 200); + // The response tells the page which count its content was stored under. + assert.deepEqual(await stored.json(), { clears: 0 }); assert.deepEqual(await read(), [{ type: "text", text: "Mobile Dev browser panel." }, { type: "text", text: device }]); await new Promise(resolve => setTimeout(resolve, 5)); const screenshot = { type: "image", data: "aGVsbG8=", mimeType: "image/png" }; - assert.equal((await putContext(endpoint, second, JSON.stringify({ content: [screenshot] }))).status, 204); + assert.equal((await putContext(endpoint, second, JSON.stringify({ content: [screenshot] }))).status, 200); const both = await read(); assert.equal(both.length, 4); assert.match(both[0].text!, /tab 1 of 2/); @@ -353,7 +355,7 @@ test("a browser tab's model context reaches the agent and leaves with the tab", // Closing a tab removes what it shared; an empty update clears without closing. await tabs[1].terminateSession(); assert.deepEqual((await read()).map(item => item.text), ["Mobile Dev browser panel.", device]); - assert.equal((await putContext(endpoint, first, JSON.stringify({ content: [] }))).status, 204); + assert.equal((await putContext(endpoint, first, JSON.stringify({ content: [] }))).status, 200); assert.match((await read())[0].text!, /No Mobile Dev browser panel/); await tabs[0].terminateSession(); for (const client of clients) await client.close(); @@ -372,8 +374,9 @@ test("the agent clears a browser tab's context and the tab learns of it", async await host.connect(hostSide); t.after(async () => { await host.close(); await plugin.close(); await fake.close(); await rm(directory, { recursive: true, force: true }); }); const text = async (name: string) => ((await host.callTool({ name, arguments: {} })).content as Array<{ text: string }>)[0].text; + const read = async () => ((await host.callTool({ name: "mobile_read_panel_context", arguments: {} })).content as Array<{ text?: string }>).map(item => item.text ?? "").join("\n"); - assert.match(await text("mobile_clear_panel_context"), /No Mobile Dev browser panel/); + assert.match(await text("mobile_clear_panel_context"), /nothing to clear/); const opened = await host.callTool({ name: "mobile_open_simulator", arguments: {} }); const link = new URL((opened.content as Array<{ text: string }>)[1].text.match(/http:\/\/127\.0\.0\.1:\d+\/[\w-]+\/\?\S+/)![0]); const endpoint = new URL("context", link); @@ -381,21 +384,52 @@ test("the agent clears a browser tab's context and the tab learns of it", async const client = new Client({ name: "tab", version: "1" }); await client.connect(tab); const id = tab.sessionId!; - const clears = async () => (await (await fetch(endpoint, { headers: { "mcp-session-id": id } })).json() as { clears: number }).clears; + const count = async (session: string) => (await (await fetch(endpoint, { headers: { "mcp-session-id": session } })).json() as { clears: number }).clears; + const clears = () => count(id); + const put = (session: string, text: string) => putContext(endpoint, session, JSON.stringify({ content: [{ type: "text", text }] })); const note = "Note on the login screen."; - assert.equal((await putContext(endpoint, id, JSON.stringify({ content: [{ type: "text", text: note }] }))).status, 204); + assert.equal((await put(id, note)).status, 200); assert.equal(await clears(), 0); + // Unread notes stay: the tool clears only what mobile_read_panel_context returned. + assert.match(await text("mobile_clear_panel_context"), /Nothing cleared/); + assert.equal(await clears(), 0); + assert.match(await read(), /login screen/); assert.match(await text("mobile_clear_panel_context"), /Cleared the shared context of 1 Mobile Dev browser tab\./); - assert.match(await text("mobile_read_panel_context"), /No Mobile Dev browser panel/); + assert.match(await read(), /No Mobile Dev browser panel/); assert.equal(await clears(), 1); - // A tab that stored nothing is told as well: its page may still show notes. - await text("mobile_clear_panel_context"); - assert.equal(await clears(), 2); - // Notes added after a clear are kept. - assert.equal((await putContext(endpoint, id, JSON.stringify({ content: [{ type: "text", text: note }] }))).status, 204); + // With nothing stored there is nothing to clear, and the tab is not told to drop anything. + assert.match(await text("mobile_clear_panel_context"), /nothing to clear/); + assert.equal(await clears(), 1); + // A note changed after the last read is kept; reading again makes it clearable. + assert.equal((await put(id, note)).status, 200); + await read(); + const stored = await put(id, "A newer note."); + assert.equal(stored.status, 200); + assert.deepEqual(await stored.json(), { clears: 1 }); + assert.match(await text("mobile_clear_panel_context"), /Nothing cleared/); + assert.equal(await clears(), 1); + assert.match(await read(), /newer note/); + assert.match(await text("mobile_clear_panel_context"), /Cleared the shared context of 1/); assert.equal(await clears(), 2); - assert.equal((await host.callTool({ name: "mobile_read_panel_context", arguments: {} })).content!.length, 2); + // A tab whose notes changed since the read is kept while the read one clears. + const tab2 = new StreamableHTTPClientTransport(new URL("mcp", link)); + const client2 = new Client({ name: "tab-2", version: "1" }); + await client2.connect(tab2); + const id2 = tab2.sessionId!; + assert.equal((await put(id, note)).status, 200); + assert.equal((await put(id2, "Second tab note.")).status, 200); + await read(); + assert.equal((await put(id2, "Second tab, changed.")).status, 200); + assert.match(await text("mobile_clear_panel_context"), /Cleared the shared context of 1 Mobile Dev browser tab\. Kept 1 Mobile Dev browser tab whose notes changed since your last read/); + assert.equal(await clears(), 3); + assert.equal(await count(id2), 0, "the kept tab is not told to drop anything"); + assert.match(await read(), /Second tab, changed/); + // A repeat can drop notes added in between, so the tool must not advertise idempotence. + const { tools } = await host.listTools(); + assert.equal(tools.find(tool => tool.name === "mobile_clear_panel_context")?.annotations?.idempotentHint, false); + await tab2.terminateSession(); + await client2.close(); await tab.terminateSession(); await client.close(); }); diff --git a/packages/mcp-app/src/browser-panel.ts b/packages/mcp-app/src/browser-panel.ts index d58958ad..460e42fd 100644 --- a/packages/mcp-app/src/browser-panel.ts +++ b/packages/mcp-app/src/browser-panel.ts @@ -35,6 +35,7 @@ async function open() { fetch: (url, options) => fetch(url, { ...options, keepalive: options?.method === "DELETE" }), }); let bridge: AppBridge | undefined; + let bridgeReady = false; let heartbeat: ReturnType | undefined; let clearsPoll: ReturnType | undefined; let polling = false; @@ -44,6 +45,9 @@ async function open() { let contextChanged = () => {}; // How many times the agent has cleared this tab's context, as the server counts them. let clears = 0; + // The count this tab's current content was stored under. A clear the server made before + // the store cannot have dropped that content, so the poll ignores counts up to it. + let storedClears = 0; function dispose() { if (disposed) return; @@ -88,10 +92,20 @@ async function open() { if (response.ok === false) return; const body: unknown = await response.json(); const count = typeof body === "object" && body !== null && "clears" in body ? body.clears : undefined; - if (typeof count === "number" && count !== clears) { clears = count; contextChanged(); } + if (typeof count === "number" && count > storedClears && count !== clears) { clears = count; contextChanged(); } } catch { /* the heartbeat reports a stopped server */ } finally { polling = false; } } + // The agent can only clear content the server holds, so the page polls only while this + // tab has stored some (the server keeps no idle stream to push on). + function watchClears(shared: boolean) { + if (disposed || shared === false) { + clearInterval(clearsPoll); + clearsPoll = undefined; + return; + } + clearsPoll ??= setInterval(() => { void pollClears(); }, CLEARS_POLL_MS); + } client.onerror = error => { if (error instanceof TypeError) stopped(DISCONNECTED); if (error instanceof StreamableHTTPError && error.code === 404) stopped(EXPIRED); @@ -101,7 +115,6 @@ async function open() { try { await client.connect(transport); heartbeat = setInterval(() => { void check(); }, 30_000); - clearsPoll = setInterval(() => { void pollClears(); }, CLEARS_POLL_MS); const { tools } = await client.listTools(); const tool = tools.find(candidate => candidate.name === toolName); // A panel that does not render in the chat names its page under its own key. @@ -139,6 +152,10 @@ async function open() { }); if (response.status === 404) stopped(EXPIRED); if (response.ok === false) throw new Error(`Mobile Dev could not share this with the chat (${response.status}).`); + const body: unknown = await response.json().catch(() => undefined); + const count = typeof body === "object" && body !== null && "clears" in body ? body.clears : undefined; + if (typeof count === "number" && count > storedClears) storedClears = count; + watchClears((content ?? []).length > 0); return {}; }; bridge.onopenlink = async ({ url }) => { @@ -151,8 +168,9 @@ async function open() { }; bridge.onrequestdisplaymode = async () => ({ mode: "inline" }); contextChanged = () => { - const updated = withClears(context()); - bridge?.setHostContext(updated); + // setHostContext notifies over the bridge, which must be connected first. + if (bridgeReady === false || disposed) return; + bridge?.setHostContext(withClears(context())); }; dark.addEventListener("change", contextChanged); const initialized = new Promise((resolve, reject) => { @@ -165,6 +183,7 @@ async function open() { const frameWindow = frame.contentWindow!; const frameTransport = new PostMessageTransport(frameWindow, frameWindow); await bridge.connect(frameTransport); + bridgeReady = true; frame.srcdoc = content.text; await initialized; await bridge.sendToolInput({ arguments: target }); diff --git a/packages/ui/src/model-context.ts b/packages/ui/src/model-context.ts index 1250d90f..254e774f 100644 --- a/packages/ui/src/model-context.ts +++ b/packages/ui/src/model-context.ts @@ -223,9 +223,15 @@ export class PanelContext { const first = this.seenClears === undefined; this.seenClears = clears; // The first value is the state the panel opened in, not a clear made while it was open. - if (!first && this.attachmentCount) { - this.attached = undefined; this.screenshots = []; this.annotations = []; this.pendingAnnotations.clear(); - this.revision++; this.changed(); + if (!first) { + countUiEvent("ui.context.agent_clear"); + if (this.attachmentCount) { + this.attached = undefined; this.screenshots = []; this.annotations = []; this.pendingAnnotations.clear(); + this.revision++; this.changed(); + } + // The clear wiped this tab's whole stored context; share the devices again so + // the selection it promises to leave alone survives it. + void this.publish().catch(() => {}); } } const ready = this.canAttach; diff --git a/packages/ui/test/context-clears.test.ts b/packages/ui/test/context-clears.test.ts index 3ea28065..7a46ebe6 100644 --- a/packages/ui/test/context-clears.test.ts +++ b/packages/ui/test/context-clears.test.ts @@ -25,6 +25,8 @@ function browser() { return { context, updates, host(clears?: number) { hostContext = clears === undefined ? {} : { [CONTEXT_CLEARS]: clears }; context.hostChanged(); } }; } +const flush = () => new Promise(resolve => setTimeout(resolve)); + test("a clear from the agent drops the notes a browser panel still shows", async () => { const f = browser(); f.host(0); @@ -35,6 +37,23 @@ test("a clear from the agent drops the notes a browser panel still shows", async f.host(1); assert.equal(f.context.attachmentCount, 0); assert.equal(f.context.screenAnnotations.length, 0); + // The drop replaces this tab's cleared server context, so the devices survive the clear. + await flush(); + const last = JSON.stringify(f.updates.at(-1)!.content); + assert.match(last, /Mobile Dev has no selected simulator/); + assert.doesNotMatch(last, /Make this smaller/); +}); + +test("a clear with no notes still shares the devices again", async () => { + const f = browser(); + f.host(0); + f.context.selectSimulator(annotation.simulator); + await flush(); + const published = f.updates.length; + f.host(1); + await flush(); + assert.equal(f.updates.length, published + 1); + assert.match(JSON.stringify(f.updates.at(-1)!.content), /test-device/); }); test("the count a panel opens with is not a clear", async () => { diff --git a/tooling/hosts/browser-panel.spec.ts b/tooling/hosts/browser-panel.spec.ts index 38d86f88..80486006 100644 --- a/tooling/hosts/browser-panel.spec.ts +++ b/tooling/hosts/browser-panel.spec.ts @@ -97,6 +97,21 @@ test.describe("Browser panels", { tag: "@browser" }, () => { } }); + test("an agent clear leaves the panel's device context readable", async ({ page, browserPanel }) => { + const read = async () => JSON.stringify((await browserPanel.host.callTool({ name: "mobile_read_panel_context", arguments: {} })).content); + await page.goto(browserPanel.url); + await expect(page.frameLocator("iframe").locator("#ios-panel")).toBeVisible(); + await expect.poll(read).toContain("Mobile Dev browser panel."); + // The clear only forgets what was read, so pair each attempt with a fresh read. + await expect.poll(async () => { + await read(); + const result = await browserPanel.host.callTool({ name: "mobile_clear_panel_context", arguments: {} }); + return JSON.stringify(result.content); + }).toContain("Cleared the shared context of 1"); + // The page learns of the clear from its poll and the panel shares the devices again. + await expect.poll(read, { timeout: 10_000 }).toContain("Mobile Dev browser panel."); + }); + test("an unrelated tool cannot run from a panel link", async ({ page, browserPanel }) => { const url = new URL(browserPanel.url); url.searchParams.set("tool", "mobile_show_devices");