blob: c56b65c6429fc505867dbbdebbab891f2376937a [file]
// The menu bar as the model reads it.
//
// Against a five-model, six-task real-machine matrix, three of the four tasks
// that failed for every model failed on one fact: no observation this executor
// produced contained a single menu element. Save as PDF, find in project and
// rotate image are menu commands and nothing in a window reaches them.
//
// Shipping the menu is not the same as shipping it usably. A whole menu bar is
// larger than most windows — TextEdit's is 369 elements against 16 — so what is
// asserted here is the shape that makes it affordable to carry on every
// observation, and the sentence without which a model cannot act on it.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
renderObservationForModel,
renderObservationText,
} from '../computer-use-observation-text.js';
import type { CuObservation, CuObservedElement } from '../computer-use-types.js';
function element(
elementId: string,
role: string,
extra: Partial<CuObservedElement> = {},
): CuObservedElement {
return { elementId, role, ...extra };
}
/** A window with a menu bar beside it, shaped the way maka-cu reports one. */
function observation(overrides: Partial<CuObservation> = {}): CuObservation {
return {
observationId: 'obs_1',
appId: 'com.apple.TextEdit',
pid: 1,
windowId: 2,
elements: [
element('0', 'AXWindow', { label: 'note.txt' }),
element('1', 'AXTextArea', { parentElementId: '0', value: 'hi' }),
// An ordinary window control whose role starts with AXMenu. TextEdit has
// one: 文稿操作. Splitting the menu out by role prefix would move it.
element('2', 'AXMenuButton', { parentElementId: '0', label: '文稿操作' }),
element('3', 'AXMenuBar'),
element('4', 'AXMenuBarItem', { parentElementId: '3', label: '文件' }),
element('5', 'AXMenu', { parentElementId: '4' }),
element('6', 'AXMenuItem', { parentElementId: '5', label: '新建' }),
element('7', 'AXMenuItem', { parentElementId: '5', enabled: false }),
element('8', 'AXMenuItem', { parentElementId: '5', label: '导出为PDF…', enabled: false }),
element('9', 'AXMenuBarItem', { parentElementId: '3', label: '编辑' }),
],
...overrides,
} as CuObservation;
}
test('a window control whose role begins with AXMenu stays in the window', () => {
const text = renderObservationForModel(observation());
const [windowPart, menuPart] = text.split(/^menu_bar=/m);
assert.match(windowPart ?? '', /文稿操作/);
assert.doesNotMatch(menuPart ?? '', /文稿操作/);
// And the header counts the window, not the whole observation: a model told
// `elements=10` and shown two lists cannot tell which number it was.
assert.match(text, /elements=3$/m);
});
test('the empty container between a menu title and its commands is gone', () => {
const text = renderObservationForModel(observation());
// `AXMenu` carries no name, no state and nothing to act on. Removing it is
// what makes an opened menu read the way a menu looks.
assert.doesNotMatch(text, /AXMenu\b(?!Bar|Item|Button)/);
const lines = text.split('\n');
const title = lines.findIndex((line) => line.includes('"文件"'));
const command = lines.findIndex((line) => line.includes('"新建"'));
assert.ok(title >= 0 && command > title);
// One level of indentation between them, not two.
const depth = (line: string) => line.match(/^\t*/)?.[0].length ?? 0;
assert.equal(depth(lines[command] ?? ''), depth(lines[title] ?? '') + 1);
});
test('a separator is not written down, and the command after it keeps its id', () => {
const text = renderObservationForModel(observation());
// An unnamed, disabled, actionless, childless AXMenuItem is AppKit's
// separator line. TextEdit's 文件 menu is 8 of 42, its 格式 menu 11 of 72.
assert.doesNotMatch(text, /^\s*7 AXMenuItem\s*$/m);
// The rule is narrower than "drop what has no label", which was measured
// against window trees and rejected: 1,023 unnamed but operable elements
// across ten applications, and no pixel fallback to reach one that was hid.
assert.match(text, /8 AXMenuItem "导出为PDF…" \[disabled\]/);
});
test('a listed menu says that it opens, and how', () => {
const text = renderObservationForModel(observation());
// Without this a model reads a list of menu names as a list of things that
// cannot be used, and the round trip that would open one is never spent.
assert.match(text, /menu_bar=2/);
assert.match(text, /not_opened/);
assert.match(text, /menu="<title>"/);
});
test('an opened menu names itself rather than repeating the offer', () => {
const text = renderObservationForModel(
observation({ menu: { opened: '文件' } } as Partial<CuObservation>),
);
assert.match(text, /opened="文件"/);
assert.doesNotMatch(text, /not_opened/);
});
test('a disabled command is explained once, not left to be retried', () => {
const text = renderObservationForModel(observation());
// Measured: TextEdit in the background has 52 of 250 menu items enabled and
// 168 in front, and the 116 that change are 存储, 导出为PDF…, 页面设置… —
// the commands a task is usually about. `AXPress` on one returns success and
// does nothing, so a model not told this reads the refusal as its own error.
assert.match(text, /needs its application in front/);
});
test('a menu with nothing disabled is not given the explanation', () => {
const all = observation();
const text = renderObservationForModel({
...all,
elements: all.elements.filter((e) => e.enabled !== false),
});
assert.doesNotMatch(text, /needs its application in front/);
});
test('a menu cut short by the executor says so, and a menu merely unopened does not', () => {
const cut = renderObservationForModel(
observation({ menu: { opened: '文件', truncated: true } } as Partial<CuObservation>),
);
assert.match(cut, /truncated=true\(this menu was cut short/);
// Stopping at the bar is the shape the host asked for. Reporting it as a
// truncation would present the host's own request to the model as a limit of
// the machine, and send it looking for a command that was never below.
assert.doesNotMatch(renderObservationForModel(observation()), /truncated=true\(this menu/);
});
test('an observation with no menu bar renders as it did before menus existed', () => {
const base = observation();
const text = renderObservationForModel({
...base,
elements: base.elements.slice(0, 3),
});
assert.doesNotMatch(text, /menu_bar=/);
assert.match(text, /elements=3$/m);
});
test('a wrapper around exactly one thing is collapsed, and its child keeps its id', () => {
// `mergeSingleItemGroups`, which is one of the thirteen transforms Codex's own
// renderer runs. Measured here: VS Code 172 of 985 elements, Calculator 4 of
// 42, TextEdit 1 of 20 — and Finder 1 of 1,198, because Finder's containers
// mostly hold several children and holding several is a statement that they
// belong together.
const text = renderObservationForModel({
observationId: 'obs_1',
appId: 'a',
pid: 1,
windowId: 2,
elements: [
element('0', 'AXWindow', { label: 'w' }),
element('1', 'AXGroup', { parentElementId: '0' }),
element('2', 'AXGroup', { parentElementId: '1' }),
element('3', 'AXButton', { parentElementId: '2', label: 'Save' }),
],
} as CuObservation);
const rows = text.split('\n');
assert.equal(rows.length, 3, 'header, window, button');
// Collapsing is not pruning: the button keeps the id it was minted with, so
// anything the model was already holding still addresses it.
assert.match(rows[2] ?? '', /^\t3 AXButton "Save"$/);
});
test('a container holding several children is collapsed too, and the strict form is reachable', () => {
// Holding several children once read as a statement that they belong
// together, and that reading did not survive being measured: lifting the
// children erases a line, not an element, and the relaxed rule keeps every
// operated element and every named ancestor at 87% of the tokens. The strict
// rule stays reachable because the offline evaluator baselines against it.
const sample = {
observationId: 'obs_1',
appId: 'a',
pid: 1,
windowId: 2,
elements: [
element('0', 'AXWindow', { label: 'w' }),
element('1', 'AXGroup', { parentElementId: '0' }),
element('2', 'AXButton', { parentElementId: '1', label: 'A' }),
element('3', 'AXButton', { parentElementId: '1', label: 'B' }),
],
} as CuObservation;
assert.doesNotMatch(renderObservationForModel(sample), /1 AXGroup/);
assert.match(renderObservationText(sample, { multiChildWrappers: false }), /1 AXGroup/);
});
test('a single-child wrapper that is a control, or named, or focused, stays', () => {
// Every clause of the test is load-bearing, and each of these would have been
// collapsed by a rule that only asked "does it have a label".
for (const extra of [
{ label: 'Sidebar' },
{ actions: ['raise'] },
{ focused: true },
{ value: '3' },
] as Array<Partial<CuObservedElement>>) {
const text = renderObservationForModel({
observationId: 'obs_1',
appId: 'a',
pid: 1,
windowId: 2,
elements: [
element('0', 'AXWindow', { label: 'w' }),
element('1', 'AXGroup', { parentElementId: '0', ...extra }),
element('2', 'AXButton', { parentElementId: '1', label: 'Save' }),
],
} as CuObservation);
assert.match(text, /1 AXGroup/, `collapsed a group carrying ${JSON.stringify(extra)}`);
}
});
test('a button with no label is not a wrapper, whatever it contains', () => {
// TextEdit's full-screen button holds one anonymous group and carries no
// label of its own. A rule written as "unnamed and holds one thing" collapses
// the button; this one keeps it, because a button is not a container role.
const text = renderObservationForModel({
observationId: 'obs_1',
appId: 'a',
pid: 1,
windowId: 2,
elements: [
element('0', 'AXWindow', { label: 'w' }),
element('1', 'AXButton', { parentElementId: '0', subrole: 'AXFullScreenButton' }),
element('2', 'AXGroup', { parentElementId: '1' }),
],
} as CuObservation);
assert.match(text, /1 AXButton/);
});