Skip to content

fix(input): reliable synthetic pointer engine — single activation, timestamps, drag hold, hittable flag, screenshot geometry - #59

Merged
charliewwdev merged 4 commits into
ai-dashboad:mainfrom
pierre-cheneau:fix/synthetic-input-engine
Aug 21, 2026
Merged

charliewwdev merged 4 commits into
ai-dashboad:mainfrom
pierre-cheneau:fix/synthetic-input-engine

Conversation

@pierre-cheneau

Copy link
Copy Markdown
Contributor

Problem

Emulated input is unreliable in ways that make agent-driven testing untrustworthy. Reproduced on Windows (Flutter 3.44.9) with a probe app whose state is mirrored into a Text, so every check below is asserted against the app's own state, not screenshots (tools/harness_interaction.py in the linked repro):

  1. Taps activate twice. tap(key|text|ref) dispatches a synthetic pointer tap and then invokes the widget's onTap/onPressed directly as a "fallback". Any hittable target fires twice — a counter goes +2, an expand/collapse toggle visibly does nothing. tap(text) only looked right because a Text has no callback to re-fire.
  2. Pointer events carry no timestamps. All emitted PointerDown/Move/Up had timeStamp = 0. Flutter's VelocityTracker and scroll physics derive velocities from timestamps, so swipe on a PageView/TabBarView either snaps back (< half page) or is left mid-transition; on a real app the stuck drag then swallowed every later tap until a restart. Same in drag, swipe_coordinates, long press. The pointer was also never released if a handler threw.
  3. Actions return on fixed sleeps, not frames. Future.delayed(300ms) after the input; on a heavier app the state query issued right after still saw the pre-tap tree.
  4. Hidden window = hangs. Actions and screenshots await endOfFrame; a minimized/occluded window renders no frames on Windows, so calls hung for minutes.
  5. drag cannot do long-press drags (LongPressDraggable, long-press-to-select lists): it moved immediately after pointer-down. The live schema (engine/tool_registry.dart) had no coordinate form; tool_definitions.dart is a stale second copy.
  6. visible ≠ reachable. inspect/inspect_interactive reported cards under a bottom bar / FAB, or scrolled off-screen, as visible: true; a drag started there landed on the bar.
  7. Screenshots carry no geometry. Images are scaled by max_width while tap_at/swipe take logical coordinates — an easy trap for agents (I fell into it).
  8. Indicator particle painter asserted every frame (Color.withOpacity with a negative alpha): 1000+ entries polluting get_errors.

Fix

