Driving real apps: gotchas for agents
The recurring traps of a long real-app E2E session, each with the workaround tapir has built in.
On this page
Every agent re-discovers these the hard way. Read the page once before driving a non-trivial app; it will save you a dozen dead-end round trips.
Quick Reference#
| Trap | Use |
|---|---|
An e<N> ref fails after a rebuild or navigation. |
Re-snap with tapir:snap, or mint a q<N> handle with tapir:find / tapir:observe. |
A text field snapshots as two nested textbox nodes. |
Target the node marked typeable: true, or use tapir:fill. |
tapir:console misses debugPrint output. |
Nothing to do: TapirPlugin.install() captures it in-package. |
tapir:exceptions keeps reporting an old error. |
Pass --since=<iso8601> recorded before the action. |
| A web restart loses the CDP port. | Nothing to do: restart reuses the recorded cdpPort. |
| A dialog, sheet or menu blocks the next screen. | Run tapir:reset_overlays. |
| A green tick with no visible result. | Read the effect block on the response. |
| A hidden desktop app reports no change. | Nothing by default; read framesForced and warnings on the response. |
1. Refs go stale on rebuild#
e<N> refs from tapir:snap freeze the widget at snapshot time. A navigation,
a modal open/close, a setState, or any significant rebuild invalidates them:
the next action on a stale e<N> fails with a not-found or stale envelope.
Workaround:
- Re-snap (
tapir:snap) after any action that changes the screen, and use the fresh refs. - For a target that survives re-renders (a stable
Text, accessibility label, orKey), prefertapir:find/tapir:observe, which mint a re-resolvableq<N>handle that re-walks the live tree on every action call.q<N>handles survive rebuild, route push, and snapshot disposal as long as the predicates still match. - Pointer verbs (
tapir:tap,tapir:hover,tapir:drag, ...) now dispatch at the element's LIVE rect, not the cached snapshot rect, so a target that merely shifted slots (sameElement/RenderObject) is still hit correctly. The false-success class (gate passes, pointer lands on the stale position,onTapnever fires) is gone for slot shifts. - An
e<N>ref whose widget was rebuilt is relocated by its label when the live tree still has the same number of nodes with that label on the same route, and the response names it underreresolved. When the count differs, or you have navigated to another route, the ref still fails asdefunct (element no longer mounted); re-snap.
2. Text fields may snapshot nested#
A wind WInput (and any TextField wrapped in Semantics(textField: true))
historically snapshotted as TWO nested textbox nodes, because RenderEditable
unconditionally owns its own textField Semantics node and MergeSemantics
cannot absorb it. Agents naturally targeted the inner leaf, where tapir:type
threw a -32000.
Workaround:
tapir:snapnow collapses the nested pair (by render-object containment, never label/value equality, so two sibling fields sharing a label stay distinct) and emits a single ref for the outer node, markedtypeable: true. Target the node carryingtypeable: true.- Better: use
tapir:fill --ref=<ref> --text=<value>, which focuses, clears, types, and settles in one call (and retries once on a stale handle). It resolves the right editable for you and composes the gated focus/clear/type handlers, so you do not re-build the focus + clear + type + settle dance.
3. tapir:console captures debugPrint in-package now#
tapir:console historically surfaced full structured logs only when
crumbs was installed and wired.
Workaround:
TapirPlugin.install()now chains adebugPrintoverride that records everydebugPrint(...)/print(...)call into a bounded in-package ring buffer, so those entries appear intapir:consoleeven without crumbs.- When crumbs IS installed it enriches the output with
Logger.root.onRecordentries and its other watchers. Directdart:developer log()calls that bypassdebugPrintstill require the crumbsLogWatcher.
4. tapir:exceptions is cumulative#
tapir:exceptions returns the full exception history by default, so a single
pre-existing error keeps re-appearing after every action and produces false
positives when you are checking whether YOUR action raised something new.
Workaround:
- Record the current time before the action, then pass
tapir:exceptions --since=<iso8601>afterwards to get only exceptions raised strictly after that timestamp. Unparseablesincevalues are treated as absent (full list).
5. restart preserves the CDP port#
When dowel was started with --cdp-port (the web path that backs
tapir:screenshot CDP fallback, tapir:resize, tapir:device), a naive restart
used to drop the port, breaking those CDP-routed tools until you restarted with
the flag again.
Workaround:
dowel restart(and barestart) now re-read the priorcdpPortfrom this project's session state (state.jsonunder~/.dowel/sessions/) and reuse it as the default, so CDP stays wired across restarts. An explicit--cdp-portstill wins.
6. Overlays get stuck#
A left-over dialog, bottom sheet, dropdown menu, or barrier modal blocks the
next screen from rendering, and a single tapir:modal (dismiss-modals) does not
clear overlays that are not PopupRoutes.
Workaround:
- Use
tapir:reset_overlays, which runs three idempotent layers: dismiss everyPopupRoute, pressEscape(for shortcut-driven overlays), then tap a Cancel/Dismiss/Close/OK/Done affordance as a last resort. Safe to call speculatively between flows; the response (popped,escaped,dismissTapped) tells you which layer cleared the screen.
7. A green tick is dispatch, not effect#
✓ Tapped e7 and ✓ Filled e5 say the event left tapir, not that the widget
took it. A number field can swallow a fill and keep nothing; a button the gate
accepted can have an onTap that never fires.
Workaround:
- Read the
effectblock on the response:effect.changedfortapandscroll,effect.verifiedandeffect.valuefortype,fillandclear. - In summary mode
tapir:fillandtapir:typeexit1wheneffect.verifiedis false, andtapir:navigate,tapir:navigate_backandtapir:scrollsay so when nothing happened. With--jsonor--includeSnapshotyou get the raw envelope and read the field yourself.
8. A hidden desktop app#
A hidden, minimised or occluded window moves the app to AppLifecycleState.hidden
and stops frame production, so a tap used to report "no observable change", a
snapshot went stale and a screenshot returned the last cached frame. This is
not specific to a browser tab.
Workaround:
- Nothing, by default: tapir delivers
resumedand forces a frame at the start of each verb, and a response that did so carriesframesForced: true. Verified on macOS with the window hidden. The app's own lifecycle observers seeresumed. - If a response still carries a
warningsblock, tapir could not force frames (--dart-define=TAPIR_FORCE_FRAMES=false, adetachedapp, a wake that produced no frame, or any hidden web tab). Bring the window to front and retry. - An asleep macOS display can still starve frames;
caffeinate -u -t 3wakes it (unverified for tapir). See Frame production.
Related Documentation#
- tapir:fill and tapir:reset_overlays: the two built-in workarounds for nested text fields and stuck overlays.
- tapir:tap: the
effectblock and--until. - tapir:exceptions and tapir:find: the
--sincedelta and the re-resolvableq<N>handle. - Reference: Actionability gate: why an action is refused and with which reason.
- Reference: Frame production: the hidden-window and backgrounded-tab failure mode.