mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-26 09:02:17 +00:00
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.
189 lines
6.1 KiB
Python
Executable file
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()
|