SDK (lib/flutter_skill.dart)

  • One synthetic-pointer engine, _emitGesture(from, to, hold, travel), used by tap, long press, swipe, swipe_coordinates, drag and double tap: monotonically increasing timeStamps from a Stopwatch, ~one move per frame, PointerUp in finally, then _settle() — two frames (the one that runs the callbacks and the one that paints the result), each bounded to 500 ms. When no frame arrives (window minimized/hidden) the action returns promptly and its response carries warning: "No frame was rendered after this action; the app window is probably minimized or hidden…".
  • tap(key|text|ref): _isHittableAt(element, center) (RendererBinding.hitTestInView) decides — reachable → pointer only; covered/pointer-ignored → direct callback. Exactly one activation.
  • inspect / inspect_interactive gain hittable (hit test at the element's center); visible keeps its old meaning ("has a size").
  • drag and swipeCoordinates accept hold (ms).
  • Screenshot ext returns imageWidth/Height, logicalWidth/Height, devicePixelRatio.
  • Particle painter alpha clamped.

Server

  • drag tool: start_x/start_y/end_x/end_y, hold_ms, duration_ms in the live registry (and the legacy copy); numeric args arriving as strings are accepted.
  • screenshot adds image_size, logical_size, device_pixel_ratio, scale and a coordinates_hint.
  • tap/tap_at/swipe/drag responses pass the SDK warning through.

Verification

Probe app (TabBar/TabBarView, ListView with a toggling group, counter button, LongPressDraggable → DragTarget, root Listener recording pointer-down positions) driven over MCP stdio; indicators on and off.

Scenario stock 0.9.36 this PR
tap_at accuracy at 4 screen points (incl. under the indicator bar) 8/8 8/8
single activation: text / key / ref / coordinates 2/6 8/8
tab switch by text and by coordinates 4/4 4/4
swipe → next tab, settled 0/2 2/2
state read right after an action is fresh 4/4 4/4
long-press drag onto DragTarget (keys, coordinates) n/a 4/4
screenshot geometry 0/2 2/2
total 20/28 32/32

Also on a real app (Strat·It Playbook, every move undone): template → group header, template → section header, strategy → folder, strategy → strategy (opens create-folder dialog), batch drag in selection mode, edge auto-scroll up/down. Actions got 3–4× faster (frame-settled instead of 300 ms sleeps). Minimized-window case verified: actions return in ~1 s with the warning instead of hanging.

dart analyze clean on the touched files.

Notes for reviewers

…vices

hot_reload called the VM's raw reloadSources RPC. A Flutter app embeds no
kernel compiler, so the VM answers success:false ("Error while starting
Kernel isolate task"); the report was ignored and the tool reported success
while nothing recompiled. hot_restart was the same call (or threw).

The flutter run process registers reloadSources/hotRestart as VM services
(alias "Flutter Tools") - what DevTools and IDEs call. The driver now
tracks registrations from the Service stream and invokes sN.reloadSources /
sN.hotRestart; without an attached tool it checks the ReloadReport and
surfaces a clear error instead of a fake success.

Also:
- re-bind to the new main isolate after a restart (old id -> Sentinel
  Collected on every later call) and self-heal on stale isolates
- wait for ext.flutter.didSendFirstFrameEvent so the tree is populated
- on Windows, hold until the wall-clock second rolls over: Dart truncates
  mtimes to seconds there, so the tool ignores edits made in the same
  second as its last compile ("0 updated files")
- report what happened: "Hot reload performed by Flutter Tools (330ms)"
…g hold; screenshot geometry; hittable flag

All emulated input now goes through one engine that stamps every event
with a monotonically increasing timeStamp (Flutter's velocity tracker and
scroll physics need them - without them a swipe on a PageView/TabBarView
snaps back or is left mid-transition), always releases the pointer, and
returns only after the frame that reflects the input has rendered (state
queries right after an action are no longer stale; fixed sleeps removed).
Frame waits are bounded: a minimized/hidden window renders no frames on
Windows, which used to hang actions and screenshots for minutes; actions
now return promptly and carry a 'warning' explaining the stalled frames.

tap(key/text/ref) dispatched the pointer AND invoked the widget callback
directly, activating twice (toggles cancelled out). The direct call is now
only used when the element is not reachable by hit test at its center.

inspect / inspect_interactive report 'hittable' (hit test at the element's
center): 'visible' only meant 'has a size' and was true for cards hidden
under a bottom bar or scrolled off screen.

drag / swipe_coordinates accept hold_ms so LongPressDraggable and
long-press-to-select lists recognise the drag before the pointer moves;
drag also takes start/end coordinates (numbers or numeric strings).

screenshot returns image_size, logical_size, device_pixel_ratio, scale and
a hint: tools take logical coordinates, not image pixels.

Indicator particle painter clamped its opacity (asserted every frame).
…, lazy scroll_to, scroll direction, batch settle

Sweep of the remaining tool families against a probe app (54 checks,
tools/harness_sweep.py in the linked repro):

- get_current_route/get_navigation_stack: the SDK tested
  `element.widget is ModalRoute` (a Route is never a Widget) so it always
  returned null, and the server treated the null as "tool not handled"
  ("Unknown tool"). Routes are now collected from each route's
  _ModalScopeStatus scope in stack order, with name/key/isCurrent/depth;
  the current route is the topmost current one (nested navigators win).
- press_key: fed KeyData to KeyEventManager, which on desktop waits for the
  paired raw-key message that never comes, and mapped every physical key to
  Enter. Keys now go through HardwareKeyboard + the KeyMessage handler
  (Focus -> Shortcuts/Actions), with correct USB-HID physical keys and held
  modifiers; Enter on a focused field also performs the "done" action.
- New Flutter implementations for tools that were CDP-only: type_text
  (IME-style insert at the selection), focus, blur, set_checkbox (also
  Checkbox/SwitchListTile), hover (one synthetic mouse device).
- scroll_to: pages through every scrollable when the target is not built
  yet (ListView.builder), then ensureVisible.
- scroll_until_visible: 'direction' is the scroll direction, so the finger
  swipe is the opposite way; finishes with ensureVisible (being in the
  cache extent is not being visible).
- execute_batch: 250 ms settle between steps (step_delay_ms).
- Null results from handlers no longer surface as "Unknown tool".
@pierre-cheneau

Copy link
Copy Markdown
Contributor Author

Pushed a second commit (7e911fe) from a sweep of the remaining tool families against the probe app (54 checks, all passing): real get_current_route/get_navigation_stack (the SDK tested element.widget is ModalRoute, never true; the server read the null result as "Unknown tool"), working press_key (KeyData was queued in KeyEventManager waiting for a raw-key message; keys now go through HardwareKeyboard + the KeyMessage handler with correct physical keys), Flutter implementations for type_text/focus/blur/set_checkbox/hover, scroll_to for lazily built rows, scroll_until_visible direction semantics + ensureVisible, and a settle between execute_batch steps.

… a modifier is held

The tool handler dropped 'modifiers', so Ctrl+A arrived as a plain 'a'
(and was inserted as text). Modifiers now reach the SDK, and while one is
held the key event carries no character, as real platforms do — Ctrl+A
selects all, and type_text then replaces the selection.
@charliewwdev
charliewwdev merged commit de1a008 into ai-dashboad:main Aug 21, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants