blob: 87d99d619a30737f3014521e909b2c76844cf4a5 [file]
#!/usr/bin/env node
// Where does the agent cursor actually end up?
//
// The overlay is a transparent, click-through window spanning every display,
// and the cursor is painted into its canvas. Nothing in the product reports
// where the glyph landed, so every claim about it so far has been a claim about
// the code that computes the point, not about the pixel a person sees.
//
// This reads the pixels. It finds the painted cursor in the overlay's canvas,
// converts that back to screen coordinates, and compares it with the target
// control's real rectangle as `cu-ax-oracle.swift` reads it from the system.
// Those two numbers agreeing is the only evidence that the cursor points at
// the thing being clicked.
//
// Run: node scripts/cu-cursor-accuracy-real.mjs
import { _electron as electron } from 'playwright';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { writeFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const exec = promisify(execFile);
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const DESKTOP = join(ROOT, 'apps', 'desktop');
const ORACLE = join(ROOT, 'scripts', 'cu-ax-oracle.swift');
const OUT = '/tmp/cu-cursor-accuracy';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const TARGET_LABEL = process.env.CU_CURSOR_TARGET ?? '7';
const PROMPT =
process.env.CU_CURSOR_PROMPT ??
`用 computer use 观察计算器这个应用,然后依次点一下数字 ${TARGET_LABEL}、加号、数字 3、等号。它在后台,不要切到前台。`;
async function oracle(bundleId) {
const { stdout } = await exec('swift', [ORACLE, bundleId], {
timeout: 120_000,
maxBuffer: 32 * 1024 * 1024,
});
return JSON.parse(stdout);
}
/**
* The painted cursor, in the overlay canvas's own CSS pixels.
*
* The glyph is the only thing drawn on an otherwise fully transparent canvas,
* so its centroid is the average position of every pixel with any alpha. An
* alpha floor keeps the soft shadow from dragging the centre away from the
* arrow tip.
*/
const CENTROID_IN_PAGE = () => {
const canvas = document.getElementById('cursor');
if (!canvas) return { found: false };
const ctx = canvas.getContext('2d');
const { width, height } = canvas;
if (!width || !height) return { found: true, painted: false, reason: 'canvas has no size' };
const data = ctx.getImageData(0, 0, width, height).data;
let sumX = 0;
let sumY = 0;
let count = 0;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
// Every other pixel in both axes. The glyph is ~30px across, so a stride of
// two cannot miss it, and a full scan of a 1920×1200 canvas in JS is slow
// enough to become the sampling interval.
for (let y = 0; y < height; y += 2) {
for (let x = 0; x < width; x += 2) {
if (data[(y * width + x) * 4 + 3] > 40) {
sumX += x;
sumY += y;
count += 1;
if (x < minX) minX = x;
if (y < minY) minY = y;
if (x > maxX) maxX = x;
if (y > maxY) maxY = y;
}
}
}
if (count === 0) return { found: true, painted: false };
const dpr = window.devicePixelRatio || 1;
return {
found: true,
painted: true,
pixels: count,
cssX: sumX / count / dpr,
cssY: sumY / count / dpr,
tipX: minX / dpr,
tipY: minY / dpr,
boxCss: { w: (maxX - minX) / dpr, h: (maxY - minY) / dpr },
};
};
await mkdir(OUT, { recursive: true });
await exec('osascript', ['-e', 'tell application id "com.apple.calculator" to quit']).catch(
() => {},
);
await sleep(800);
await exec('open', ['-g', '-a', 'Calculator']).catch(() => {});
await sleep(2000);
// Put the target where Computer Use is actually used: behind the window the
// user is looking at. With the calculator off to one side of the desktop the
// cursor has clear air to be drawn in and the interesting case never happens.
await exec('osascript', [
'-e',
'tell application "System Events" to tell (first process whose bundle identifier is "com.apple.calculator") to set position of window 1 to {320, 380}',
]).catch(() => {});
await sleep(800);
const app = await electron.launch({ args: ['.'], cwd: DESKTOP });
const samples = [];
let verdict = [];
let overlaySeen = 0;
let overlayPageMisses = 0;
let blankSamples = 0;
let calls = [];
// The overlay is created on the first action and destroyed with the session,
// which on a short turn is a window narrower than one polling interval. Polling
// missed it entirely on one run and reported "never painted" for what was
// really "never sampled". Listen instead, and sample the page itself.
const overlayPages = [];
// Whether the resting cursor is still pinned above every application window is
// the thing that was reported, and it is only observable while the overlay is
// alive — which on a short turn is narrower than one polling interval. Sampled
// from the window event rather than looked for.
const levelSamples = [];
app.on('window', (opened) => {
if (!opened.url().includes('cursor-overlay')) return;
overlayPages.push(opened);
void (async () => {
for (let i = 0; i < 120; i += 1) {
const level = await app
.evaluate(({ BrowserWindow }) => {
const w = BrowserWindow.getAllWindows().find((win) =>
win.webContents.getURL().includes('cursor-overlay'),
);
return w && !w.isDestroyed() ? { alwaysOnTop: w.isAlwaysOnTop() } : null;
})
.catch(() => null);
if (!level) break;
levelSamples.push(level.alwaysOnTop);
await sleep(80);
}
})();
});
try {
const page = await app.firstWindow();
await page.waitForSelector('.maka-composer-textarea', { timeout: 60_000 });
await sleep(1200);
const newTask = page.getByRole('button', { name: '新任务' });
if (await newTask.count()) {
await newTask.first().click();
await sleep(1000);
}
await page.click('.maka-composer-textarea');
await page.fill('.maka-composer-textarea', PROMPT);
await page.keyboard.press('Enter');
console.log(`sent: ${PROMPT}\n`);
const stopButton = page.getByRole('button', { name: '停止' });
const deadline = Date.now() + 240_000;
let started = false;
while (Date.now() < deadline) {
if (!started && (await stopButton.count().catch(() => 0)) > 0) started = true;
// Sample the overlay whenever it exists. It is created on the first action
// and destroyed with the session, so this has to run inside the turn.
const overlayBounds = await app
.evaluate(({ BrowserWindow }) => {
const all = BrowserWindow.getAllWindows().map((w) => ({
id: w.id,
url: w.webContents.getURL(),
bounds: w.getBounds(),
visible: w.isVisible(),
}));
const overlay = all.find((w) => w.url.includes('cursor-overlay'));
return overlay
? {
...overlay.bounds,
id: overlay.id,
// Everything else on screen that belongs to this app, so a
// cursor drawn "correctly" can still be shown to be pointing at
// a window the user cannot see.
others: all
.filter((w) => w.id !== overlay.id && w.visible)
.map((w) => ({
id: w.id,
kind: w.url.includes('pip') ? 'pip' : 'main',
bounds: w.bounds,
})),
}
: null;
})
.catch(() => null);
if (overlayBounds) {
overlaySeen += 1;
const overlayPage =
overlayPages.at(-1) ?? app.windows().find((p) => p.url().includes('cursor-overlay'));
if (!overlayPage) overlayPageMisses += 1;
if (overlayPage) {
const centroid = await overlayPage.evaluate(CENTROID_IN_PAGE).catch(() => null);
if (centroid?.found && !centroid.painted) blankSamples += 1;
if (centroid?.painted) {
samples.push({
at: Date.now(),
overlay: overlayBounds,
screenX: overlayBounds.x + centroid.cssX,
screenY: overlayBounds.y + centroid.cssY,
tipScreenX: overlayBounds.x + centroid.tipX,
tipScreenY: overlayBounds.y + centroid.tipY,
pixels: centroid.pixels,
box: centroid.boxCss,
});
}
}
}
if (started && (await stopButton.count().catch(() => 0)) === 0) break;
await sleep(120);
}
await sleep(1500);
// What the machine says the button really is.
const tree = await oracle('com.apple.calculator');
const button = (tree.elements ?? []).find(
(e) => e.role === 'AXButton' && (e.title === TARGET_LABEL || e.description === TARGET_LABEL),
);
const window0 = tree.windows?.[0];
// What the model asked the computer to do, so "the cursor never appeared"
// can be told apart from "no action ever ran".
calls = await page
.evaluate(async () => {
const sessions = await window.maka.sessions.list();
if (!sessions[0]) return [];
const messages = await window.maka.sessions.readMessages(sessions[0].id);
const results = new Map(
messages.filter((m) => m.type === 'tool_result').map((m) => [m.toolUseId, m]),
);
return messages
.filter((m) => m.type === 'tool_call' && m.toolName === 'maka_computer')
.map((m) => {
const r = results.get(m.id);
const text =
typeof r?.content === 'string' ? r.content : JSON.stringify(r?.content ?? '');
return { action: m.args?.action ?? '?', head: text.replace(/\s+/g, ' ').slice(0, 160) };
});
})
.catch(() => []);
console.log(`computer calls: ${calls.map((c) => c.action).join(' → ') || '(none)'}`);
for (const c of calls) console.log(` ${c.action}: ${c.head}`);
console.log(
`overlay existed in ${overlaySeen} samples, page unreachable in ${overlayPageMisses}, blank canvas in ${blankSamples}`,
);
console.log(`overlay samples with a painted cursor: ${samples.length}`);
if (samples.length) {
const last = samples.at(-1);
console.log(
` last painted at screen (${last.screenX.toFixed(0)}, ${last.screenY.toFixed(0)})`,
);
console.log(` glyph box ${last.box.w.toFixed(0)}x${last.box.h.toFixed(0)}, ${last.pixels} px`);
console.log(` overlay window ${JSON.stringify(last.overlay)}`);
}
console.log(`calculator window: ${JSON.stringify(window0?.frame)}`);
console.log(`button "${TARGET_LABEL}": ${JSON.stringify(button?.frame ?? null)}`);
const last = samples.at(-1);
if (!last) {
verdict.push({ label: 'the cursor was painted at all', pass: false, detail: 'never painted' });
} else if (!button?.frame) {
verdict.push({
label: 'the oracle found the target button',
pass: false,
detail: 'button not in the accessibility tree',
});
} else {
const cx = button.frame.x + button.frame.w / 2;
const cy = button.frame.y + button.frame.h / 2;
const dist = Math.hypot(last.screenX - cx, last.screenY - cy);
const insideWindow =
window0 &&
last.screenX >= window0.frame.x &&
last.screenX <= window0.frame.x + window0.frame.w &&
last.screenY >= window0.frame.y &&
last.screenY <= window0.frame.y + window0.frame.h;
verdict.push({
label: 'the cursor landed inside the app window it was driving',
pass: !!insideWindow,
detail: `cursor (${last.screenX.toFixed(0)}, ${last.screenY.toFixed(0)}), window ${JSON.stringify(window0?.frame)}`,
});
verdict.push({
label: 'the cursor landed on the button it clicked',
// The glyph is drawn with its tip at the point, so its centroid sits
// down-right of the target by roughly half the arrow. A button is ~40pt.
pass: dist <= 40,
detail: `${dist.toFixed(0)}pt from the button centre (${cx.toFixed(0)}, ${cy.toFixed(0)})`,
});
// Correct coordinates are not the same as a cursor a person can read. If
// one of this app's own windows covers the point, what the user sees is an
// arrow floating over a chat transcript, pointing at nothing — while the
// picture-in-picture mirror right next to it, the window they are actually
// watching, shows the app being driven with no cursor in it at all.
const covering = (last.overlay.others ?? []).filter(
(w) =>
last.screenX >= w.bounds.x &&
last.screenX <= w.bounds.x + w.bounds.width &&
last.screenY >= w.bounds.y &&
last.screenY <= w.bounds.y + w.bounds.height,
);
const pip = (last.overlay.others ?? []).find((w) => w.kind === 'pip');
// The overlay's own window level, read from the main process. `floating`
// (3) is above every ordinary application window, which is what made a
// resting cursor hang over whatever the user was reading. Ordered against
// the target it should be at normal level (0) instead.
if (levelSamples.length > 0) {
verdict.push({
label: 'the resting cursor came off always-on-top at least once',
pass: levelSamples.some((onTop) => onTop === false),
detail: `${levelSamples.filter((t) => t === false).length}/${levelSamples.length} samples off always-on-top`,
});
}
verdict.push({
label: 'the cursor is drawn somewhere the user can connect to the target',
pass: covering.length === 0,
detail:
covering.length === 0
? 'nothing of ours covers the point'
: `covered by ${covering.map((w) => `${w.kind} ${JSON.stringify(w.bounds)}`).join(', ')}` +
(pip ? `; the mirror the user is watching is at ${JSON.stringify(pip.bounds)}` : ''),
});
}
} catch (error) {
console.log(`[ERROR] ${error?.stack ?? error}`);
verdict.push({ label: 'the probe ran', pass: false, detail: String(error?.message ?? error) });
} finally {
await app.close().catch(() => {});
}
await writeFile(join(OUT, 'samples.json'), JSON.stringify(samples, null, 2), 'utf8');
console.log('');
for (const v of verdict) console.log(`[${v.pass ? 'PASS' : 'FAIL'}] ${v.label} — ${v.detail}`);
process.exit(verdict.every((v) => v.pass) ? 0 : 1);