Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ describe("Preview feature public interface", () => {
"preview:automation.release-agent-control",
"preview:automation.describe-target",
"preview:automation.media-source",
"preview:automation.recording-cursor",
"preview:open-guest-devtools",
"preview:get-perf-counters",
];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ class FakeWebContents extends NodeEvents.EventEmitter {
return this.destroyed;
}

getZoomFactor(): number {
return 1;
}

getURL(): string {
return this.url;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BROWSER_AUTOMATION_CONTRACT_VERSION, type BrowserAutomationRequest } from "@mcode/contracts";
import {
PREVIEW_GUEST_AGENT_INPUT_CHANNEL,
PREVIEW_GUEST_AGENT_POINTER_CHANNEL,
PreviewGuestInputSuppressor,
} from "../../contracts/guest-input.js";

Expand Down Expand Up @@ -159,13 +160,15 @@ class FakeWebContents extends NodeEvents.EventEmitter {
url = "https://example.test/";
title = "Example";
focused = true;
zoomFactor = 1;
focus = vi.fn(() => {
this.focused = true;
});
constructor(readonly id: number) { super(); }
isDestroyed() { return this.destroyed; }
getURL() { return this.url; }
getTitle() { return this.title; }
getZoomFactor() { return this.zoomFactor; }
isLoading() { return false; }
isFocused() { return this.focused; }
async executeJavaScript(source: string) {
Expand Down Expand Up @@ -650,25 +653,54 @@ describe("BrowserAutomationKernel", () => {
await expect(click("opaque-replacement", "semantic-replacement")).resolves.toMatchObject({ ok: true });
});

it("publishes a resolved semantic target pointer before agent input", async () => {
const states: unknown[] = [];
const unsubscribe = kernel.subscribe(7, (state) => states.push(state));
it("reports a move then a click at the target's centre before pressing the mouse", async () => {
const pointerEvents: Array<{ event: unknown; commandsBefore: number }> = [];
const unsubscribe = kernel.subscribePointer(7, (pointerEvent) => {
pointerEvents.push({ event: pointerEvent, commandsBefore: currentWebContents!.debugger.commands.length });
});
currentWebContents!.zoomFactor = 1.5;
currentWebContents!.semanticElements.set("semantic-button", { attached: true, visible: true, x: 25, y: 40 });
await kernel.execute(event(), payload(request("click", {
target: { semanticId: "semantic-button" },
button: "left",
clickCount: 1,
timeoutMs: 1_000,
}, { requestId: "semantic-pointer" })));
expect(states).toContainEqual(expect.objectContaining({
controller: "agent",
pointer: { x: 25, y: 40 },
}));
expect(currentWebContents!.send).toHaveBeenCalledWith(
"mcode:browser-agent-input",
expect.objectContaining({ kind: "pointer", count: 1 }),
);
unsubscribe();

expect(pointerEvents.map(({ event }) => event)).toEqual([
{ tabId: "tab", phase: "move", x: 25, y: 40, zoomFactor: 1.5, sequence: 1 },
{ tabId: "tab", phase: "click", x: 25, y: 40, zoomFactor: 1.5, sequence: 2 },
]);
const firstPress = currentWebContents!.debugger.commands.findIndex(({ method, params }) =>
method === "Input.dispatchMouseEvent" && (params as { type?: string }).type === "mousePressed");
expect(firstPress).toBeGreaterThanOrEqual(0);
expect(pointerEvents.every(({ commandsBefore }) => commandsBefore <= firstPress)).toBe(true);
});

it("forwards agent pointer events to the page only while its recording cursor is on", async () => {
currentWebContents!.semanticElements.set("recorded-button", { attached: true, visible: true, x: 30, y: 60 });
const click = (requestId: string) => kernel.execute(event(), payload(request("click", {
target: { semanticId: "recorded-button" },
button: "left",
clickCount: 1,
timeoutMs: 1_000,
}, { requestId })));
const pointerSends = () => currentWebContents!.send.mock.calls.filter(([channel]) => channel === PREVIEW_GUEST_AGENT_POINTER_CHANNEL);

await click("before-recording");
expect(pointerSends()).toEqual([]);

expect(kernel.setRecordingCursor(event(), { threadId: "thread", tabId: "tab", enabled: true })).toBe(true);
await click("while-recording");
expect(pointerSends().map(([, pointer]) => pointer)).toEqual([
{ phase: "move", x: 30, y: 60 },
{ phase: "click", x: 30, y: 60 },
]);

expect(kernel.setRecordingCursor(event(), { threadId: "thread", tabId: "tab", enabled: false })).toBe(true);
await click("after-recording");
expect(pointerSends()).toHaveLength(2);
});

it("scrolls an off-screen semantic target into view before clicking", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ class FakeWebContents extends NodeEvents.EventEmitter {
stop = vi.fn();
send = vi.fn();
isDestroyed(): boolean { return this.destroyed; }
getZoomFactor(): number { return 1; }
getURL(): string { return this.url; }
getTitle(): string { return "Executor fixture"; }
isLoading(): boolean { return false; }
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/features/preview/automation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,23 @@ export function registerBrowserAutomationHandlers(platform: NodeJS.Platform): vo
ipcMain.handle("preview:automation.release-agent-control", (event, target: unknown) => automationKernel.releaseAgentControl(event, target));
ipcMain.handle("preview:automation.describe-target", (event, target: unknown) => automationKernel.describeTarget(event, target));
ipcMain.handle("preview:automation.media-source", (event, target: unknown) => automationKernel.getMediaSourceId(event, target));
ipcMain.handle("preview:automation.recording-cursor", (event, target: unknown) => automationKernel.setRecordingCursor(event, target));
ipcMain.handle("preview:open-guest-devtools", (event, target: unknown) => automationKernel.openDevTools(event, target));
ipcMain.on("preview:automation.subscribe", (event: IpcMainEvent) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win.isDestroyed()) return;
if (subscribedRenderers.has(event.sender)) return;
subscribedRenderers.add(event.sender);
const unsubscribe = automationKernel.subscribe(win.id, (state) => {
const unsubscribeController = automationKernel.subscribe(win.id, (state) => {
if (!event.sender.isDestroyed()) event.sender.send("preview:automation.controller", state);
});
event.sender.once("destroyed", unsubscribe);
const unsubscribePointer = automationKernel.subscribePointer(win.id, (pointer) => {
if (!event.sender.isDestroyed()) event.sender.send("preview:automation.pointer", pointer);
});
event.sender.once("destroyed", () => {
unsubscribeController();
unsubscribePointer();
});
});
ipcMain.on(BROWSER_AUTOMATION_HEARTBEAT_SUBSCRIBE_CHANNEL, (event: IpcMainEvent) => {
heartbeatPulse.subscribe(event.sender);
Expand Down
98 changes: 89 additions & 9 deletions apps/desktop/src/features/preview/automation/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,16 @@ import {
type BrowserAutomationHostDispatchTarget,
type BrowserAutomationHostDispatch,
type BrowserAutomationControllerState,
type BrowserAutomationPointerEvent,
} from "@mcode/contracts";
import { findAdoptedWebContentsForWindow } from "../surfaces/registry.js";
import { resolveActivePreviewWebContentsForWindow } from "../surfaces/active-web-contents.js";
import { loadPreviewGuestUrl } from "../navigation/guest-navigation.js";
import { PREVIEW_GUEST_AGENT_INPUT_CHANNEL } from "../contracts/guest-input.js";
import {
PREVIEW_GUEST_AGENT_INPUT_CHANNEL,
PREVIEW_GUEST_AGENT_POINTER_CHANNEL,
type PreviewGuestAgentPointerMessage,
} from "../contracts/guest-input.js";
import { getSession, getThreadTabSet } from "../state/window-session.js";
import {
BrowserAutomationCancelledError,
Expand Down Expand Up @@ -616,6 +621,10 @@ export class BrowserAutomationKernel {
private readonly targetGenerations = new Map<string, number>();
private readonly cancellations = new Map<string, { cancel: () => void }>();
private readonly subscribers = new Map<number, Set<(state: unknown) => void>>();
private readonly pointerSubscribers = new Map<number, Set<(event: BrowserAutomationPointerEvent) => void>>();
/** Target keys whose page is being recorded, so the guest must draw the agent cursor itself. */
private readonly recordingCursorTargets = new Set<string>();
private pointerSequence = 0;
private readonly rendererOperationLeases = new Map<string, RendererOperationLease>();

/** Validates and executes one request from a trusted renderer. */
Expand Down Expand Up @@ -974,6 +983,46 @@ export class BrowserAutomationKernel {
};
}

/** Subscribes a renderer window to agent cursor points, reported before each pointer action. */
subscribePointer(windowId: number, callback: (event: BrowserAutomationPointerEvent) => void): () => void {
const listeners = this.pointerSubscribers.get(windowId) ?? new Set();
listeners.add(callback);
this.pointerSubscribers.set(windowId, listeners);
return () => {
listeners.delete(callback);
if (listeners.size === 0) this.pointerSubscribers.delete(windowId);
};
}

/**
* Turns the in-page agent cursor on or off for one tab while the renderer records it.
* Turning it off never requires a live tab, so a recording that outlives its tab still cleans up.
*/
setRecordingCursor(event: IpcMainInvokeEvent, input: unknown): boolean {
const value = asRecord(input);
let threadId: string;
let tabId: string;
try {
threadId = assertShortId(value.threadId, "threadId");
tabId = assertShortId(value.tabId, "tabId");
} catch {
return false;
}
if (typeof value.enabled !== "boolean") return false;
if (!value.enabled) {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return false;
this.recordingCursorTargets.delete(targetKey(win.id, threadId, tabId));
return true;
}
try {
this.recordingCursorTargets.add(this.resolveCurrentTarget(event, threadId, tabId).state.key);
return true;
} catch {
return false;
}
}

/** Releases target listeners, debugger sessions, queues, and buffers for one window. */
disposeWindow(windowId: number): void {
for (const [key, state] of this.targets) {
Expand All @@ -989,6 +1038,10 @@ export class BrowserAutomationKernel {
if (key.startsWith(`[${windowId},`)) this.threadControls.delete(key);
}
this.subscribers.delete(windowId);
this.pointerSubscribers.delete(windowId);
for (const key of this.recordingCursorTargets) {
if (key.startsWith(`[${windowId},`)) this.recordingCursorTargets.delete(key);
}
}

/** Returns internal bounded counters for tests and performance diagnostics. */
Expand Down Expand Up @@ -1187,7 +1240,6 @@ export class BrowserAutomationKernel {
state: TargetState,
event: BrowserControlEvent,
request?: BrowserAutomationRequest,
pointer?: { x: number; y: number },
): boolean {
const key = JSON.stringify([state.windowId, state.threadId]);
const previous = this.threadControls.get(key) ?? createBrowserThreadControl();
Expand All @@ -1196,7 +1248,7 @@ export class BrowserAutomationKernel {
this.threadControls.set(key, next);
if (event.type === "agent-effect") {
state.controlEpoch = next.epochByTab.get(state.tabId) ?? 0;
this.emitController(state, "agent", request, pointer);
this.emitController(state, "agent", request);
} else {
this.publishThreadControl(state, next);
}
Expand Down Expand Up @@ -1236,19 +1288,40 @@ export class BrowserAutomationKernel {
}
}

private admitAgentEffect(state: TargetState, request: BrowserAutomationRequest, pointer?: { x: number; y: number }): void {
private admitAgentEffect(state: TargetState, request: BrowserAutomationRequest): void {
this.applyControlEvent(state, {
type: "agent-effect",
tabId: state.tabId,
expectedEpoch: request.expectedControlEpoch,
}, request, pointer);
}, request);
}

/** Reports where the agent is about to act; runs after admission so a held tab never shows a cursor. */
private emitPointer(state: TargetState, phase: BrowserAutomationPointerEvent["phase"], point: { x: number; y: number }): void {
this.pointerSequence += 1;
const event: BrowserAutomationPointerEvent = {
tabId: state.tabId,
phase,
x: point.x,
y: point.y,
zoomFactor: state.webContents.getZoomFactor(),
sequence: this.pointerSequence,
};
for (const listener of this.pointerSubscribers.get(state.windowId) ?? []) listener(event);
if (!this.recordingCursorTargets.has(state.key) || state.webContents.isDestroyed()) return;
const message: PreviewGuestAgentPointerMessage = { phase, x: point.x, y: point.y };
state.webContents.send(PREVIEW_GUEST_AGENT_POINTER_CHANNEL, message);
}

private emitPointerMoveThenClick(state: TargetState, point: { x: number; y: number }): void {
this.emitPointer(state, "move", point);
this.emitPointer(state, "click", point);
}

private emitController(
state: TargetState,
controller: "none" | "human" | "agent",
request?: BrowserAutomationRequest,
pointer?: { x: number; y: number },
): void {
const payload: BrowserAutomationControllerState = {
tabId: state.tabId,
Expand All @@ -1262,7 +1335,6 @@ export class BrowserAutomationKernel {
: {}),
}
: {}),
...(pointer ? { pointer } : {}),
};
state.controller = payload;
for (const listener of this.subscribers.get(state.windowId) ?? []) listener(payload);
Expand Down Expand Up @@ -1601,7 +1673,8 @@ export class BrowserAutomationKernel {
): Promise<unknown> {
const { state, webContents } = resolved;
const point = await this.resolvePoint(resolved, request.args.target);
this.admitAgentEffect(state, request, point);
this.admitAgentEffect(state, request);
this.emitPointerMoveThenClick(state, point);
await this.withSyntheticInput(state, async () => {
await this.dispatchClick(state, webContents, point, request, signal, markEffect);
this.throwIfAborted(signal);
Expand Down Expand Up @@ -1667,7 +1740,8 @@ export class BrowserAutomationKernel {
): Promise<void> {
if (!request.args.target) return;
const point = await this.resolvePoint(resolved, request.args.target);
this.admitAgentEffect(resolved.state, request, point);
this.admitAgentEffect(resolved.state, request);
this.emitPointerMoveThenClick(resolved.state, point);
await this.withSyntheticInput(resolved.state, () => this.clickPoint(resolved.state, point, markEffect));
this.throwIfAborted(signal);
}
Expand Down Expand Up @@ -1714,6 +1788,12 @@ export class BrowserAutomationKernel {
): Promise<unknown> {
const { state, webContents } = resolved;
const point = request.args.target ? await this.resolvePoint(resolved, request.args.target) : { x: 1, y: 1 };
// An untargeted scroll has no point the user would recognise, so the cursor stays put.
if (request.args.target) {
// Resolving the point awaits the page, so the user may have taken control meanwhile.
this.admitAgentEffect(state, request);
this.emitPointer(state, "move", point);
}
await this.withSyntheticInput(state, async () => {
await this.ensureDebugger(state);
await this.dispatchGuestInput(
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/features/preview/contracts/guest-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ export const PREVIEW_GUEST_CLIPBOARD_TRUST_CHANNEL = "mcode:browser-clipboard-tr
/** Guest preload channel that reserves trusted events generated by agent CDP input. */
export const PREVIEW_GUEST_AGENT_INPUT_CHANNEL = "mcode:browser-agent-input";

/**
* Guest preload channel that draws the agent cursor inside the page. The host only sends it while
* the tab is being recorded, because a tab capture sees the page and not the overlay above it.
*/
export const PREVIEW_GUEST_AGENT_POINTER_CHANNEL = "mcode:browser-agent-pointer";

/** Agent cursor point in viewport CSS pixels, sent to the guest preload before the agent acts. */
export interface PreviewGuestAgentPointerMessage {
readonly phase: "move" | "click";
readonly x: number;
readonly y: number;
}

/** Trusted guest interaction categories that transfer control from an agent to the human. */
export const PREVIEW_GUEST_HUMAN_INPUT_KINDS = [
"keyboard",
Expand Down
Loading
Loading