feat: Phase 5 — maps_navigate, and read the screen before the input

The Maps recipe: open the search box, type the place, and confirm the
result list names it. Every label comes from a live uiautomator dump
(search_omnibox_text_box, typed_suggest_container, the account sheet),
never from a guess. Text only, one step_timeout/total_timeout, guards for
the account sheet.

The first live run exposed an ordering bug in expect_change. A dump is not
instant, so a baseline taken after the input already showed the app's
response and the check could never see a wedge; it failed against a
healthy Maps. The engine now reads the screen before the step above runs
and hands that signature to the check. Each retry re-reads it.

Live: maps_navigate ok in 27.6s, result list names "Melbourne Museum".
The wedge reason and its bounded retry are demonstrated with a probe
recipe whose input changes nothing; a real wedge could not be forced, and
docs/phase-5-maps-recipe.md says so plainly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-16 16:18:06 +11:00
parent 43cd4282d0
commit a7c07c645e
7 changed files with 420 additions and 34 deletions

View File

@@ -50,34 +50,33 @@ func (h *Hierarchy) Signature(foreground string) string {
return foreground + "\n" + strings.Join(parts, "\n")
}
// ExpectChange waits until the screen no longer looks like it did when the
// function was called, and returns the signatures before and after.
// WaitForChange waits until the screen no longer matches before, and returns
// the new signature.
//
// This is the wedged-app check. An app that has stopped consuming input still
// draws the screen it had, so a delivered tap changes nothing. A timeout here
// means the last input was accepted by Android but never acted on by the app,
// which the recipe reports as "the app stopped consuming input" instead of
// blaming the label it was waiting for (AGENTS.md section 11 Phase 5).
func ExpectChange(r *adb.Runner, timeout time.Duration) (before, after string, err error) {
// The caller captures before with ScreenSignature *before* the input it wants to
// judge. That ordering is the whole check: an app that has stopped consuming
// input still draws the screen it had, so the signature after the input is
// identical to the one before it. A dump is not instant, so a baseline taken
// after the input would already include the app's response and could never see
// the wedge.
func WaitForChange(r *adb.Runner, before string, timeout time.Duration) (string, error) {
if strings.TrimSpace(before) == "" {
return "", fmt.Errorf("no screen signature was captured before the input")
}
if timeout <= 0 {
timeout = 10 * time.Second
}
before, err = ScreenSignature(r)
if err != nil {
return "", "", err
}
deadline := time.Now().Add(timeout)
for {
sig, derr := ScreenSignature(r)
if derr != nil {
return before, "", derr
sig, err := ScreenSignature(r)
if err != nil {
return "", err
}
if sig != before {
return before, sig, nil
return sig, nil
}
if !time.Now().Before(deadline) {
return before, before, fmt.Errorf("the screen did not change within %s", timeout)
if time.Now().After(deadline) {
return before, fmt.Errorf("the screen did not change within %s", timeout)
}
time.Sleep(PollInterval)
}