Skip to content

Commit 5a0b0cb

Browse files
committed
fix(preview): keep snapshots slim unless the agent asks
preview_snapshot dumped the accessibility tree, console, and network on every inspect. preview_wait_for also matched sidebar chrome, so agents thought a page was ready when only a nav label matched. Snapshots default to URL, main-landmark text, visible controls, and a PNG. AX, console, and network load only when include asks. Wait searches main by default and requires a visible locator.
1 parent cd096b9 commit 5a0b0cb

8 files changed

Lines changed: 163 additions & 39 deletions

File tree

apps/desktop/src/ipc/methods/preview.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
DesktopPreviewAnnotationThemeInputSchema,
33
DesktopPreviewArtifactInputSchema,
44
DesktopPreviewAutomationClickInputSchema,
5+
DesktopPreviewAutomationSnapshotInputSchema,
56
DesktopPreviewAutomationEvaluateInputSchema,
67
DesktopPreviewAutomationPressInputSchema,
78
DesktopPreviewAutomationScrollInputSchema,
@@ -281,11 +282,11 @@ export const automationStatus = DesktopIpc.makeIpcMethod({
281282

282283
export const automationSnapshot = DesktopIpc.makeIpcMethod({
283284
channel: IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL,
284-
payload: DesktopPreviewTabInputSchema,
285+
payload: DesktopPreviewAutomationSnapshotInputSchema,
285286
result: PreviewAutomationSnapshot,
286-
handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId }) {
287+
handler: Effect.fn("desktop.ipc.preview.automationSnapshot")(function* ({ tabId, include }) {
287288
const manager = yield* PreviewManager.PreviewManager;
288-
return yield* manager.automationSnapshot(tabId);
289+
return yield* manager.automationSnapshot(tabId, include ?? []);
289290
}),
290291
});
291292

apps/desktop/src/preload.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,11 @@ contextBridge.exposeInMainWorld("desktopBridge", {
230230
automation: {
231231
status: (tabId) =>
232232
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }),
233-
snapshot: (tabId) =>
234-
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }),
233+
snapshot: (tabId, include) =>
234+
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, {
235+
tabId,
236+
...(include === undefined ? {} : { include }),
237+
}),
235238
click: (tabId, input) =>
236239
ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }),
237240
type: (tabId, input) =>

apps/desktop/src/preview/Manager.ts

Lines changed: 87 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
PreviewAutomationNetworkEntry,
2626
PreviewAutomationScrollInput,
2727
PreviewAutomationSnapshot,
28+
PreviewAutomationSnapshotInclude,
2829
PreviewAutomationStatus,
2930
PreviewAutomationTypeInput,
3031
PreviewAutomationWaitForInput,
@@ -2863,11 +2864,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
28632864
});
28642865

