agent-desktop/scripts/perf_ab_probe.py
Lahfir 3f322728b4
feat!: implement Playwright-grade foundation contract
Settle the Playwright-grade reliability contract in agent-desktop-core
before the Windows/Linux adapters are built, so they inherit it instead
of redesigning it. Every command now observes, waits, verifies, and
reports honestly instead of firing blindly.

Highlights: capability-supertrait split of PlatformAdapter with
not_supported() defaults; canonical role/state vocabulary with live
`is --property visible`; display enumeration (`list-displays`) and honest
`--screen` with scale factor; truthful Automation permission; `native_id`
identity spine; window-id-first resolution; serializable `LocatorQuery`
with live `find`; default-on auto-wait before every ref action; three-way
`hit_test` occlusion gate; `scroll_into_view` in core; core accessible-name
precedence; typed `ActionStep` delivery tier; `ProcessState` and
`APP_UNRESPONSIVE`; `LaunchOptions`; baseline-diff desktop signals
(`wait --event`); typed clipboard (`Text`/`Image`/`FileUrls`); mouse
modifier chords and `mouse-wheel`. Hardened through a 35-reviewer pass with
independent validation and a green live e2e gate (109/0), plus a
head-vs-main performance comparison harness.

BREAKING CHANGE: default-on auto-wait changes the timing of every
previously-untouched ref-action call (bounded 5000 ms default; `--timeout-ms 0`
restores single-shot). `ENVELOPE_VERSION` is now `2.1` (adds the
`APP_UNRESPONSIVE` code and process state in error details). FFI ABI major
is `3` (append-only struct evolution; `wait --event` is intentionally not
exposed over FFI). The legacy string clipboard API is removed in favor of
typed content. `key-down`/`key-up` fail closed until daemon-owned held input
exists. `close-app` verifies termination and the osascript fallback path is
removed. `--text` matching is subtree containment: `find --text X --first`
returns the outermost matching container.
2026-07-20 00:21:38 -07:00

189 lines
6.1 KiB
Python
Executable file

#!/usr/bin/env python3
"""A/B latency probe: two agent-desktop binaries against one target app.
Modes:
fixture -- full probe incl. mutating actions; ONLY safe against the
AgentDeskFixture app.
app -- strictly read-only observation (snapshot/find timing) for
arbitrary real apps (Slack, Chrome, ...). Never dispatches
actions.
Each binary runs under an isolated HOME so cross-revision store formats
never collide. Rounds alternate between binaries to cancel machine drift.
"""
import argparse
import json
import os
import statistics
import subprocess
import sys
import tempfile
import time
ENVS = {}
def env_for(binary):
if binary not in ENVS:
env = dict(os.environ)
env["HOME"] = tempfile.mkdtemp(prefix="ad-perf-home-")
ENVS[binary] = env
return ENVS[binary]
def run(binary, *args, timeout=90):
start = time.monotonic()
proc = subprocess.run(
[binary, *args], capture_output=True, text=True, timeout=timeout, env=env_for(binary)
)
elapsed = (time.monotonic() - start) * 1000
return elapsed, proc
def envelope(proc):
try:
return json.loads(proc.stdout)
except ValueError:
return {}
def walk(node):
yield node
for child in node.get("children", []):
yield from walk(child)
def tree_stats(env):
tree = env.get("data", {}).get("tree", {})
nodes = sum(1 for _ in walk(tree))
depth = 0
def measure(node, level):
nonlocal depth
depth = max(depth, level)
for child in node.get("children", []):
measure(child, level + 1)
measure(tree, 1)
return nodes, depth, env.get("data", {}).get("ref_count")
def fixture_refs(binary, app):
_, proc = run(binary, "snapshot", "--app", app, "-i")
env = envelope(proc)
if "data" not in env:
raise SystemExit(f"{binary}: fixture snapshot failed: {proc.stdout[:300]}")
button = textfield = None
for node in walk(env["data"]["tree"]):
ref = node.get("ref") or node.get("ref_id")
if not ref:
continue
name = (node.get("name") or "").lower()
if node.get("role") == "button" and "primary" in name:
button = button or ref
if node.get("role") == "textfield":
textfield = textfield or ref
if not (button and textfield):
raise SystemExit(f"{binary}: fixture targets missing (button={button}, field={textfield})")
return button, textfield
def fixture_cases(binary, app):
button, textfield = fixture_refs(binary, app)
return [
("snapshot -i", ["snapshot", "--app", app, "-i"]),
("snapshot d30", ["snapshot", "--app", app, "--max-depth", "30"]),
("get", ["get", button]),
("is visible", ["is", button, "--property", "visible"]),
("click", ["click", button]),
("set-value", ["set-value", textfield, "ab-probe"]),
("type", ["type", textfield, "x"]),
]
def app_cases(app):
return [
("snapshot -i", ["snapshot", "--app", app, "-i"]),
("snapshot d30", ["snapshot", "--app", app, "--max-depth", "30"]),
]
def collect(binaries, cases_for, rounds):
cases = {label: cases_for(label) for label in binaries}
results = {label: {} for label in binaries}
shapes = {label: {} for label in binaries}
for _ in range(rounds):
for label, binary in binaries.items():
for name, args in cases[label]:
elapsed, proc = run(binary, *args)
env = envelope(proc)
ok = bool(env.get("ok"))
results[label].setdefault(name, []).append((elapsed, ok))
if ok and name.startswith("snapshot") and name not in shapes[label]:
shapes[label][name] = tree_stats(env)
return results, shapes
def summarize(results, shapes):
report = {}
for label, cases in results.items():
for name, samples in cases.items():
times = sorted(t for t, _ in samples)
report.setdefault(name, {})[label] = {
"p50_ms": round(statistics.median(times), 1),
"p95_ms": round(times[max(0, int(len(times) * 0.95) - 1)], 1),
"ok_rate": sum(1 for _, ok in samples if ok) / len(samples),
"shape": shapes[label].get(name),
}
return report
def head_only_find(binary, app, rounds, report):
times, matches = [], None
for _ in range(rounds):
elapsed, proc = run(binary, "find", "--app", app, "--role", "button", "--first")
env = envelope(proc)
if env.get("ok"):
times.append(elapsed)
matches = env.get("data", {}).get("match", {}).get("role")
if times:
times.sort()
report["find --role button --first (HEAD-only)"] = {
"HEAD": {
"p50_ms": round(statistics.median(times), 1),
"p95_ms": round(times[max(0, int(len(times) * 0.95) - 1)], 1),
"ok_rate": len(times) / rounds,
"matched_role": matches,
}
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["fixture", "app"], required=True)
parser.add_argument("--app", required=True)
parser.add_argument("--head-bin", required=True)
parser.add_argument("--base-bin", required=True)
parser.add_argument("--rounds", type=int, default=10)
parser.add_argument("--json-out", default="")
args = parser.parse_args()
binaries = {"HEAD": args.head_bin, "BASE": args.base_bin}
if args.mode == "fixture":
results, shapes = collect(binaries, lambda label: fixture_cases(binaries[label], args.app), args.rounds)
else:
results, shapes = collect(binaries, lambda _label: app_cases(args.app), args.rounds)
report = summarize(results, shapes)
if args.mode == "app":
head_only_find(args.head_bin, args.app, args.rounds, report)
payload = {"mode": args.mode, "app": args.app, "rounds": args.rounds, "cases": report}
if args.json_out:
with open(args.json_out, "w") as handle:
json.dump(payload, handle, indent=1)
json.dump(payload, sys.stdout, indent=1)
print()
if __name__ == "__main__":
main()