diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e706ff34..5417c62c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -410,7 +410,16 @@ 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 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 05df7fdf..50447e92 100644 --- a/apps/claude/skills/mobile-dev/SKILL.md +++ b/apps/claude/skills/mobile-dev/SKILL.md @@ -37,7 +37,10 @@ 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 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 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..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; 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; 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. @@ -65,6 +65,23 @@ 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 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, 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); await opened?.close(); @@ -123,12 +140,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) { @@ -143,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) { @@ -164,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 }); + sessions.set(id, { transport, touched: Date.now(), requests: 0, clears: 0, stores: 0 }); recordBrowserPanelSessions(sessions.size); }, }); @@ -191,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 []; @@ -199,6 +231,21 @@ export async function startPanelPage(connections: SharedTransport, host: Telemet return [heading, ...context.content]; }); }, + clear() { + // 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, kept }; + }, async close() { closing = true; clearInterval(expiry); 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 25a705ea..43f0d510 100644 --- a/packages/core/test/browser-panel.test.ts +++ b/packages/core/test/browser-panel.test.ts @@ -331,16 +331,21 @@ 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"] } }] })); - 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/); @@ -350,12 +355,85 @@ 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(); }); +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; + 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"), /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); + 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 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 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 read(), /No Mobile Dev browser panel/); + assert.equal(await clears(), 1); + // 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); + // 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(); +}); + 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..460e42fd 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."; @@ -34,16 +35,25 @@ 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; 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; + // 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; disposed = true; clearInterval(heartbeat); + clearInterval(clearsPoll); clearTimeout(startup); dark.removeEventListener("change", contextChanged); removeEventListener("pagehide", hide); @@ -70,6 +80,32 @@ 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 > 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 +137,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 }) => { @@ -113,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 }) => { @@ -125,8 +168,9 @@ async function open() { }; bridge.onrequestdisplaymode = async () => ({ mode: "inline" }); contextChanged = () => { - const updated = 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) => { @@ -139,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/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..254e774f 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,23 @@ 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) { + 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; 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..7a46ebe6 --- /dev/null +++ b/packages/ui/test/context-clears.test.ts @@ -0,0 +1,85 @@ +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(); } }; +} + +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); + 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); + // 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 () => { + 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); 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");