diff --git a/internal/verifier/llm.go b/internal/verifier/llm.go index fd4280e..a090184 100644 --- a/internal/verifier/llm.go +++ b/internal/verifier/llm.go @@ -130,6 +130,22 @@ type ActionCandidate struct { prob float64 } +// transitionalTree reports a NavHost cross-fade: more than one route *Screen +// alive at once. Mirrors runner.isTransitionalHierarchy so the LLM rejects the +// same frames the verifier's transitional handling does. +func transitionalTree(tree *hierarchy.Tree) bool { + screens := 0 + for _, element := range tree.Elements { + if strings.HasSuffix(element.ResourceID, "Screen") { + screens++ + if screens > 1 { + return true + } + } + } + return false +} + // verbActionKind maps a picker verb to the action kind it dispatches. func verbActionKind(verb string) ActionKind { switch verb { @@ -172,6 +188,14 @@ func (v *Verifier) Candidates() []ActionCandidate { if v.lastTree == nil { return nil } + // A cross-fade frame carries more than one route *Screen at once (a NavHost + // mid-transition); its layout is mid-animation, often in a collapsed + // coordinate space, so acting on it taps garbage (e.g. the soft keyboard). + // Skip it so the LLM re-observes a settled frame next step. Mirrors the + // runner's isTransitionalHierarchy. + if transitionalTree(v.lastTree) { + return nil + } root := v.runtime.GlobalObject().Get("actions") if root == nil || goja.IsUndefined(root) || goja.IsNull(root) { return nil diff --git a/internal/verifier/llm_test.go b/internal/verifier/llm_test.go index a1a7510..f689449 100644 --- a/internal/verifier/llm_test.go +++ b/internal/verifier/llm_test.go @@ -235,6 +235,24 @@ func TestCandidatesOffRouteLeafYieldsNothing(t *testing.T) { } } +func TestCandidatesSkipsCrossFadeFrames(t *testing.T) { + // Two route *Screen tags alive at once is a NavHost cross-fade: its layout is + // mid-animation (collapsed coordinate space), so the LLM must NOT act on it. + crossFade := `{ + "attributes": {"bounds": "[0,0,320,640]"}, + "children": [ + {"attributes": {"resource-id": "LedgerScreen", "bounds": "[0,0,320,640]"}, "children": [ + {"attributes": {"resource-id": "TxnSubmit", "text": "Add credit", "bounds": "[20,332,300,380]"}, "clickable": true, "enabled": true, "children": []} + ]}, + {"attributes": {"resource-id": "AddTransactionScreen", "bounds": "[0,0,320,640]"}, "children": []} + ] + }` + v := enumVerifier(t, "{kind:'builtin', verb:'taps'}", crossFade) + if got := v.Candidates(); got != nil { + t.Errorf("cross-fade frame should yield no candidates, got %v", descriptions(got)) + } +} + func TestCandidatesNilWithoutTreeOrActions(t *testing.T) { withActions := newLoadedVerifier(t, "globalThis.actions = {kind:'builtin', verb:'taps'};") if got := withActions.Candidates(); got != nil {