settled-computer 0.1.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ """settled-computer: event-driven settling for computer-use agents.
2
+
3
+ Every action waits until the screen reacts and stops changing, then returns the
4
+ settled frame plus a verdict — no fixed sleeps, no second screenshot round trip.
5
+ """
6
+ from .engine import (
7
+ BusyProbe,
8
+ Frame,
9
+ Grabber,
10
+ LatencyBook,
11
+ Region,
12
+ SettleConfig,
13
+ SettleResult,
14
+ act_and_settle,
15
+ browser_busy_probe,
16
+ install_browser_probe,
17
+ make_mss_grabber,
18
+ playwright_grabber,
19
+ wait_settled,
20
+ )
21
+
22
+ __version__ = "0.1.0a1"
23
+
24
+ __all__ = [
25
+ "BusyProbe",
26
+ "Frame",
27
+ "Grabber",
28
+ "LatencyBook",
29
+ "Region",
30
+ "SettleConfig",
31
+ "SettleResult",
32
+ "act_and_settle",
33
+ "browser_busy_probe",
34
+ "install_browser_probe",
35
+ "make_mss_grabber",
36
+ "playwright_grabber",
37
+ "wait_settled",
38
+ "__version__",
39
+ ]
@@ -0,0 +1,581 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ settle.py - event-driven "wait until the UI stops changing" for computer-use agents.
4
+
5
+ Replaces the fixed `sleep(N)` most agent harnesses run after every click/keypress.
6
+
7
+ How it works
8
+ ------------
9
+ 1. Grab a baseline frame BEFORE performing the action (so a fast reaction is never missed).
10
+ 2. Perform the action.
11
+ 3. Poll frames (~20 Hz), diffing at full resolution in 8x8-pixel cells, and wait for:
12
+ phase 1 (react): the screen changes, or `react_deadline` passes -> "no_reaction"
13
+ phase 2 (settle): no significant change for `quiet_time` -> "settled"
14
+ with `max_wait` as a hard cap -> "timeout"
15
+ 4. Return the last full-resolution frame, so it doubles as the screenshot for the model
16
+ (no second capture) plus a short text note describing what happened.
17
+
18
+ Optional extra signals:
19
+ - `busy_probe`: any async/sync callable returning True while the app is busy
20
+ (browser: in-flight fetch/XHR, DOM mutations, running animations; desktop: busy cursor,
21
+ process CPU, etc.). See `browser_busy_probe` below.
22
+ - `ignore_regions`: fractions of the screen to mask out (clock, ticker, video).
23
+
24
+ Usage
25
+ -----
26
+ grab = make_mss_grabber() # desktop
27
+ res = await act_and_settle(grab, lambda: pyautogui.click(x, y))
28
+ send_to_model(image=res.frame, text=res.note_for_model())
29
+
30
+ # browser (Playwright, async API)
31
+ await install_browser_probe(page)
32
+ cfg = SettleConfig(pixel_delta=12, busy_probe=browser_busy_probe(page))
33
+ res = await act_and_settle(playwright_grabber(page), lambda: page.mouse.click(x, y), cfg)
34
+
35
+ Run the self-test: python settle.py --selftest
36
+ Requires: numpy. Optional: mss (desktop capture), Pillow (browser screenshots).
37
+ """
38
+ from __future__ import annotations
39
+
40
+ import argparse
41
+ import asyncio
42
+ import inspect
43
+ import io
44
+ import sys
45
+ import time
46
+ from collections import defaultdict, deque
47
+ from dataclasses import dataclass, replace
48
+ from typing import Awaitable, Callable, Optional, Sequence, Union
49
+
50
+ import numpy as np
51
+
52
+ Frame = np.ndarray # full-resolution uint8 frame: H x W x 4 (BGRA, fastest), H x W x 3, or H x W
53
+ Grabber = Callable[[], Union[Frame, Awaitable[Frame]]]
54
+ BusyProbe = Callable[[], Union[bool, Awaitable[bool]]]
55
+ Region = tuple # (x0, y0, x1, y1) as fractions of the screen, 0..1
56
+
57
+
58
+ # --------------------------------------------------------------------------- config / result
59
+ @dataclass
60
+ class SettleConfig:
61
+ poll_interval: float = 0.05 # seconds between frames (~20 Hz)
62
+ react_deadline: float = 0.30 # max wait for ANY change after the action
63
+ quiet_time: float = 0.20 # continuous quiet needed to call it settled
64
+ max_wait: float = 8.0 # hard cap
65
+ pixel_delta: int = 0 # 0 = exact compare (fast path, right for desktop capture);
66
+ # >0 (e.g. 12) tolerates noise, e.g. JPEG browser screenshots
67
+ block: int = 8 # frames are compared in block x block pixel cells
68
+ react_blocks: int = 2 # changed cells that count as "the UI reacted" (one typed char ~ 2-4)
69
+ activity_blocks: int = 8 # changed cells that keep resetting the quiet timer
70
+ # (below this, e.g. a blinking caret, counts as residual motion)
71
+ ignore_regions: Sequence[Region] = ()
72
+ busy_probe: Optional[BusyProbe] = None
73
+ residual_bail_after: float = 1.5 # a confined animation (video/spinner) bails after this long
74
+ confined_area: float = 0.35 # changed area (screen fraction) still counts as "confined"
75
+
76
+
77
+ @dataclass
78
+ class SettleResult:
79
+ reason: str # "settled" | "no_reaction" | "timeout" | "residual"
80
+ reacted: bool # did the screen (or busy probe) show any reaction
81
+ waited: float # seconds spent waiting (excludes the action itself)
82
+ residual_motion: float # 0..1: share of quiet polls with tiny changes (spinner/caret hint)
83
+ frame: Frame # last full-resolution frame, ready to send to the model
84
+ motion_box: Optional[tuple] = None # (x0, y0, x1, y1) fractions bounding the animating area
85
+ spread: bool = False # timeout only: motion covers more than `confined_area`
86
+
87
+ @property
88
+ def settled(self) -> bool:
89
+ return self.reason in ("settled", "no_reaction")
90
+
91
+ def note_for_model(self, kind: str = "action") -> str:
92
+ """One-line verdict for the model. `kind` ("wait", "move"/"hover", "type", anything else)
93
+ tunes the no-reaction wording, because "missed its target" makes no sense for a wait."""
94
+ if self.reason == "settled":
95
+ msg = f"Screen settled {self.waited:.2f}s after the action."
96
+ elif self.reason == "no_reaction":
97
+ if kind == "wait":
98
+ msg = f"Nothing changed during {self.waited:.1f}s."
99
+ elif kind in ("move", "hover"):
100
+ msg = f"No visible change within {self.waited:.2f}s (no hover effect appeared)."
101
+ elif kind == "type":
102
+ msg = (f"No visible change within {self.waited:.2f}s of typing. The focus may be elsewhere, "
103
+ "or the field does not echo characters (e.g. a password box).")
104
+ else:
105
+ msg = (f"No visible change within {self.waited:.2f}s of the action. It may simply have had no "
106
+ "visible effect (the control was already focused or already in that state), missed its "
107
+ "target, or the app is slow to respond.")
108
+ elif self.reason == "residual":
109
+ box = self.motion_box
110
+ where = f" in region [{box[0]:.2f},{box[1]:.2f},{box[2]:.2f},{box[3]:.2f}]" if box else ""
111
+ msg = (f"Screen settled {self.waited:.2f}s after the action, except a small region{where} that "
112
+ "keeps animating (video, ticker, spinner or progress indicator?). Everything outside it is "
113
+ "stable. If that region is the loading indicator for what you are waiting on, call wait() "
114
+ "instead of acting.")
115
+ else:
116
+ msg = (f"Screen was still changing after {self.waited:.1f}s (timeout). "
117
+ "Likely loading or animating; wait and re-check before acting.")
118
+ box = self.motion_box
119
+ if self.spread and box is not None:
120
+ msg += (f" Motion is spread across region [{box[0]:.2f},{box[1]:.2f},"
121
+ f"{box[2]:.2f},{box[3]:.2f}].")
122
+ if self.residual_motion > 0.3 and self.reason != "residual":
123
+ msg += " A small region is still animating (spinner, ticker or video?)."
124
+ return msg
125
+
126
+
127
+ # --------------------------------------------------------------------------- core
128
+ async def _maybe_await(value):
129
+ return await value if inspect.isawaitable(value) else value
130
+
131
+
132
+ def _mask(shape, regions: Sequence[Region]) -> np.ndarray:
133
+ """Boolean grid (True = watched) at block resolution; `regions` are screen fractions to ignore."""
134
+ m = np.ones(shape, dtype=bool)
135
+ h, w = shape
136
+ for x0, y0, x1, y1 in regions:
137
+ m[int(y0 * h):int(np.ceil(y1 * h)), int(x0 * w):int(np.ceil(x1 * w))] = False
138
+ return m
139
+
140
+
141
+ def _as_u32(frame: Frame) -> np.ndarray:
142
+ """One uint32 per pixel so equality checks touch 4x fewer elements (BGRA/RGBA frames are
143
+ viewed in place; other layouts are packed once)."""
144
+ if frame.ndim == 2:
145
+ return frame.astype(np.uint32)
146
+ if frame.shape[2] == 4 and frame.dtype == np.uint8 and frame.flags.c_contiguous:
147
+ return frame.view(np.uint32).reshape(frame.shape[:2])
148
+ packed = np.zeros(frame.shape[:2] + (4,), np.uint8)
149
+ packed[..., :3] = frame[..., :3]
150
+ return packed.view(np.uint32).reshape(frame.shape[:2])
151
+
152
+
153
+ def _changed_blocks(prev: Frame, cur: Frame, delta: int, block: int, mask: np.ndarray) -> tuple[int, Optional[tuple]]:
154
+ """Number of block x block cells containing a changed pixel, plus the fractional bounding box
155
+ of the change (None when nothing changed) for the confined-animation report.
156
+ Comparing at full resolution and then reducing with any() keeps thin text strokes visible,
157
+ which a strided downsample would skip. delta == 0 uses a fast exact compare (~1 ms per 1080p
158
+ frame when nothing changed); delta > 0 ignores per-channel differences up to `delta`."""
159
+ if prev.shape != cur.shape: # resolution changed mid-wait: treat everything as changed
160
+ return int(mask.size), (0.0, 0.0, 1.0, 1.0)
161
+ if delta <= 0:
162
+ changed = _as_u32(prev) != _as_u32(cur)
163
+ else:
164
+ d = np.maximum(prev, cur) - np.minimum(prev, cur) # uint8-safe |a - b|
165
+ if d.ndim == 3:
166
+ d = d[..., :3].max(axis=2)
167
+ changed = d > delta
168
+ if not changed.any():
169
+ return 0, None
170
+ hb, wb = changed.shape[0] // block, changed.shape[1] // block
171
+ grid = changed[:hb * block, :wb * block].reshape(hb, block, wb, block).any(axis=(1, 3)) & mask
172
+ n = int(grid.sum())
173
+ if n == 0:
174
+ return 0, None
175
+ rows = np.nonzero(grid.any(axis=1))[0]
176
+ cols = np.nonzero(grid.any(axis=0))[0]
177
+ h, w = grid.shape
178
+ box = (float(cols[0] / w), float(rows[0] / h),
179
+ float((cols[-1] + 1) / w), float((rows[-1] + 1) / h))
180
+ return n, box
181
+
182
+
183
+ async def wait_settled(grab: Grabber, cfg: Optional[SettleConfig] = None,
184
+ baseline: Optional[Frame] = None) -> SettleResult:
185
+ """Wait until the screen reacts and then stops changing.
186
+
187
+ Pass `baseline` = a frame captured BEFORE the action. Without it, a reaction that
188
+ finishes before the first poll is invisible and gets misreported as "no_reaction".
189
+ """
190
+ cfg = cfg or SettleConfig()
191
+ t0 = time.monotonic()
192
+ prev = baseline if baseline is not None else await _maybe_await(grab())
193
+ mask = _mask((prev.shape[0] // cfg.block, prev.shape[1] // cfg.block), cfg.ignore_regions)
194
+
195
+ frame = prev
196
+ reacted = False
197
+ last_activity = t0
198
+ last_busy = t0 # a busy app gets a short grace before "no_reaction", because
199
+ quiet_polls = tiny_polls = 0 # the busy->idle transition usually precedes the paint
200
+ poll = min(cfg.poll_interval, 0.015) # burst-then-relax: fast first polls catch fast UIs,
201
+ burst_until = t0 + 0.1 # then fall back to the configured cadence
202
+ motion: deque = deque() # (time, box) of recent changed polls, for the
203
+ waited_polls = 0 # confined-animation detector
204
+
205
+ while True:
206
+ await asyncio.sleep(0.0 if waited_polls == 0 else poll) # first poll is immediate: the
207
+ waited_polls += 1 # reaction is often already there
208
+ frame = await _maybe_await(grab())
209
+ now = time.monotonic()
210
+
211
+ n, box = _changed_blocks(prev, frame, cfg.pixel_delta, cfg.block, mask)
212
+ prev = frame
213
+ busy = bool(cfg.busy_probe and await _maybe_await(cfg.busy_probe()))
214
+ if busy:
215
+ last_busy = now
216
+
217
+ first_reaction = (not reacted) and n >= cfg.react_blocks
218
+ reacted = reacted or first_reaction
219
+ if first_reaction or n >= cfg.activity_blocks or busy:
220
+ last_activity = now
221
+ quiet_polls = tiny_polls = 0
222
+ else:
223
+ quiet_polls += 1
224
+ tiny_polls += 1 if n > 0 else 0
225
+
226
+ if n > 0 and box is not None:
227
+ motion.append((now, box))
228
+ while motion and motion[0][0] < now - cfg.residual_bail_after:
229
+ motion.popleft()
230
+ motion_box = None
231
+ if motion:
232
+ motion_box = (min(b[0] for _, b in motion), min(b[1] for _, b in motion),
233
+ max(b[2] for _, b in motion), max(b[3] for _, b in motion))
234
+
235
+ elapsed = now - t0
236
+ if now > burst_until:
237
+ poll = cfg.poll_interval
238
+ residual = tiny_polls / quiet_polls if quiet_polls else 0.0
239
+
240
+ if reacted and now - last_activity >= cfg.quiet_time:
241
+ return SettleResult("settled", True, elapsed, residual, frame)
242
+ if (reacted and motion_box is not None and elapsed >= cfg.residual_bail_after
243
+ and (motion_box[2] - motion_box[0]) * (motion_box[3] - motion_box[1]) <= cfg.confined_area):
244
+ # One confined region keeps animating (video, spinner, ticker): it will not settle,
245
+ # so return the current frame with its bounding box instead of burning max_wait.
246
+ return SettleResult("residual", True, elapsed, residual, frame, motion_box)
247
+ if not reacted and not busy and elapsed >= cfg.react_deadline \
248
+ and now - last_busy >= min(cfg.react_deadline, 0.1):
249
+ return SettleResult("no_reaction", False, elapsed, residual, frame)
250
+ if elapsed >= cfg.max_wait:
251
+ spread = (motion_box is not None
252
+ and (motion_box[2] - motion_box[0]) * (motion_box[3] - motion_box[1]) > cfg.confined_area)
253
+ return SettleResult("timeout", reacted, elapsed, residual, frame, motion_box, spread=spread)
254
+
255
+
256
+ async def act_and_settle(grab: Grabber, action: Callable[[], object],
257
+ cfg: Optional[SettleConfig] = None) -> SettleResult:
258
+ """Capture a baseline, run the action (sync or async), then wait for the UI to settle."""
259
+ baseline = await _maybe_await(grab())
260
+ await _maybe_await(action())
261
+ return await wait_settled(grab, cfg, baseline=baseline)
262
+
263
+
264
+ # --------------------------------------------------------------------------- adaptive timeouts
265
+ class LatencyBook:
266
+ """Learns how long settling takes per key (e.g. "click") so the `max_wait` cap tracks reality.
267
+
268
+ - Timeouts are recorded too (as the wait they hit). Learning from successes alone would let a
269
+ key that often times out learn a short cap from its few fast runs.
270
+ - Learning only ever shortens the cap below the configured value, and never touches fields the
271
+ user pinned through configure() (`pinned`).
272
+ - quiet_time is deliberately NOT learned: it protects against pauses between UI phases
273
+ (debounced search, dialog then network), which the time-to-last-motion cannot reveal."""
274
+
275
+ def __init__(self, keep: int = 50):
276
+ self._samples = defaultdict(lambda: deque(maxlen=keep))
277
+
278
+ def record(self, key: str, result: SettleResult) -> None:
279
+ if result.reason in ("settled", "timeout"):
280
+ self._samples[key].append(result.waited)
281
+
282
+ def max_wait_for(self, key: str, default: float = 8.0, floor: float = 1.0,
283
+ factor: float = 3.0) -> float:
284
+ xs = sorted(self._samples.get(key, ()))
285
+ if len(xs) < 5:
286
+ return default
287
+ p95 = xs[min(len(xs) - 1, int(len(xs) * 0.95))]
288
+ return max(floor, min(default, p95 * factor))
289
+
290
+ def config_for(self, key: str, base: Optional[SettleConfig] = None,
291
+ pinned: frozenset = frozenset()) -> SettleConfig:
292
+ base = base or SettleConfig()
293
+ if "max_wait" in pinned:
294
+ return base
295
+ return replace(base, max_wait=self.max_wait_for(key, base.max_wait))
296
+
297
+
298
+ # --------------------------------------------------------------------------- frame sources
299
+ def make_mss_grabber(monitor: int = 1) -> Grabber:
300
+ """Desktop capture via `pip install mss`. Returns contiguous BGRA frames, which enables the fast
301
+ exact-compare path; drop the alpha channel (frame[:, :, :3]) only when encoding for the model."""
302
+ import mss
303
+
304
+ sct = (getattr(mss, "MSS", None) or mss.mss)() # mss.mss is deprecated in newer releases
305
+
306
+ def grab() -> Frame:
307
+ return np.asarray(sct.grab(sct.monitors[monitor]))
308
+
309
+ return grab
310
+
311
+
312
+ def playwright_grabber(page, quality: int = 40) -> Grabber:
313
+ """Browser frames via Playwright (async API). Use SettleConfig(pixel_delta=12) with these frames
314
+ to absorb JPEG noise. Screenshots cost ~50-150 ms each, so for browsers lean mainly on
315
+ `browser_busy_probe`."""
316
+ from PIL import Image
317
+
318
+ async def grab() -> Frame:
319
+ data = await page.screenshot(type="jpeg", quality=quality)
320
+ return np.asarray(Image.open(io.BytesIO(data)).convert("RGB"))
321
+
322
+ return grab
323
+
324
+
325
+ # --------------------------------------------------------------------------- browser busy signal
326
+ _BROWSER_PROBE_JS = """
327
+ (() => {
328
+ if (window.__settle) return;
329
+ const s = window.__settle = { inflight: 0, lastMutation: performance.now() };
330
+ const origFetch = window.fetch;
331
+ window.fetch = function (...args) {
332
+ s.inflight++;
333
+ return origFetch.apply(this, args).finally(() => { s.inflight--; });
334
+ };
335
+ const origSend = XMLHttpRequest.prototype.send;
336
+ XMLHttpRequest.prototype.send = function (...args) {
337
+ s.inflight++;
338
+ this.addEventListener('loadend', () => { s.inflight--; }, { once: true });
339
+ return origSend.apply(this, args);
340
+ };
341
+ new MutationObserver(() => { s.lastMutation = performance.now(); })
342
+ .observe(document, { subtree: true, childList: true, attributes: true, characterData: true });
343
+ })();
344
+ """
345
+
346
+ _BROWSER_BUSY_JS = """
347
+ (quietMs) => {
348
+ const s = window.__settle;
349
+ if (!s) return false;
350
+ if (document.readyState !== 'complete') return true;
351
+ if (s.inflight > 0) return true;
352
+ if (performance.now() - s.lastMutation < quietMs) return true;
353
+ // Finite animations/transitions still running. Infinite ones (CSS spinners) are ignored
354
+ // on purpose, otherwise pages with a permanent spinner would never settle.
355
+ return document.getAnimations().some(a => {
356
+ if (a.playState !== 'running') return false;
357
+ const t = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming() : null;
358
+ return !t || t.endTime !== Infinity;
359
+ });
360
+ }
361
+ """
362
+
363
+
364
+ async def install_browser_probe(page) -> None:
365
+ """Inject the in-page tracker now and on every future navigation (Playwright async API)."""
366
+ await page.add_init_script(_BROWSER_PROBE_JS)
367
+ try:
368
+ await page.evaluate(_BROWSER_PROBE_JS)
369
+ except Exception:
370
+ pass # page may be mid-navigation; the init script covers the next load
371
+
372
+
373
+ def browser_busy_probe(page, mutation_quiet_ms: int = 100) -> BusyProbe:
374
+ async def probe() -> bool:
375
+ try:
376
+ return bool(await page.evaluate(_BROWSER_BUSY_JS, mutation_quiet_ms))
377
+ except Exception:
378
+ return True # execution context destroyed => navigation in progress
379
+
380
+ return probe
381
+
382
+
383
+ # --------------------------------------------------------------------------- self-test
384
+ class _FakeScreen:
385
+ """1080p synthetic screen driven by wall-clock time, for testing without a display."""
386
+ H, W = 1080, 1920
387
+
388
+ def __init__(self, react_at=None, animate_until=None, forever=False, caret=False,
389
+ glyph_at=None, box=None, flash=False):
390
+ self.t0 = time.monotonic()
391
+ self.react_at = react_at
392
+ self.animate_until = animate_until
393
+ self.forever = forever
394
+ self.caret = caret
395
+ self.glyph_at = glyph_at
396
+ self.box = box # optional (x0, y0, x1, y1) pixel region the animation moves within
397
+ self.flash = flash # full-screen animation: the whole screen rewrites every grab
398
+ self.flash_phase = False
399
+
400
+ def now(self) -> float:
401
+ return time.monotonic() - self.t0
402
+
403
+ def grab(self) -> Frame:
404
+ t = self.now()
405
+ f = np.zeros((self.H, self.W, 4), np.uint8) # BGRA like mss
406
+ if self.flash and self.react_at is not None and t >= self.react_at:
407
+ end = float("inf") if self.forever else self.animate_until
408
+ if t < end:
409
+ self.flash_phase = not self.flash_phase # changes on EVERY grab: cannot alias
410
+ if self.flash_phase:
411
+ f[:] = 200 # whole-screen animation: nothing is ever "settled"
412
+ return f
413
+ if self.caret and int(t / 0.25) % 2 == 0:
414
+ f[100:120, 100:103] = 255 # tiny blinking caret (3 cells)
415
+ if self.react_at is not None and t >= self.react_at:
416
+ end = float("inf") if self.forever else self.animate_until
417
+ step = int(min(t, end) * 30) # moves until `end`, then freezes (or runs forever)
418
+ if self.box:
419
+ x0, y0, x1, y1 = self.box
420
+ x = x0 + (step * 37) % max(1, (x1 - x0) - 300)
421
+ f[y0:y1, x:x + 300] = 200 # animation confined to a region (video/spinner)
422
+ else:
423
+ x = (step * 37) % (self.W - 300)
424
+ f[400:700, x:x + 300] = 200 # moving block == animation
425
+ if self.glyph_at is not None and t >= self.glyph_at:
426
+ f[600:616, 300:310] = 255 # one typed character (~4 cells), appears once and stays
427
+ return f
428
+
429
+
430
+ async def _selftest() -> int:
431
+ failures = 0
432
+
433
+ def check(name: str, cond: bool, res: SettleResult) -> None:
434
+ nonlocal failures
435
+ print(f"[{'PASS' if cond else 'FAIL'}] {name}: reason={res.reason} "
436
+ f"waited={res.waited:.2f}s residual={res.residual_motion:.2f}")
437
+ failures += 0 if cond else 1
438
+
439
+ # 1. reaction, animation, then settle (~0.6s animation end + 0.2s quiet)
440
+ s = _FakeScreen(react_at=0.1, animate_until=0.6)
441
+ r = await wait_settled(s.grab, SettleConfig())
442
+ check("animation then settle", r.reason == "settled" and 0.65 <= r.waited <= 1.1, r)
443
+
444
+ # 2. nothing happens -> "no_reaction" after react_deadline, not the full timeout
445
+ s = _FakeScreen()
446
+ r = await wait_settled(s.grab, SettleConfig())
447
+ check("no reaction detected", r.reason == "no_reaction" and 0.3 <= r.waited <= 0.5, r)
448
+
449
+ # 3. never settles -> hard timeout
450
+ s = _FakeScreen(react_at=0.05, forever=True)
451
+ r = await wait_settled(s.grab, SettleConfig(max_wait=1.0))
452
+ check("endless animation hits timeout", r.reason == "timeout" and r.waited >= 1.0, r)
453
+
454
+ # 3b. regression: a single typed character (a few cells) must count as a reaction
455
+ s = _FakeScreen(glyph_at=0.1)
456
+ r = await wait_settled(s.grab, SettleConfig())
457
+ check("one typed character is detected", r.reason == "settled" and r.residual_motion == 0, r)
458
+
459
+ # 4. blinking caret is ignored but reported as residual motion
460
+ s = _FakeScreen(react_at=0.1, animate_until=0.3, caret=True)
461
+ r = await wait_settled(s.grab, SettleConfig(quiet_time=0.6))
462
+ check("caret ignored, residual reported", r.reason == "settled" and r.residual_motion > 0, r)
463
+
464
+ # 5. baseline matters: an instant one-frame change right after the action
465
+ def instant(screen: _FakeScreen):
466
+ def action():
467
+ t = screen.now()
468
+ screen.react_at = t
469
+ screen.animate_until = t # static block appears immediately
470
+ return action
471
+
472
+ s = _FakeScreen()
473
+ r = await act_and_settle(s.grab, instant(s), SettleConfig())
474
+ check("baseline catches instant change", r.reason == "settled" and r.reacted, r)
475
+
476
+ s = _FakeScreen()
477
+ instant(s)()
478
+ r = await wait_settled(s.grab, SettleConfig()) # no baseline -> the pitfall
479
+ check("no baseline misses it (expected pitfall)", r.reason == "no_reaction", r)
480
+
481
+ # 6. busy probe holds the wait while the app is "thinking" with an unchanged screen
482
+ s = _FakeScreen(react_at=0.5, animate_until=0.5)
483
+ cfg = SettleConfig(busy_probe=lambda: s.now() < 0.5)
484
+ r = await wait_settled(s.grab, cfg)
485
+ check("busy probe prevents early exit", r.reason == "settled" and r.waited >= 0.6, r)
486
+
487
+ # 7. adaptive timeouts
488
+ book = LatencyBook()
489
+ for w in (0.3, 0.4, 0.35, 0.5, 0.45, 0.4):
490
+ book.record("app:click", SettleResult("settled", True, w, 0.0, np.zeros((1, 1))))
491
+ mw = book.max_wait_for("app:click")
492
+ ok = 1.0 <= mw < 8.0
493
+ print(f"[{'PASS' if ok else 'FAIL'}] adaptive max_wait: {mw:.2f}s")
494
+ failures += 0 if ok else 1
495
+
496
+ # 8. tolerant path (pixel_delta > 0): +-5 noise is ignored, a real change is not
497
+ rng = np.random.default_rng(0)
498
+ flat = np.full((1080, 1920, 3), 100, np.uint8)
499
+ noisy = np.clip(flat.astype(np.int16) + rng.integers(-5, 6, flat.shape), 0, 255).astype(np.uint8)
500
+ moved = flat.copy()
501
+ moved[300:340, 300:400] = 200
502
+ grid = _mask((1080 // 8, 1920 // 8), [])
503
+ n_noise, box_noise = _changed_blocks(flat, noisy, 12, 8, grid)
504
+ n_real, box_real = _changed_blocks(flat, moved, 12, 8, grid)
505
+ ok = n_noise == 0 and box_noise is None and n_real >= 8 and box_real is not None
506
+ print(f"[{'PASS' if ok else 'FAIL'}] pixel_delta tolerance: noise cells={n_noise}, real-change cells={n_real}")
507
+ failures += 0 if ok else 1
508
+
509
+ # 9. confined animation (video) -> "residual" bail with a bounding box, well before max_wait
510
+ s = _FakeScreen(react_at=0.05, forever=True, box=(1100, 250, 1600, 650))
511
+ r = await wait_settled(s.grab, SettleConfig(max_wait=8.0, residual_bail_after=1.5))
512
+ ok = (r.reason == "residual" and 1.5 <= r.waited <= 3.0 and r.motion_box is not None
513
+ and r.motion_box[0] >= 0.5 and r.reacted)
514
+ print(f"[{'PASS' if ok else 'FAIL'}] confined animation bails early: reason={r.reason} "
515
+ f"waited={r.waited:.2f}s box={r.motion_box}")
516
+ failures += 0 if ok else 1
517
+
518
+ # 9b. a full-screen animation is NOT confined: still hits the timeout, no box shortcut
519
+ s = _FakeScreen(react_at=0.05, forever=True, flash=True)
520
+ r = await wait_settled(s.grab, SettleConfig(max_wait=1.5, residual_bail_after=0.5))
521
+ ok = r.reason == "timeout" and r.waited >= 1.5
522
+ print(f"[{'PASS' if ok else 'FAIL'}] full-screen animation still times out: reason={r.reason} "
523
+ f"waited={r.waited:.2f}s")
524
+ failures += 0 if ok else 1
525
+
526
+ # 10. learning must not override explicit configuration, and must not ignore timeouts
527
+ book = LatencyBook()
528
+ for _ in range(10):
529
+ book.record("click", SettleResult("settled", True, 0.23, 0.0, np.zeros((1, 1))))
530
+ user = SettleConfig(quiet_time=1.0, max_wait=30.0)
531
+ pinned_cfg = book.config_for("click", user, pinned=frozenset({"max_wait"}))
532
+ free_cfg = book.config_for("click", user)
533
+ ok = (pinned_cfg.max_wait == 30.0 and pinned_cfg.quiet_time == 1.0
534
+ and free_cfg.max_wait == 1.0 and free_cfg.quiet_time == 1.0)
535
+ print(f"[{'PASS' if ok else 'FAIL'}] configure() pins beat learning: pinned max_wait={pinned_cfg.max_wait} "
536
+ f"unpinned={free_cfg.max_wait} quiet_time stays {free_cfg.quiet_time}")
537
+ failures += 0 if ok else 1
538
+
539
+ book = LatencyBook()
540
+ for _ in range(8):
541
+ book.record("load", SettleResult("settled", True, 0.23, 0.0, np.zeros((1, 1))))
542
+ before = book.max_wait_for("load")
543
+ for _ in range(3):
544
+ book.record("load", SettleResult("timeout", True, 1.0, 0.0, np.zeros((1, 1))))
545
+ after = book.max_wait_for("load")
546
+ ok = before == 1.0 and after >= 2.9
547
+ print(f"[{'PASS' if ok else 'FAIL'}] timeouts raise the learned cap: {before:.1f}s -> {after:.1f}s")
548
+ failures += 0 if ok else 1
549
+
550
+ # 11. notes are worded per action kind, and timeouts report their spread
551
+ z = np.zeros((1, 1))
552
+ n_wait = SettleResult("no_reaction", False, 1.0, 0.0, z).note_for_model("wait")
553
+ n_click = SettleResult("no_reaction", False, 0.3, 0.0, z).note_for_model("click")
554
+ n_spread = SettleResult("timeout", True, 8.0, 0.0, z, (0.0, 0.0, 1.0, 1.0), spread=True).note_for_model()
555
+ ok = ("Nothing changed" in n_wait and "missed" not in n_wait and "missed its target" in n_click
556
+ and "spread across" in n_spread)
557
+ print(f"[{'PASS' if ok else 'FAIL'}] per-kind notes and timeout spread")
558
+ failures += 0 if ok else 1
559
+
560
+ # 12. a timeout reports where the motion is (this was silently dropped before)
561
+ s = _FakeScreen(react_at=0.05, forever=True, flash=True)
562
+ r = await wait_settled(s.grab, SettleConfig(max_wait=1.0))
563
+ ok = r.reason == "timeout" and r.motion_box is not None and r.spread
564
+ print(f"[{'PASS' if ok else 'FAIL'}] timeout carries motion box: box={r.motion_box} spread={r.spread}")
565
+ failures += 0 if ok else 1
566
+
567
+ print("ALL PASSED" if failures == 0 else f"{failures} FAILED")
568
+ return failures
569
+
570
+
571
+ def main() -> None:
572
+ ap = argparse.ArgumentParser(description="Event-driven UI settle-wait for computer-use agents")
573
+ ap.add_argument("--selftest", action="store_true", help="run synthetic-screen tests")
574
+ args = ap.parse_args()
575
+ if args.selftest:
576
+ sys.exit(1 if asyncio.run(_selftest()) else 0)
577
+ ap.print_help()
578
+
579
+
580
+ if __name__ == "__main__":
581
+ main()