28652866
const captureAutomationSnapshot = Effect.fn("PreviewManager.captureAutomationSnapshot")(
2866-
function* (tabId: string, wc: Electron.WebContents, send: SendCommand) {
2867-
yield* Effect.all([send("Runtime.enable"), send("Accessibility.enable")], {
2868-
concurrency: 2,
2869-
discard: true,
2870-
});
2867+
function* (
2868+
tabId: string,
2869+
wc: Electron.WebContents,
2870+
send: SendCommand,
2871+
include: ReadonlyArray<PreviewAutomationSnapshotInclude> = [],
2872+
) {
2873+
const includeAx = include.includes("ax");
2874+
const includeConsole = include.includes("console");
2875+
const includeNetwork = include.includes("network");
2876+
yield* send("Runtime.enable");
2877+
if (includeAx) yield* send("Accessibility.enable");
28712878
const page = yield* evaluateWithDebugger<{
28722879
url: string;
28732880
title: string;
@@ -2905,44 +2912,73 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
29052912
const rect = element.getBoundingClientRect();
29062913
return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
29072914
};
2915+
const clickable = (element) => {
2916+
if (element.matches("a[href],button,input,textarea,select,[role],[tabindex]")) return true;
2917+
const role = element.getAttribute("role");
2918+
if (role === "row" || role === "gridcell" || role === "option") return true;
2919+
const style = getComputedStyle(element);
2920+
return style.cursor === "pointer" && (element.tagName === "TR" || element.tagName === "TD" || element.tagName === "DIV");
2921+
};
2922+
const seen = new Set();
29082923
const elements = Array.from(document.querySelectorAll(
2909-
"a[href],button,input,textarea,select,[role],[tabindex]"
2910-
)).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
2924+
"a[href],button,input,textarea,select,[role],[tabindex],[role=row],tr,[role=gridcell]"
2925+
)).filter((element) => {
2926+
if (!visible(element) || !clickable(element) || seen.has(element)) return false;
2927+
seen.add(element);
2928+
return true;
2929+
}).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element, index) => {
29112930
const rect = element.getBoundingClientRect();
29122931
return {
2932+
id: "e" + (index + 1),
29132933
tag: element.tagName.toLowerCase(),
29142934
role: element.getAttribute("role"),
2915-
name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "",
2935+
name: (element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "").slice(0, 160),
29162936
selector: selectorFor(element),
29172937
x: rect.x,
29182938
y: rect.y,
29192939
width: rect.width,
29202940
height: rect.height
29212941
};
29222942
});
2943+
const main = document.querySelector("main");
29232944
return {
29242945
url: location.href,
29252946
title: document.title,
29262947
loading: document.readyState !== "complete",
2927-
visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
2948+
visibleText: ((main && main.innerText) || document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
29282949
interactiveElements: elements
29292950
};
29302951
})()`,
29312952
true,
29322953
);
2933-
const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
2934-
send("Accessibility.getFullAXTree"),
2935-
attemptPromise(
2936-
{
2937-
operation: "automationSnapshot.capturePage",
2938-
tabId,
2939-
webContentsId: wc.id,
2940-
},
2941-
() => wc.capturePage(),
2954+
const accessibility = includeAx ? yield* send("Accessibility.getFullAXTree") : undefined;
2955+
const [diagnostics, timelines] = yield* Effect.all(
2956+
[Ref.get(diagnosticsRef), Ref.get(actionTimelineRef)],
2957+
{ concurrency: 2 },
2958+
);
2959+
const sourceImage = yield* attemptPromise(
2960+
{
2961+
operation: "automationSnapshot.capturePage",
2962+
tabId,
2963+
webContentsId: wc.id,
2964+
},
2965+
() => wc.capturePage(),
2966+
).pipe(
2967+
Effect.catch(() =>
2968+
send("Page.captureScreenshot", { format: "png" }).pipe(
2969+
Effect.map((result) => {
2970+
const data =
2971+
result !== null &&
2972+
typeof result === "object" &&
2973+
"data" in result &&
2974+
typeof result.data === "string"
2975+
? result.data
2976+
: "";
2977+
return nativeImage.createFromBuffer(Buffer.from(data, "base64"));
2978+
}),
2979+
),
29422980
),
2943-
Ref.get(diagnosticsRef),
2944-
Ref.get(actionTimelineRef),
2945-
]);
2981+
);
29462982
const sourceSize = sourceImage.getSize();
29472983
const image =
29482984
sourceSize.width > MAX_SCREENSHOT_WIDTH
@@ -2952,9 +2988,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
29522988
const browserDiagnostics = diagnostics.get(wc.id);
29532989
return {
29542990
...page,
2955-
accessibilityTree: accessibility,
2956-
consoleEntries: [...(browserDiagnostics?.consoleEntries ?? [])],
2957-
networkEntries: [...(browserDiagnostics?.networkEntries ?? [])],
2991+
...(includeAx ? { accessibilityTree: accessibility } : {}),
2992+
consoleEntries: includeConsole ? [...(browserDiagnostics?.consoleEntries ?? [])] : [],
2993+
networkEntries: includeNetwork ? [...(browserDiagnostics?.networkEntries ?? [])] : [],
29582994
actionTimeline: [...(timelines.get(tabId) ?? [])],
29592995
screenshot: {
29602996
mimeType: "image/png" as const,
@@ -2968,10 +3004,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
29683004

29693005
const automationSnapshot = Effect.fn("PreviewManager.automationSnapshot")(function* (
29703006
tabId: string,
3007+
include: ReadonlyArray<PreviewAutomationSnapshotInclude> = [],
29713008
) {
29723009
const wc = yield* requireWebContents(tabId);
29733010
return yield* withControlSession(tabId, wc, "snapshot", (send) =>
2974-
captureAutomationSnapshot(tabId, wc, send),
3011+
captureAutomationSnapshot(tabId, wc, send, include),
29753012
);
29763013
});
29773014

@@ -3398,7 +3435,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
33983435
yield* send("Runtime.enable");
33993436
const locator = automationLocator(input);
34003437
if (locator) yield* ensurePlaywrightInjected(tabId, send);
3401-
const [locatorJson, textJson, urlIncludesJson] = yield* Effect.all([
3438+
const [locatorJson, textJson, urlIncludesJson, scopeJson] = yield* Effect.all([
34023439
locator
34033440
? encodeJson({ operation: "automationWaitFor.encodeLocator", tabId }, locator)
34043441
: Effect.succeed(null),
@@ -3408,6 +3445,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
34083445
input.urlIncludes
34093446
? encodeJson({ operation: "automationWaitFor.encodeUrl", tabId }, input.urlIncludes)
34103447
: Effect.succeed(null),
3448+
encodeJson({ operation: "automationWaitFor.encodeScope", tabId }, input.scope ?? "main"),
34113449
]);
34123450
const deadline = (yield* currentMillis) + timeoutMs;
34133451
while ((yield* currentMillis) <= deadline) {
@@ -3418,9 +3456,29 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
34183456
send,
34193457
`(() => {
34203458
try {
3421-
const selectorMatched = ${locatorJson ? `(() => { const injected = globalThis.__t3PlaywrightInjected; return injected.querySelector(injected.parseSelector(${locatorJson}), document, false) !== null; })()` : "true"};
3459+
const root = ${scopeJson} === "document"
3460+
? document.documentElement
3461+
: (document.querySelector("main") || document.documentElement);
3462+
const selectorMatched = ${
3463+
locatorJson
3464+
? `(() => {
3465+
const injected = globalThis.__t3PlaywrightInjected;
3466+
const parsed = injected.parseSelector(${locatorJson});
3467+
const element = injected.querySelector(parsed, root, false);
3468+
if (!element) return false;
3469+
const visible = injected.elementState(element, "visible");
3470+
if (!visible.matches) return false;
3471+
if (element.getAttribute("role") === "dialog") {
3472+
const slot = element.getAttribute("data-slot") || "";
3473+
if (slot.includes("trigger")) return false;
3474+
}
3475+
return true;
3476+
})()`
3477+
: "true"
3478+
};
3479+
const textRoot = root instanceof Element ? root : (root.body || document.body);
34223480
const textMatched = ${
3423-
textJson ? `(document.body?.innerText || "").includes(${textJson})` : "true"
3481+
textJson ? `(textRoot?.innerText || "").includes(${textJson})` : "true"
34243482
};
34253483
const urlMatched = ${
34263484
urlIncludesJson ? `location.href.includes(${urlIncludesJson})` : "true"
@@ -3876,6 +3934,7 @@ export class PreviewManager extends Context.Service<
38763934
) => Effect.Effect<PreviewAutomationStatus, PreviewManagerError>;
38773935
readonly automationSnapshot: (
38783936
tabId: string,
3937+
include?: ReadonlyArray<PreviewAutomationSnapshotInclude>,
38793938
) => Effect.Effect<PreviewAutomationSnapshot, PreviewManagerError>;
38803939
readonly automationClick: (
38813940
tabId: string,

apps/server/src/mcp/toolkits/preview/tools.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
PreviewAutomationSetColorSchemeInput,
1414
PreviewAutomationSetColorSchemeResult,
1515
PreviewAutomationSnapshot,
16+
PreviewAutomationSnapshotInput,
1617
PreviewAutomationStatus,
1718
PreviewAutomationTabTargetInput,
1819
PreviewAutomationTypeInput,
@@ -108,8 +109,8 @@ export const PreviewSetAppearanceTool = safeBrowserTool(
108109
export const PreviewSnapshotTool = readonlyBrowserTool(
109110
Tool.make("preview_snapshot", {
110111
description:
111-
"Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot.",
112-
parameters: PreviewAutomationTabTargetInput,
112+
"Inspect a page before interacting. Default is a slim snapshot: URL, visible text, interactive elements, and a PNG. Pass include:['ax'], include:['console'], or include:['network'] only when you need those heavier slices. Hidden tabs still capture.",
113+
parameters: PreviewAutomationSnapshotInput,
113114
success: PreviewAutomationSnapshot,
114115
failure: PreviewAutomationError,
115116
dependencies,
@@ -174,7 +175,7 @@ export const PreviewEvaluateTool = browserTool(
174175
export const PreviewWaitForTool = readonlyBrowserTool(
175176
Tool.make("preview_wait_for", {
176177
description:
177-
"Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.",
178+
"Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match. Text defaults to the main landmark so sidebar labels do not satisfy the wait. Locators must match a visible element.",
178179
parameters: PreviewAutomationWaitForInput,
179180
success: PreviewActionResult,
180181
failure: PreviewAutomationError,

apps/web/src/components/preview/PreviewAutomationHosts.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type PreviewAutomationNavigateInput,
1010
type PreviewAutomationOpenInput,
1111
type PreviewAutomationResizeInput,
12+
type PreviewAutomationSnapshotInput,
1213
type PreviewAutomationResizeResult,
1314
type PreviewAutomationSetColorSchemeInput,
1415
type PreviewAutomationSetColorSchemeResult,
@@ -584,7 +585,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
584585
}
585586
case "snapshot": {
586587
const ready = await requireReadyTab();
587-
return await ready.bridge.automation.snapshot(ready.runtimeTabId);
588+
const input = request.input as PreviewAutomationSnapshotInput;
589+
return await ready.bridge.automation.snapshot(ready.runtimeTabId, input.include);
588590
}
589591
case "click": {
590592
const ready = await requireReadyTab();

packages/contracts/src/ipc.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
PreviewAutomationResponse,
7272
PreviewAutomationScrollInput,
7373
PreviewAutomationSnapshot,
74+
PreviewAutomationSnapshotInclude,
7475
PreviewAutomationStatus,
7576
PreviewAutomationStreamEvent,
7677
PreviewAutomationTypeInput,
@@ -974,6 +975,11 @@ export interface DesktopPreviewTabDefaults {
974975
readonly colorScheme?: DesktopPreviewColorScheme | undefined;
975976
}
976977

978+
export const DesktopPreviewAutomationSnapshotInputSchema = Schema.Struct({
979+
tabId: DesktopPreviewTabIdSchema,
980+
include: Schema.optional(Schema.Array(PreviewAutomationSnapshotInclude)),
981+
});
982+
977983
export const DesktopPreviewRegisterWebviewInputSchema = Schema.Struct({
978984
tabId: DesktopPreviewTabIdSchema,
979985
webContentsId: Schema.Int.check(Schema.isGreaterThan(0)),
@@ -1186,7 +1192,10 @@ export interface DesktopPreviewBridge {
11861192
};
11871193
automation: {
11881194
status: (tabId: string) => Promise<PreviewAutomationStatus>;
1189-
snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>;
1195+
snapshot: (
1196+
tabId: string,
1197+
include?: ReadonlyArray<PreviewAutomationSnapshotInclude>,
1198+
) => Promise<PreviewAutomationSnapshot>;
11901199
click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>;
11911200
type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>;
11921201
press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>;

packages/contracts/src/preview.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import {
1717
PreviewAutomationOpenInput,
1818
PreviewAutomationResizeInput,
1919
PreviewAutomationResizeResult,
20+
PreviewAutomationSnapshotInput,
2021
PreviewAutomationStatus,
22+
PreviewAutomationWaitForInput,
2123
} from "./previewAutomation.ts";
2224

2325
const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);
@@ -32,6 +34,8 @@ const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResul
3234
const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost);
3335
const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError);
3436
const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus);
37+
const decodeSnapshotInput = Schema.decodeUnknownSync(PreviewAutomationSnapshotInput);
38+
const decodeWaitForInput = Schema.decodeUnknownSync(PreviewAutomationWaitForInput);
3539

3640
describe("PreviewAutomationOpenInput", () => {
3741
it("accepts the inline preview visibility flag", () => {
@@ -223,6 +227,26 @@ describe("PreviewAutomationStatus", () => {
223227
});
224228
});
225229

230+
describe("PreviewAutomationSnapshotInput", () => {
231+
it("defaults to a slim snapshot and accepts extra diagnostic slices", () => {
232+
expect(decodeSnapshotInput({})).toEqual({});
233+
expect(decodeSnapshotInput({ include: ["ax", "console", "network"] }).include).toEqual([
234+
"ax",
235+
"console",
236+
"network",
237+
]);
238+
expect(() => decodeSnapshotInput({ include: ["screenshot"] })).toThrow();
239+
});
240+
});
241+
242+
describe("PreviewAutomationWaitForInput", () => {
243+
it("defaults text and locators to the main landmark", () => {
244+
expect(decodeWaitForInput({ text: "Dashboard" })).toEqual({ text: "Dashboard" });
245+
expect(decodeWaitForInput({ text: "Dashboard", scope: "document" }).scope).toBe("document");
246+
expect(() => decodeWaitForInput({ scope: "main" })).toThrow();
247+
});
248+
});
249+
226250
describe("PreviewEvent", () => {
227251
it("decodes opened", () => {
228252
const event = decodePreviewEvent({

0 commit comments

Comments
 (0)