The Maps omnibox hint rotates: it read "Search here" in the Phase 5 run and "Try petrol stations, ATMs" in the AUD-128 re-verification, so the recipe's text match on it failed. The resource id does not rotate. - internal/ui: add Find/FindID and the "id:" selector; Find dispatches an id: selector to the resource id and everything else to text as before. - internal/recipe: wait/tap/wait_gone/tap_if and the tap helper use Find, so a recipe may name a resource id. Guards stay text-only. - recipes/maps_navigate.recipe: wait/tap id:search_omnibox_text_box. - tests for the id selector; docs and README updated. Co-Authored-By: Paperclip <noreply@paperclip.ing>
14 KiB
Phase 5 — the Maps recipe, and the wedged-app check
Phase 5 of AGENTS.md section 11: the maps_navigate recipe, plus the wedged-app
detection the task asked for.
Date: 2026-10-16. Phone: Pixel_8a (akita) over Tailscale at
100.101.49.17:46689 (the port moves on every reboot; nothing hardcodes it).
What was built
| File | What it holds |
|---|---|
recipes/maps_navigate.recipe |
The recipe: open the search box, type the place, confirm the result list names it. |
internal/ui/dump.go |
Find / FindID: the id: selector, for a label that rotates. |
internal/ui/change.go |
ScreenSignature and WaitForChange: the screen fingerprint and the bounded change check. |
internal/recipe/recipe.go |
The new expect_change <duration> verb. |
internal/recipe/engine.go |
Runs the check, and reads the screen before the step above it. |
internal/ui/change_test.go, internal/recipe/recipe_test.go |
Unit tests for the fingerprint and the verb. |
The recipe
One file, plain text, no coordinates anywhere (FR-4):
task: maps_navigate
app: com.google.android.apps.maps
param: query
guard "Manage your Google Account" => Maps is showing its account sheet; press back on the phone and retry
guard "Turn on Incognito mode" => Maps is showing its account sheet; press back on the phone and retry
steps:
- launch
- foreground 30s
- wait id:search_omnibox_text_box 30s retry 2
- tap id:search_omnibox_text_box
- expect_change 15s retry 2
- type "{query}"
- expect_change 15s retry 2
- wait "{query}" 30s else "the Maps result list did not name the place"
Run it with:
./bin/artemis run maps_navigate query="Melbourne Museum"
The search box is addressed by id, not by its label
The first version of the recipe waited for the text Search here. On
2026-10-17 a re-verification run failed at that step: the box was on screen,
but its label had rotated to Try petrol stations, ATMs:
step 3 wait [Search here]: element matching "Search here" did not appear within 30s;
foreground=com.google.android.apps.maps;
screen shows: "Layers", "Enter compass mode", "Directions", "Ivanhoe", ...
The Maps omnibox carries its hint as a rotating content description, so a text match on it is not stable. A fresh dump of the live node shows the stable half and the rotating half side by side:
<node resource-id="com.google.android.apps.maps:id/search_omnibox_text_box"
class="android.widget.EditText" content-desc="Try petrol stations, ATMs"
clickable="true" enabled="true" bounds="[31,142][663,268]"/>
The recipe now names the resource id with the id: selector:
wait id:search_omnibox_text_box and tap id:search_omnibox_text_box. This is
still a named match with the tap coordinate derived from the matched element's
bounds — not a fixed coordinate, so FR-4 holds. internal/ui/dump.go dispatches
Find: a selector that starts with id: is matched against the resource id
(full or short name), and every other selector against text and content
description as before. guards remain text-only, because a guard names visible
screen text.
The rule to keep: use a text selector where a stable label exists, and an
id: selector where the label rotates. The final wait "{query}" stays a
text match on purpose — the result row must name the place the caller asked for.
The labels came from the phone, not from a guess
A fresh uiautomator dump of the Maps home and the search screen on the real
phone:
# Map home
[EditText] Search here id=search_omnibox_text_box bounds=[31,142][663,268]
[View] Voice search bounds=[663,142][789,268]
[View] Lens in Maps bounds=[789,142][915,268]
[FrameLayout] Account and settings. bounds=[915,142][1049,268]
[FrameLayout] Explore / You / Contribute / Business bounds=[0,2168][1080,2337]
# Search screen, after tapping "Search here"
[Button] Navigate up bounds=[31,142][157,268]
[EditText] Search here id=search_omnibox_text_box bounds=[157,142][921,268]
[EditText] <query> id=search_omnibox_edit_text bounds=[157,142][921,268]
[Button] Voice search bounds=[923,142][1049,268]
[ImageView] Clear bounds=[954,173][1017,236]
[RecyclerView] <results> id=typed_suggest_container bounds=[0,284][1080,1577]
# One suggestion row in that list
[TextView] 7.6 km bounds=[0,378][210,431]
[TextView] Melbourne Museum bounds=[210,296][668,366]
[TextView] Nicholson Street, Carlton VIC bounds=[210,366][823,429]
[FrameLayout] Activate to enter suggestion Melbourne Museum into search bar
bounds=[933,300][1059,426]
Three things this dump settled:
- The search box is the
EditTextlabelledSearch here(resource idsearch_omnibox_text_box). Tapping it opens the search screen with the box focused. - Typing shows the results list immediately; there is no separate "press enter"
step. The list is the
RecyclerViewwith idtyped_suggest_container. - Each suggestion row carries the place as its title and as the label of its
own row button:
Activate to enter suggestion <place> into search bar. So the finalwait "{query}"matches a real row, not the empty box. Because that row button is clickable and small, it is the match the run log reports.
The account sheet was opened on purpose to take its labels for the guards
(Manage your Google Account, Turn on Incognito mode). Maps on this phone is
signed in as samuelrolfe@gmail.com, so there is no sign-in screen in the
search path and no guard was invented for one.
The wedged-app check (expect_change)
An app can stop consuming input while it keeps drawing: Android delivers the
tap, the app never acts on it (docs/phase-3-spotify-recipe.md, attempts 9, 10
and 13). Before Phase 5 the recipe reported that as the label it was waiting
for, which names the wrong problem.
expect_change <duration> reads the screen, waits a bounded time for it to
change, and fails with the distinct reason:
the app stopped consuming input: the screen did not change within 6s;
foreground=com.google.android.apps.maps; screen shows: "Layers", ...
The screen fingerprint is the foreground package plus every visible label and
its bounds, sorted so that the order uiautomator happens to report never looks
like a change (internal/ui/change.go).
The baseline must be read before the input
The first live run of the recipe failed the check against a perfectly healthy Maps:
step 4 tap [Search here]: ok (5.463s)
step 5 expect_change [15s]: the app stopped consuming input: the screen did not change within 15s
The tap had worked and the search screen had opened. The bug was ordering: a
uiautomator dump is not instant, so a baseline taken after the tap already
showed the app's response, and the "after" screen could never differ.
The engine now reads the screen immediately before the step above runs, and
hands that signature to the expect_change step that follows it
(internal/recipe/engine.go). A dump runs a JVM on the phone, so this read
happens only when a recipe asks for the check. Each retry re-reads the screen
before it re-presses the step above.
The live run
$ ./bin/artemis run maps_navigate query="Melbourne Museum"
task maps_navigate (Search Google Maps for a place and show the matching result list.)
step 1 launch []: ok (616ms)
step 2 foreground []: ok (157ms)
step 3 wait [Search here]: ok (2.672s)
step 4 tap [Search here]: ok (5.647s)
step 5 expect_change [15s]: ok (3.156s)
step 6 type [Melbourne Museum]: ok (447ms)
step 7 expect_change [15s]: ok (3.077s)
step 8 wait [Melbourne Museum]: ok (2.692s)
OK maps_navigate in 27.606s
The run log (logs/runs.jsonl) records "ok":true, and the step that proves the
DoD reports:
{"step":8,"verb":"wait","args":["{query}"],
"detail":"found android.widget.FrameLayout[-] text=\"Activate to enter suggestion Melbourne Museum into search bar\" bounds=[933,300][1059,426]",
"ok":true,"duration_ms":2692}
And the screen after the run, read back with a fresh dump — the result list names the searched place:
$ ./bin/artemis dump
38 text nodes
[TextView] 7.6 km bounds=[0,378][210,431]
[TextView] Melbourne Museum bounds=[210,296][668,366] <- the place, first row
[TextView] Nicholson Street, Carlton VIC bounds=[210,366][823,429]
[FrameLayout] Activate to enter suggestion Melbourne Museum into search bar
[TextView] 7.6 km bounds=[0,539][210,592]
[TextView] Melbourne Museum - Museum Market Cafe bounds=[210,457][923,527]
[TextView] Nicholson Street, Carlton VIC bounds=[210,527][823,590]
[TextView] 7.5 km bounds=[0,700][210,753]
[TextView] Melbourne Museum - Museum Shop bounds=[210,618][923,688]
[TextView] Nicholson Street, Carlton VIC bounds=[210,688][823,751]
[TextView] 8.2 km bounds=[0,861][210,914]
[TextView] Museum of Chinese Australian History bounds=[210,779][923,849]
[TextView] Cohen Place, Melbourne VIC bounds=[210,849][803,912]
[TextView] 7.6 km bounds=[0,1022][210,1075]
[TextView] Children's Gallery (Melbourne Museum) bounds=[210,940][923,1010]
[TextView] Carlton VIC bounds=[210,1010][451,1073]
The wedge reason, demonstrated live
A real wedged app could not be reproduced in Phase 5: the Phase 3 wedge came from a memory-starved phone under load, and it cannot be forced on demand. What was verified instead is the detection path itself, with a probe recipe (not shipped) whose step above is a real input that Maps answers with no screen change — an ENTER key event on the map home, where nothing has focus:
$ ARTEMIS_RECIPES=<probe dir> ./bin/artemis run maps_wedge_probe
step 4 key [enter]: ok (150ms)
step 5 expect_change [6s]: the app stopped consuming input: the screen did not change within 6s; foreground=com.google.android.apps.maps; screen shows: "Layers", "Re-centre map to your location", "Directions", "Carlton", "Mostly cloudy, 23°, Good, AQC", "23°"
FAILED maps_wedge_probe after 17.857s: ...
The bounded retry was verified in the same way (expect_change 6s retry 2):
the engine re-pressed the step above twice more and then stopped, three attempts
in total, so a wedged app ends the run instead of looping (FR-11):
step 5 expect_change [6s]: the app stopped consuming input: ...
step 5 expect_change: retrying, pressing key again
step 4 key [enter]: ok (223ms)
step 5 expect_change [6s]: the app stopped consuming input: ...
step 5 expect_change: retrying, pressing key again
step 4 key [enter]: ok (150ms)
step 5 expect_change [6s]: the app stopped consuming input: ...
FAILED maps_wedge_probe after 46.099s: ...
Limit, stated plainly. The check cannot tell a wedged app from an input that
legitimately changes nothing: it keys on "the screen did not change". A recipe
must place expect_change only after an input that must change the screen. The
maps_navigate recipe does that twice — the tap that opens the search screen,
and the typing that draws the result list.
Verification status
| Item | State |
|---|---|
| Screen fingerprint, stability and sensitivity | unit tested (internal/ui) |
expect_change parsing, retry, else |
unit tested (internal/recipe) |
| All shipped recipes parse | unit tested (internal/recipe) |
maps_navigate opens a real search and names the place |
live, verified — 8 steps, 27.6s, result list above |
maps_navigate re-verified after the label rotated |
live, verified — 8 steps, 27.2s, id selector; see “Re-verification 2026-10-17” below |
| The wedge reason fires on an input that changes nothing | live, verified — probe above |
| A wedged app was reproduced | no — could not be forced; see the limit above |
The wedge check is bounded by retry |
live, verified — 3 attempts, then stop |
Re-verification 2026-10-17
AUD-128 re-ran the recipe on the current tree and the current phone session.
The text selector failed (the label had rotated, above), so the recipe moved to
the id: selector and the run went green:
$ ./bin/artemis run maps_navigate query="Melbourne Museum"
task maps_navigate (Search Google Maps for a place and show the matching result list.)
step 1 launch []: ok (676ms)
step 2 foreground []: ok (166ms)
step 3 wait [id:search_omnibox_text_box]: ok (2.795s)
step 4 tap [id:search_omnibox_text_box]: ok (5.768s)
step 5 expect_change [15s]: ok (3.21s)
step 6 type [Melbourne Museum]: ok (420ms)
step 7 expect_change [15s]: ok (3.479s)
step 8 wait [Melbourne Museum]: ok (2.992s)
OK maps_navigate in 27.177s
The run log records "ok":true, and the wedge probe fired on a live no-op input
(key escape on the map home), three attempts in total, then stopped:
step 3 key [escape]: ok (151ms)
step 4 expect_change [6s]: the app stopped consuming input: the screen did not change within 6s;
foreground=com.google.android.apps.maps; screen shows: "Layers", "Enter compass mode", ...
step 4 expect_change: retrying, pressing key again
step 3 key [escape]: ok (207ms)
step 4 expect_change [6s]: the app stopped consuming input: ...
step 4 expect_change: retrying, pressing key again
step 3 key [escape]: ok (180ms)
step 4 expect_change [6s]: the app stopped consuming input: ...
FAILED maps_wedge_probe after 44.346s: ...
Reproducing it
cd ~/paseo/projects/artemis_control
./bin/artemis run maps_navigate query="Melbourne Museum"
tail -1 logs/runs.jsonl
./bin/artemis dump
The phone must be awake and unlocked (a secure keyguard is a loud refusal,
AGENTS.md section 13 Q2). From the map home this takes about 28 seconds.
Not done
- The recipe stops at the result list, which is what the Phase 5 DoD asks for. It does not tap a result to open the place, so it does not start navigation.
- Phase 6 (the action API) and Phase 7 (resilience) have not been started, on instruction.