Skip to content

feat(flow): clear on the type directive - #581

Open
hubgan wants to merge 8 commits into
feat/keyboard-clearfrom
feat/flow-type-clear
Open

feat(flow): clear on the type directive#581
hubgan wants to merge 8 commits into
feat/keyboard-clearfrom
feat/flow-type-clear

Conversation

@hubgan

@hubgan hubgan commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #580, which is stacked on #579. Base is feat/keyboard-clear, not main - review the top two commits.

Problem

A flow that replays against a non-empty field types on top of the old value - a remembered login, a restored draft, a fragment re-run without a launch, a retry after a failed step. It does not fail at that step. It fails later at an assert, pointing at the wrong place.

- type:
    into: { text: Email }
    text: "new@example.com"
    clear: true

Threaded through the step union, the YAML shape, parse, serialize, the runner, the run-report label and the recording summary.

Two semantics decisions

  • text becomes optional when clear is set. type: { into: search, clear: true } - empty the box, then assert the empty state - is a legitimate step. A missing or empty text without clear still rejects exactly as before.
  • submit defaults to false when there is no text (and stays true otherwise). Enter into a field the step just emptied would run an empty search or submit a blank form. It is stored and serialized only when it differs from that default, so YAML round-trips unchanged.

clear is in the rejectUnknownKeys allowlist, so a misspelled claer fails loudly rather than silently reverting the step to an append.

Why the Enter is still a separate keyboard call

The Enter cannot be collapsed into the same call: since #579 the keyboard tool rejects a request carrying both text and key outright, so two calls are the only way to express "type, then submit".

That applies to key only. clear is explicitly allowed alongside text, so the clear rides the same call as the text — which is what makes a rejected text leave the field intact. See the review round below.

There is no post-clear read-back check (second commit)

The first cut verified the clear by re-reading the focused node and failing the step if it still held text. That is removed. It was blind exactly where it mattered and wrong where it fired:

  • iOS never carries a value - flow-ios-tree projects {role, frame, children, label, identifier, focused} and never requests a text field. Dead on the whole platform.
  • Chromium <input> has no value either: the DOM walker fills it from the element's own child text nodes, and an input has none.
  • Android without a contentDescription puts the contents in label (labelOf falls back to text) - i.e. most React Native TextInputs.
  • Where it did fire it was often wrong: an emptied Android field with a contentDescription reports its hint as the value (so a successful clear reads as leftover text), and a Chromium <textarea> exposes its default content once el.value empties.

Both halves were demonstrated on hardware. At the time, a detection bug (since fixed in #580) made the clear genuinely fail on an API 30 emulator - "wifi" -> "wif" - and the read-back check returned undefined and passed the step anyway, i.e. the exact append it existed to prevent. Android now clears correctly on those levels; the point stands that the check could not see the failure.

The silent-no-op risk lives at the tool layer, which now rejects clear outright on any backend that cannot perform it (see #580). Flows wanting proof should assert the field's value - the flow language's own idiom, which works everywhere. The skill docs previously promised the step "fails immediately rather than appending"; that claim is gone, replaced with the assert guidance, since leaving it would stop an agent adding the check that actually works.

Removing the read-back also removed its abort-guard gap (a run cancelled between the clear and the read was reported as a step failure rather than a skip) and its unsettled single-read race.

Live-device verification

Full flow run against Bluesky on a Pixel_3a (API 36), branch build on ARGENT_PORT=3999. The flow deliberately demonstrates the bug and the fix side by side:

OK: True | passed 8 failed 0
   0 await   pass  visible id=loginUsernameInput
   2 assert  pass  ... equals "Username or email address hubgan.bsky.social"           <- pre-populated
   4 type    pass  into id=loginUsernameInput                                           <- no clear
   5 assert  pass  ... equals "Username or email address hubgan.bsky.social.appended"   <- APPENDED
   7 type    pass  into id=loginUsernameInput (cleared first)
   8 assert  pass  ... equals "Username or email address replaced.by.clear"             <- REPLACED
  10 type    pass  into id=loginUsernameInput (cleared first)
  11 assert  pass  ... equals "Username or email address"                               <- emptied

Local checks: npm test -w @argent/tool-server (3124 passed, 1 skipped), npm run lint, npm run build, npm run typecheck:tests -w @argent/tool-server, prettier --check.

Skill docs

Per the repo note, only packages/skills/skills/ is edited (packages/argent/skills is generated).

  • argent-create-flow - the type table row, the submit/clear paragraph, the Vega note.
    (The skill docs for the keyboard tool's own clear - argent-device-interact, argent-tv-interact, argent-test-ui-flow - ship in feat(tool-server): clear on the keyboard tool #580 alongside the parameter itself, so that PR stands on its own. Only the flow directive's docs are here.)
  • flow-execute's tool description documents type's options for the first time (it already spelled out tap, long-press, scroll-to, pinch, rotate and snapshot).
  • The load-bearing one: argent-create-flow's recording-cleanup rule now carries clear from a recorded tool: keyboard step into the type: directive. Without it the polish step drops clear and the cleaned flow reverts to appending - a one-line docs omission that produces a data bug.

Known follow-up (not addressed here)

argent-create-flow/SKILL.md was already ~1850 words over the 5000-word skill cap before this branch and this adds ~240 more. Trimming it is owned separately; this PR sticks to table cells and the one paragraph in the section that already explains submit.


Review round (bb6e3fed, 8057e428, 20cbed86, 6a2aed5d)

Review found two ways clear could destroy the value it was meant to replace. Both were reproduced on a Pixel 3a against Bluesky, and both re-verified fixed on the same device.

clear and text now ride one keyboard call

Every backend validates the whole request before touching the device precisely so a rejected call leaves no trace — assertTypeableAndroidText, and the per-character resolves in the chromium and simulator-server backends, all run ahead of the clear. Two calls stepped outside that guarantee. Same arguments, same device, same error:

call shape result field afterwards
tool: keyboard { clear: true, text: "José" } error, character "é" … can't be typed ORIGINAL — untouched
type: { into, text: "José", clear: true } (two calls) error, identical message emptied
type: { into, text: "José", clear: true } (one call, after the fix) error, identical message ORIGINAL — untouched

Splitting is forced for key, which the tool refuses to accept next to text (#579), and not for clear, which it accepts — so folding the clear in with the text costs nothing. Enter stays a separate call, and the comment now says which of the two that is about.

A clear no longer runs on an unverified focus

waitForFocus returned void on timeout exactly as on success, so the clear was dispatched whether or not focus ever reached into. It now reports its outcome, and a clear whose focus landed elsewhere fails the step instead of emptying whatever else holds it.

The outcome keys off whether any node in the tree reported focus, not off the tree source. That distinction is load-bearing: an iOS build whose injected framework predates firstResponder answers getFullHierarchy without it, so no node is ever flagged — the first cut treated that as "focus is elsewhere" and refused every clear on the platform, caught on an iPhone 16 Pro before it went anywhere.

The verdict follows the most recent successful read, not "focus was seen at some point". The sticky form made it a race: an app that blurs when the tap lands reports the previous field as focused for however many rounds precede the blur, so the same flow against the same app hard-failed or passed depending on whether round 1 beat the blur.

Known residual, deliberate: an app that blurs on an outside tap leaves nothing focused, which is indistinguishable from a tree that cannot report focus, so that clear still goes through as a no-op and the step still passes. A clear with nothing focused loses no data; clearing the wrong field does.

One sharp edge is documented rather than guessed at: the focus test is an overlap, so a focus-flagged node large enough to cover the target confirms it by construction (an Android host WebView, an iOS first-responder container, a Chromium focus-trap div). On Chromium that is benign — one activeElement per document, so a focused container means no input holds focus and the clear no-ops. The destructive shape needs two focused nodes at once, which only the Android/iOS adapters can emit and which has not been observed on a real device; tightening the test to containment would break the legitimate cases it exists for (focus reported on the input inside a testID container, or the selector matching a label inside the focused field).

Also

  • runType reclassifies a dispatch that rejects mid-cancellation as an aborted skip, matching runRotate — a cancelled run no longer surfaces a raw backend message as a step failure. runRotate now shares that helper instead of its own inline copy.
  • The report label is (clear first), not (cleared first): it is stamped before the step runs and rides every outcome, so the past tense claimed a clear on steps that failed, errored or were skipped. The recording summary keeps (cleared) — there, every step really did run as it was recorded.
  • Two arms were pinned by nothing, each found by mutation: dispatchOrAbort swallowing every backend rejection as "run aborted" passed all 3131 tests, and mapping a non-focus-reporting source to "unconfirmed" (refusing clear on every target behind one) passed as well. Both now fail.

Corrected claims

type does not "append". The focusing tap lands the caret mid-field, so on Android the new text is spliced into the old value. Measured on the Pixel 3a — field holding forty As, then type: { text: "ZZZ" }:

AAAAAAAAAAAAAAAAAAAAAAAAAAZZZAAAAAAAAAAAAAA

On iOS the caret jumps to the end and it really does append (verified separately on the iPhone 16 Pro, both a WebKit <input> and a UIKit UITextField). Both are now stated per-platform — and in the four other places that still taught the old model, including the keyboard tool's own description, which sits beside flow-execute's in the same tool list and stated the opposite of it.

The suggested assert does not work everywhere. Measured on the iPhone 16 Pro: after typing hello-probe into Bluesky's search box, assert: { text: { in: <field>, contains: "hello-probe" } } fails with its text was "Search" — iOS never exposes a field's contents to the flow tree. On Chromium the same assert hard-fails (it reads the accessible name); only a label-matching assert passes, and it proves nothing. On Android the contents are exposed, paired with the hint when a contentDescription is present and alone when it is not. The skill now spells this out per platform and suggests asserting the consequence where the contents are invisible.

Two smaller doc corrections: the Vega rejection is attributed to the directive gate that actually performs it (the whole type directive is rejected there, clear or not), and the recording-cleanup rule carries a recorded key: "enter" across as submit: true, which a clear-only type would otherwise silently drop.

Verification

Full flow run on the Pixel 3a, branch build on ARGENT_PORT=3999, asserting with equals so any leftover would fail:

OK: True | passed 6 failed 0
  0 type    pass  into id=loginUsernameInput (clear first)
  1 assert  pass  ... equals "Username or email address hubgan.bsky.social"
  2 type    pass  into id=loginUsernameInput (clear first)
  3 assert  pass  ... equals "Username or email address replaced.by.clear"   <- REPLACED
  4 type    pass  into id=loginUsernameInput (clear first)
  5 assert  pass  ... equals "Username or email address"                     <- emptied

The atomicity bug re-run against the fixed build: type: { into, text: "José", clear: true } still errors on the é, but the field now still holds ORIGINAL instead of being emptied.

The focus refusal is pinned by unit tests rather than on that device, and deliberately so: tapping outside a field in Bluesky blurs it, so nothing holds focus and the step lands in the documented residual above (the clear no-ops, the step still passes) rather than in the destructive case the guard exists for. The over-broad first cut did refuse it there — which is exactly how it also refused every legitimate clear on iOS.

iPhone 16 Pro: plain type, clear+replace and clear-only all pass against Bluesky's search box, the box visibly empty after the clear-only step.

Local: npm test -w @argent/tool-server (3134 passed, 1 skipped), npm run lint, npm run build, npm run typecheck:tests -w @argent/tool-server, npm run test:scripts (59), prettier --check, grade-skills.mjs 10/10, and the SpiderShield tool-description gate at 9.10 against its 9.0 threshold.


Rebased onto the reworked #580/#579, where the keyboard tool now rejects { text, key } in one call. That only strengthens the split this PR keeps: runType's Enter has to be its own call, while clear still rides with the text. The runType comment is updated to cite the rejection rather than the typeTv argument it used to lean on.

@hubgan
hubgan force-pushed the feat/flow-type-clear branch 3 times, most recently from fbc1366 to 09882fa Compare July 29, 2026 09:04
@hubgan
hubgan marked this pull request as ready for review July 29, 2026 09:24
@hubgan
hubgan requested a review from latekvo July 29, 2026 09:24

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: Reviewed the top two commits against feat/keyboard-clear (5e99a240), not main.

Verification run: tsc --build, eslint, prettier --check, typecheck:tests, and npx vitest run test/flows/ (33 files / 564 tests) all green on the head; grade-skills.mjs 10/10; the tool-description-quality SpiderShield gate at 9.10 against its 9.0 threshold. The parse/serialize surface was walked exhaustively — all 27 cells of {text absent / non-empty / empty} x {clear absent / true / false} x {submit absent / true / false} either reject with an accurate message or round-trip to a behaviour-preserving fixpoint, and every flow YAML valid before this branch parses to identical runtime behaviour.

The feature itself was exercised end-to-end on a real Chromium device through flow-execute: type without clear leaves old.remembered.loginold.remembered.loginnew@example.com, clear: true replaces it with new@example.com, a clear-only step empties the field and sends no Enter, and the run report labels the step (cleared first). when-guarded and run:-composed clear steps, the recording path, and adjacent snapshot steps all behaved correctly, and no {{secret:…}} plaintext reaches any surface this branch touches.

Comments inline.

Comment thread packages/tool-server/src/tools/flows/flow-actions.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-actions.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-actions.ts Outdated
Comment thread packages/skills/skills/argent-create-flow/SKILL.md Outdated
Comment thread packages/tool-server/src/tools/flows/flow-actions.ts Outdated
Comment thread packages/tool-server/test/flows/flow-type-clear-runner.test.ts Outdated
Comment thread packages/tool-server/test/flows/flow-type-clear-runner.test.ts Outdated
hubgan added a commit that referenced this pull request Jul 29, 2026
Review of #581 surfaced two ways the `type` directive's `clear` could
destroy data it was meant to replace. Both reproduced on a Pixel 3a
against Bluesky, and both re-verified fixed on the same device.

Clear and text now ride ONE `keyboard` call. Every backend validates the
whole request before touching the device precisely so a rejected call
leaves no trace — `assertTypeableAndroidText`, and the per-character
resolves in the chromium and simulator-server backends, all run ahead of
the clear. Issuing them separately stepped outside that guarantee:
`{ into: field, text: "José", clear: true }` cleared, then rejected the
text, leaving the field EMPTY where the identical args as a single call
left it holding its original value. Enter stays a separate call — the
`typeTv` argument for splitting applies to `key`, which it rejects
outright, and not to `clear`, which it rejects just as early.

`waitForFocus` now reports its outcome instead of discarding it, and a
clear whose focus landed elsewhere fails the step rather than emptying
whatever else holds it — previously reported as a pass, labelled as a
clear, on a field it never touched. The outcome keys off whether any node
in the tree reported focus, not off the source: an iOS build whose
injected framework omits `firstResponder` reports none at all, and
conflating that with "focus is elsewhere" refused every clear on the
platform.

`runType` also reclassifies a dispatch that rejects mid-cancellation as
an aborted skip, matching `runRotate` and `runLaunch`; a cancelled run
no longer surfaces a raw backend message as a step failure.

Docs: `type` does not "append" — the focusing tap lands the caret mid
field, so on Android the new text is spliced INTO the old value (forty
`A`s typed with `ZZZ` gives `AAA…ZZZ…AAA`), while on iOS it appends.
The suggested `assert` on a field's text does not work everywhere
either: iOS never exposes a field's contents to the flow tree, and a
Chromium `<input>` only does so without an aria-label or placeholder.
Both stated per-platform now, with the Vega rejection attributed to the
directive gate that actually performs it, and the recording-cleanup rule
carrying a recorded Enter across as `submit: true`.

The run report says `(clear first)` rather than `(cleared first)`: the
label is stamped before the step runs and rides every outcome, so the
past tense claimed a clear on steps that failed, errored or were skipped.
@hubgan
hubgan requested a review from latekvo July 29, 2026 14:37
@hubgan
hubgan force-pushed the feat/keyboard-clear branch from 9c5746f to 29f03e3 Compare July 30, 2026 11:52
hubgan added a commit that referenced this pull request Jul 30, 2026
Review of #581 surfaced two ways the `type` directive's `clear` could
destroy data it was meant to replace. Both reproduced on a Pixel 3a
against Bluesky, and both re-verified fixed on the same device.

Clear and text now ride ONE `keyboard` call. Every backend validates the
whole request before touching the device precisely so a rejected call
leaves no trace — `assertTypeableAndroidText`, and the per-character
resolves in the chromium and simulator-server backends, all run ahead of
the clear. Issuing them separately stepped outside that guarantee:
`{ into: field, text: "José", clear: true }` cleared, then rejected the
text, leaving the field EMPTY where the identical args as a single call
left it holding its original value. Enter stays a separate call — the
`typeTv` argument for splitting applies to `key`, which it rejects
outright, and not to `clear`, which it rejects just as early.

`waitForFocus` now reports its outcome instead of discarding it, and a
clear whose focus landed elsewhere fails the step rather than emptying
whatever else holds it — previously reported as a pass, labelled as a
clear, on a field it never touched. The outcome keys off whether any node
in the tree reported focus, not off the source: an iOS build whose
injected framework omits `firstResponder` reports none at all, and
conflating that with "focus is elsewhere" refused every clear on the
platform.

`runType` also reclassifies a dispatch that rejects mid-cancellation as
an aborted skip, matching `runRotate` and `runLaunch`; a cancelled run
no longer surfaces a raw backend message as a step failure.

Docs: `type` does not "append" — the focusing tap lands the caret mid
field, so on Android the new text is spliced INTO the old value (forty
`A`s typed with `ZZZ` gives `AAA…ZZZ…AAA`), while on iOS it appends.
The suggested `assert` on a field's text does not work everywhere
either: iOS never exposes a field's contents to the flow tree, and a
Chromium `<input>` only does so without an aria-label or placeholder.
Both stated per-platform now, with the Vega rejection attributed to the
directive gate that actually performs it, and the recording-cleanup rule
carrying a recorded Enter across as `submit: true`.

The run report says `(clear first)` rather than `(cleared first)`: the
label is stamped before the step runs and rides every outcome, so the
past tense claimed a clear on steps that failed, errored or were skipped.
@hubgan
hubgan force-pushed the feat/flow-type-clear branch from 6a2aed5 to f241e1c Compare July 30, 2026 11:52
@hubgan
hubgan force-pushed the feat/keyboard-clear branch from 29f03e3 to e227319 Compare July 30, 2026 13:01
hubgan added a commit that referenced this pull request Jul 30, 2026
Review of #581 surfaced two ways the `type` directive's `clear` could
destroy data it was meant to replace. Both reproduced on a Pixel 3a
against Bluesky, and both re-verified fixed on the same device.

Clear and text now ride ONE `keyboard` call. Every backend validates the
whole request before touching the device precisely so a rejected call
leaves no trace — `assertTypeableAndroidText`, and the per-character
resolves in the chromium and simulator-server backends, all run ahead of
the clear. Issuing them separately stepped outside that guarantee:
`{ into: field, text: "José", clear: true }` cleared, then rejected the
text, leaving the field EMPTY where the identical args as a single call
left it holding its original value. Enter stays a separate call — the
`typeTv` argument for splitting applies to `key`, which it rejects
outright, and not to `clear`, which it rejects just as early.

`waitForFocus` now reports its outcome instead of discarding it, and a
clear whose focus landed elsewhere fails the step rather than emptying
whatever else holds it — previously reported as a pass, labelled as a
clear, on a field it never touched. The outcome keys off whether any node
in the tree reported focus, not off the source: an iOS build whose
injected framework omits `firstResponder` reports none at all, and
conflating that with "focus is elsewhere" refused every clear on the
platform.

`runType` also reclassifies a dispatch that rejects mid-cancellation as
an aborted skip, matching `runRotate` and `runLaunch`; a cancelled run
no longer surfaces a raw backend message as a step failure.

Docs: `type` does not "append" — the focusing tap lands the caret mid
field, so on Android the new text is spliced INTO the old value (forty
`A`s typed with `ZZZ` gives `AAA…ZZZ…AAA`), while on iOS it appends.
The suggested `assert` on a field's text does not work everywhere
either: iOS never exposes a field's contents to the flow tree, and a
Chromium `<input>` only does so without an aria-label or placeholder.
Both stated per-platform now, with the Vega rejection attributed to the
directive gate that actually performs it, and the recording-cleanup rule
carrying a recorded Enter across as `submit: true`.

The run report says `(clear first)` rather than `(cleared first)`: the
label is stamped before the step runs and rides every outcome, so the
past tense claimed a clear on steps that failed, errored or were skipped.
@hubgan
hubgan force-pushed the feat/flow-type-clear branch from f241e1c to 7e30bc7 Compare July 30, 2026 13:03

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: Second pass over feat/flow-type-clear, at 7e30bc71 (re-verified after the rebase — the earlier run was against the same seven commits before they were replayed onto e227319f).

Re-verified the two data-loss findings from the previous round on a real Chromium device driven by a branch build:

  • Atomicity — fixed. type: { into: { id: email }, text: "café", clear: true } errors on the é and leaves the field holding old@example.com; as two calls it emptied it.
  • Clearing on an unverified focus — fixed for the case it was reported on. #keep focused, #target refusing focus on mousedown: the step now fails with refusing to clear … and #keep keeps IMPORTANT DATA. Replied on that thread with the shapes that still get through.

One inline comment is a green run that did something it did not report, and is worth reading first: flow-actions.ts:527 — the focus check confirms on frame overlap, so a focused editable that covers the target confirms by construction, and the clear empties a different field while the step reports pass. Reproduced on a shadow-DOM host and on a plain modal focus trap, with a geometric control that refuses correctly.

Also exercised end to end on that device: clear+replace, clear-only (no Enter), clear-only with submit: true (one Enter), the (clear first) label across pass / fail, when-guarded and run:-composed clear steps, the recording path with clear in a recorded keyboard step, and the per-platform assert guidance against <input> / <textarea> / placeholder / aria-label / password fields. Locally: npx vitest run test/flows/ 33 files / 572 tests green at head, npm run lint, npm run build, typecheck:tests, prettier --check all clean, and each new branch mutation-tested in a scratch copy.

One measurement that bears on the deferred-trim note: argent-create-flow/SKILL.md goes 6854 → 7513 words across this branch (git show <rev>:… | wc -w), so the addition is +659 rather than the ~240 the description estimates — worth knowing, since that number is what the trimming was deferred against. Nothing enforces a cap either way: scripts/grade-skills.mjs has seven criteria and all are minimums.

Comments inline; four replies on existing threads.

// the alternative refuses every clear on the iOS builds above (verified: it
// did), and a clear with nothing focused loses no data, where clearing the
// WRONG field does. Assert the result if a flow must prove the clear landed.
if (step.clear && focus === "unconfirmed") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: A selector that resolves to a sibling <label for=…> hard-stops the flow here, while the same selector without clear works and writes into the right field. The overlap test at :530 compares the focused input against the frame of whatever the selector matched, and a label that sits beside its input never overlaps it. (A wrapping <label>Name <input></label> does contain its input, so that form is unaffected.)

Live on a Chromium device, branch build, against <label for="bare">BareLabel</label><input id="bare" value="bare-value-here"> — the input carries no accessible name of its own, so only the label matches:

  • - type: { into: BareLabel, text: "new@example.com" }pass, and the text lands in #bare; document.activeElement is #bare.
  • - type: { into: BareLabel, text: "new@example.com", clear: true }failfocus never reached text="BareLabel" within 3000ms — refusing to clear …. #bare still holds bare-value-here, and document.activeElement is #bare — the tap did put focus on the field the step named, via the browser's own label behaviour.

Same for the map form into: { text: "BareLabel" }, deterministic across repeated runs. On the same page into: Email, where the input carries aria-label="Email", resolves to the input and the clear passes — so what decides is markup the flow author does not own.

The conservative refusal itself reads as deliberate (:1028-1044 argues it, and the poll cannot distinguish "a label forwarded focus correctly" from "focus went elsewhere"). What seems worth weighing is the cost profile: it bites hardest on exactly the fields clear exists for — describe renders that same unlabelled input as input "bare-value-here" id="bare", so on a pre-filled field the element's own name in the tree is the volatile value, and the stable selector is the label beside it — while SKILL.md:90 tells authors to "Set clear: true whenever the field may already hold a value". flow-execute's description (flow-run.ts:449-453) documents clear: true as "empties it first" and names no failure mode.

The reason text is pinned by a single substring: dropping describeSelector(step.into) from it, and separately replacing its whole second half with the bare string "refusing to clear", each leave the flow suite at 572/572, since the only assertion on it is toContain("refusing to clear").


- **Android** exposes them. A field with a `contentDescription` reports the hint and the value together, so assert `contains` (or `equals` the whole `"<hint> <value>"` string); a field without one — most React Native `TextInput`s — reports the contents alone, and `equals` on the bare value is right.
- **iOS** never exposes them. The assert reads the field's label instead, so it hard-fails on a perfectly good clear.
- **Chromium** exposes an `<input>`/`<textarea>`'s contents only as the element's accessible _name_, and only when it carries no `aria-label`, no `aria-labelledby` and no `placeholder` (never for a password field). With any of those the assert reads that label and hard-fails, exactly like iOS — and an assert written against the label instead passes whether or not the clear happened, which proves nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: A <textarea> with authored content carries a second, stale copy of its markup default that this bullet does not account for, and the three attribute conditions do not gate it. chromium.ts:139 sets the accessible name from the live el.value, and :453 (if (text && text !== name) node.value = text.slice(0, 200)) fills value from ownText(el) — the element's own child text nodes, which for a <textarea> is its authored default. While the two agree the gate blocks and only the name is emitted; once typing makes el.value diverge, the stale default appears in value as well, and nodeText joins both (ui-tree-match.ts:146-148).

Live on a Chromium device, branch build, <textarea id="ta">textarea-value</textarea> — no aria-label, no aria-labelledby, no placeholder:

- type: { into: { id: ta }, text: "final", clear: true, submit: false }
    -> pass
- assert: { text: { in: { id: ta }, equals: "final" } }
    -> fail: element matched id="ta" but its text was "final textarea-value" (wanted to equal "final")

document.getElementById('ta').value is "final" immediately afterwards — the clear and the replacement both landed, and the recommended assert hard-stops the flow anyway.

Adding the attributes this bullet names as the failing case does not change it. With aria-label="Notes" and placeholder="Write something" on the same element, describe emits:

textarea "Notes" value="textarea-value" id="ta" [clickable]
- assert: { text: { in: { id: ta }, contains: "textarea-value" } }   -> pass

So for this element the stale content is present regardless of the three attributes, and the check an author would write to prove the old value is gone reports it as still present. flow-actions.ts:1063-1070 names the case ("a Chromium <textarea> exposes its default content once el.value empties") and points here for the tabulation. Related to the earlier thread on the removed read-back, which reported the <input> side of the same walker behaviour — this is the <textarea> instance, on the guidance written in answer to it.

// there — NOT necessarily appending; see the `type` notes in the
// argent-create-flow skill) so a cleared field is visible at a
// glance; a clear-only step has nothing on the right-hand side.
// Past tense is accurate here, unlike the run report's `(clear

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: No step produced by the recorder can reach this arm, so the tense is only ever applied to steps that did not run live. flow-add-step.ts:239-256 emits exactly tap | launch | run | tool, flow-insert-echo.ts:29 emits echo, and the only kind: "type" construction in src/ is the YAML parser at flow-utils.ts:1783. A type step therefore arrives solely through the hand-edit path — which is supported and documented (:80-82, "Host mode re-reads the file so manual edits made during the recording survive into the summary"; the summary is parseFlow over the file re-read at :90, :93).

Live, branch build on a Chromium device: flow-start-recording, write two type steps into the file, flow-finish-recording with zero flow-add-step calls in between:

summary: [
 "1. type: {\"id\":\"bare\"} ⇐ \"never-ran\"",
 "2. type: {\"id\":\"ta\"} ⇐ (cleared)"
]

Real DOM read over CDP immediately after: {"bare":"bare-value-here","ta":"textarea-value"} — untouched. The PR's own new test at flow-tools.test.ts:531 has to fs.writeFile its three type steps for the same reason, so the only exercised path is the one this comment rules out. (cleared) is the past-tense report of a destructive action on a field that was never touched — the hazard stepTarget's comment at flow-run.ts:772-778 cites when it declines the same tense.

if (!frame) {
return { ok: false, reason: offscreenHint(step.into) };
}
// Bare, like every other directive's `gesture-tap` (runTap, runLongPress,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: The parenthetical misdescribes three of the four functions it names. Only runTap at :785 dispatches a gesture-tap; runLongPress dispatches gesture-drag / gesture-custom (:818, :826), scrollIncrement dispatches gesture-scroll / gesture-swipe (:582, :602), and runPinch dispatches gesture-pinch (:893).

The consequence is that the first of runType's three dispatches sits outside the classification the other two just gained. Where a rejection coincides with an aborted signal — which is what dispatchOrAbort actually keys on, see the separate note on :1079 — the same step reports two different statuses depending on which dispatch was in flight. With a stub registry that aborts the run and then rejects on the named tool (the error strings below are the stub's own, not a real backend message):

ABORT-DURING gesture-tap -> [["error","gesture-tap aborted — transport closed"]]
ABORT-DURING keyboard    -> [["skip","run aborted"]]

git show <base>:…/flow-actions.ts has all three dispatches bare (:941 tap, :953 text, :959 Enter), and flow-abort.test.ts's "reports a type cancelled DURING the keyboard dispatch as a skip, not an error" is not on base — so base was uniform and the split arrives with this PR.

const body = (
raw as { type: { into?: unknown; text?: unknown; clear?: unknown; submit?: unknown } }
).type;
if (!body || typeof body !== "object") badEntry(raw, "type needs { into, text }");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: The sibling message — four lines below this one on base, :1777 at head — moved with the change and this one did not. :1777 became "type needs a non-empty text (or clear: true)", and :1767 / :1774 are new (type.clear must be a boolean, type.text must be a non-empty string when given); this one still names text as part of the required shape after the PR made it optional.

$ node -e '…parseFlow("steps:\n  - type: hello\n")'
Unrecognized flow entry (type needs { into, text }): {"type":"hello"}
$ node -e '…parseFlow("steps:\n  - type: { into: search, clear: true }\n")'
[{"kind":"type","into":{"text":"search","loose":true},"clear":true}]

It is reachable only for a non-object body, so it is what an author sees after extrapolating the bare-scalar form that tap, long-press, scroll-to and snapshot accept — and it points them at a shape that is now one of two.

// `submit` defaults to true; only serialize the explicit opt-out.
if (step.submit === false) body.submit = false;
if (step.text !== undefined) body.text = step.text;
// `clear` defaults to false; only serialize the opt-in, mirroring how

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: submit does not serialize only the opt-out as of the branch six lines below: the else if at :816 writes submit: true for a clear-only step, which is an opt-in.

$ node -e 'const {parseFlow,serializeFlow}=require("./dist/tools/flows/flow-utils.js");
  console.log(serializeFlow(parseFlow("steps:\n  - type: { into: search, clear: true, submit: true }\n")))'
steps:
  - type:
      into: search
      clear: true
      submit: true

A text-only reading does not rescue it, because the next comment in the same case block states the two-sided rule correctly — :813-814, "submit defaults to true when there is text and false without it, so only serialize a value that differs from the step's own default". Base's wording ("submit defaults to true; only serialize the explicit opt-out.") was true at base; the two lines this comment introduces are what falsified it.


The step will not clear blind: if the tree reports focus landing somewhere other than the field you named, the step **fails** instead of emptying whatever else holds it. It does not read the field back afterwards, though. If a flow must prove the new value took, follow it with an `assert` on the field's text — but check the platform first, because a text field's contents reach the flow tree unevenly:

- **Android** exposes them. A field with a `contentDescription` reports the hint and the value together, so assert `contains` (or `equals` the whole `"<hint> <value>"` string); a field without one — most React Native `TextInput`s — reports the contents alone, and `equals` on the bare value is right.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: The Android bullet carries no password exception, while the Chromium bullet two lines down carries one — and the two adapters redact identically. flow-android-tree.ts:122-126 (:123 elided):

const isPassword = attrs.password === "true";

const label = isPassword ? "[password]" : labelOf(attrs);
const rawText = (attrs.text ?? "").trim();
const hasValue = !isPassword && Boolean(rawText) && rawText !== label;

leaf.value is therefore never set (:155) and nodeText yields exactly "[password]", so equals and contains on the typed value both evaluate false. flow-chromium-tree.ts:45-52 describes itself as "mirroring the Android adapter's [password] placeholder".

The surrounding section's own running example is a password field — type: { into: password, text: "hunter2", submit: false } at :88, {{secret:APP_PASSWORD}} at :102 — so an author following "Android exposes them … equals on the bare value is right" adds an assert that hard-stops the run. It fails self-describingly (the reason quotes "[password]") and leaks nothing, but it is the trap the Chromium bullet is written to prevent.

...(step.clear ? { clear: true } : {}),
...(step.text !== undefined ? { text: step.text } : {}),
});
if (!sent) return ABORTED_OUTCOME;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: The mechanism this guard is documented by does not exist, which makes its actual trigger much narrower than the docblock implies.

:109-111 says "Cancelling a run tears down the transport the dispatch rides on (the simulator-server connection, the CDP session), so a call in flight when the caller gives up rejects with a raw backend message." Nothing does that. registry.invokeTool does not consult the signal before dispatching; the ToolContext is forwarded into keyboard's platform handlers (keyboard/index.tscross-platform-tool.ts) but every one of them discards it — the only occurrence of "signal" under src/tools/keyboard/ is the words "telemetry signal" at simulator-server-keys.ts:87, and the sibling tv-remote/platforms/focus-remote.ts shows what honouring it would look like (options?.signal?.throwIfAborted()). Flow-run's Chromium teardown is run-level, in the finally after execSteps settles.

So dispatchOrAbort returns false here only when an unrelated rejection happens to coincide with an aborted signal — typeTv refusing clear outright on a TV target (platforms/tv.ts:33-40, which runDirective does not gate: only Vega is excluded from type), or an adb shell input hitting its 15s timeout (android-input.ts:141).

The docblock's first sentence is also literally false for the caller the helper was extracted from. :108-109 describes it as being for "a directive whose tool has no abort handling of its own", but gesture-rotate polls ctx?.signal?.aborted every frame, lifts the fingers, and throws a named AbortError (gesture-rotate/index.ts:135-144) — which is precisely why runRotate needs the wrapper, and why keyboard, which really has none, reaches the arm only by coincidence.

* the selector momentarily doesn't resolve.
*
* Reports rather than decides: plain typing treats "unconfirmed" as
* best-effort and types anyway (no focus seen can also mean the focused view

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: This parenthetical is the rationale for unobservable, attached to unconfirmed. The FocusOutcome docblock above draws the line the other way: "'unobservable' means no focus evidence was obtained — the source cannot report focus, nothing was flagged, or the tree could not be read at all — so a failed poll says nothing, while 'unconfirmed' means the tree DID report focus, just on something other than the target" (:466-469). "No focus seen" is by that definition unobservable, and unobservable is the outcome that does NOT refuse.

The clause is carried over into a sentence this diff rewrote. On base it was accurate, because there was no tri-state and unconfirmed informally covered both cases —

$ git show <base>:…/flow-actions.ts
 * … an unconfirmed poll falls
 * through to typing after the timeout rather than failing the step, since "no
 * focus seen" can also mean the focused view didn't make it into the tree.

— and this PR narrowed the term without moving the justification. runType:1028-1029 states the correct one for unconfirmed and omits this clause. Only the first half is misplaced; "misplaced text is visible and additive" is right, and plain typing never branches on the outcome, so no behaviour is wrong here — only the contract a reader takes away. Reaching unconfirmed needs focused.length > 0 with no overlap, so "the focused view didn't make it into the tree" would require a second focused node, the shape :527-529 calls unobserved.

The same loose sense survives in the one file this docblock sends the reader to. :473 says "see flow-ios-tree" for the firstResponder-less build; flow-ios-tree.ts:259-260 (untouched here, so it predates the vocabulary) calls that exact case "just leaves the wait's poll unconfirmed" — the value that now hard-fails the step. Making the code match that comment, by returning "unconfirmed" from giveUp(), fails flow-type-clear-runner.test.ts's "still clears when the tree reports focus on no node at all" and "judges focus by the latest read".

// container, a Chromium `<div tabindex>` focus trap) reads as "confirmed"
// for every target on screen. On Chromium that is benign (one
// activeElement per document, so a focused container means no input holds
// focus and the clear no-ops); the destructive shape needs two focused

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: Both clauses are false on a live Chromium page, and the consequence is the destruction this guard exists to prevent — reached on the confirmed branch, so the refusal never runs.

"a focused container means no input holds focus and the clear no-ops". With an open shadow root document.activeElement is the host, never the inner element, so the flagged container coexists with an input that really holds the keys. Live on a Chromium device, branch build:

describe:
  div id="shost" [focused]                       (0.031, 0.532, 0.702, 0.152)
  input "SHADOW SECRET" id="sinput" [clickable]  (0.038, 0.540, 0.453, 0.040)   <- holds focus, NOT flagged
  input "Overlay" id="over" [clickable]          (0.044, 0.594, 0.453, 0.040)   <- the target, inside the host's box

before:  shadow input = "SHADOW SECRET"   #over = "OVERLAY ORIGINAL"   activeElement = shost
- type: { into: { id: over }, clear: true }   ->  pass   "into id=over (clear first)"
after:   shadow input = ""                 #over = "OVERLAY ORIGINAL"

Reproduced 3/3, and again after the rebase. The geometry is not the contrived part — a shadow host that lays out a screen spans it, so it overlaps every target on the page. Note the keyboard backend resolves focus correctly here: chromium-clear.ts walks shadowRoot.activeElement down to the real editable, so it clears the element that genuinely has focus. It is the flow's own overlap test that named a different one.

"the destructive shape needs two focused nodes at once, which only the Android/iOS adapters can emit". One suffices, and it need not be a container. A plain modal / rich-editor focus trap — a focusin listener bouncing focus back to a <textarea>, no shadow DOM and no preventDefault — on a second Electron app:

describe:
  textarea "DRAFT do not erase me" id="notes" [clickable,focused]  (0.022, 0.149, 0.673, 0.307)
  input "Email" id="email" [clickable]                             (0.033, 0.268, 0.587, 0.068)

before:  #notes = "DRAFT do not erase me"   #email = "old.remembered.login"
- type: { into: { id: email }, text: "new@example.com", clear: true, submit: false }
         ->  pass   "into id=email (clear first)"
after:   #notes = "new@example.com"         #email = "old.remembered.login"

The flagged node is an ordinary focused text field whose box contains the target; the user's draft is cleared and overwritten and the named target is never touched.

The two-focused-node shape is also what an ordinary hybrid-app dump looks like. Feeding adaptFullAndroidHierarchyToDescribeResult a uiautomator hierarchy with a focused android.webkit.WebView at [0,100][1080,1900] wrapping email at [40,200][1040,280] and a focused other at [40,600][1040,680] — both focused="true", which the adapter preserves — and running it through runType: STATUS: [{"status":"pass"}], KEYBOARD: [{"clear":true,"text":"new@example.com"},{"key":"enter"}]. That one is a hierarchy fixture, not a device observation — I had no Android hardware here.

The control is geometric, not observational: on the same rig, with the focused node NOT covering the target (#keep focused, #target elsewhere), the guard fires correctly — fail, refusing to clear …, #keep intact. So these take the confirmed branch, and the "latest read" rule at :501-508 cannot help — the latest read is a false confirm, not a race. Distinct from the unobservable fall-through I replied about on the earlier waitForFocus thread: that one is a missing observation, this is a positive but wrong one.

Scope: base runType took step: { into: FlowSelector; text: string; submit?: boolean } — the directive could not clear at all, so the worst case there was misplaced text, which is additive and visible. SKILL.md:94 states "The step will not clear blind: if the tree reports focus landing somewhere other than the field you named, the step fails" with no exception. In both runs above I engineered the mis-focus (a mousedown preventDefault, a focusin bounce) rather than finding it in a third-party app — but that is the situation the refusal was added for, and both are ordinary component-library patterns.

@hubgan
hubgan force-pushed the feat/keyboard-clear branch from e227319 to 14c9b9b Compare July 30, 2026 13:34
Hubert Gancarczyk and others added 8 commits July 30, 2026 15:34
A flow that replays against a non-empty field types on top of the old value.
It does not fail at that step — it fails later at an `assert`, pointing at the
wrong place. `type` can now empty the field first:

    - type:
        into: { text: Email }
        text: "new@example.com"
        clear: true

Threaded through the step union, the YAML shape, parse, serialize, the runner,
the run-report label and the recording summary.

Two semantics decisions:

- `text` becomes optional when `clear` is set. `type: { into: search, clear:
  true }` — empty the box, then assert the empty state — is a legitimate step.
  A missing/empty `text` without `clear` still rejects as before.
- `submit` defaults to false when there is no text. It defaults to true
  otherwise, and is stored (and serialized) only when it differs from that
  default, so YAML round-trips unchanged. Firing Enter into a field the step
  just emptied would run an empty search or submit a blank form.

`clear` joins the `rejectUnknownKeys` allowlist, so a misspelled `claer` fails
loudly instead of silently reverting the step to an append.

The clear is dispatched as its own `keyboard` call, before the text. It is NOT
folded into a combined `{ clear, text, key }` call even though the tool now
supports that ordering: `typeTv` rejects `key` outright and does so before it
types, so a combined call on an Apple TV / Android TV target would throw with
the field still empty.

`runType` already holds a settled tree, so it verifies the clear landed and
fails the step when the field is still populated — a flow that silently fails
to clear is far worse than one that fails loudly in the right place. The check
reads the focused node's `value` only: a field's placeholder surfaces as its
LABEL (Android exposes the hint that way), and an emptied field reports no
`value` at all, so treating label text as content failed every successful
clear — observed on a real device before the check was narrowed. Password
fields are skipped outright, since uiautomator reports their text as empty and
"looks empty" there is a false pass rather than evidence.

Skill docs: the `type` row and `submit` paragraph in argent-create-flow, the
keyboard section and capability row in argent-device-interact (including the
"use triple-tap to select all" line, which steered agents at the workaround
this replaces), and the Vega/TV rejection in argent-tv-interact.

The load-bearing one is argent-create-flow's recording-cleanup rule: it now
carries `clear` from a recorded `tool: keyboard` step into the `type:`
directive. Without that, the polish step drops it and the cleaned flow reverts
to appending — a docs omission that produces a data bug.
The `type` directive verified a `clear` by re-reading the focused node and
failing the step when it still held text. Review plus device probes showed the
check is blind exactly where it matters and wrong where it fires, so it is
removed rather than patched.

Blind:

- iOS never carries a value. `flow-ios-tree` projects leaves as
  {role, frame, children, label, identifier, focused} and does not even request
  a text field from `getFullHierarchy` — the check was dead on the platform.
- A Chromium `<input>` has no value either: the DOM walker fills `value` from
  the element's own child text nodes, and an input has none.
- On Android a field with no contentDescription puts its contents in `label`
  (`labelOf` falls back to `text`), so no value again — i.e. most React Native
  TextInputs.

Wrong where it fires:

- An emptied Android field WITH a contentDescription reports its hint as the
  value, so a successful clear reads as leftover text and fails the step.
  `<TextInput accessibilityLabel="Email" placeholder="you@example.com"/>` is
  the ordinary shape that hits this.
- A Chromium `<textarea>` exposes its default content once `el.value` empties,
  producing the same false failure only after the clear succeeds.

Demonstrating both halves: on an API 30 emulator whose clear genuinely failed
("wifi" -> "wif"), the check returned undefined and passed the step — the exact
append it existed to prevent — while the shapes above would have failed steps
that worked.

The silent-no-op risk lives at the tool layer, which now rejects `clear`
outright on every backend that cannot perform it. Flows wanting proof should
`assert` the field's value, which works on every platform. The skill docs said
the step "fails immediately rather than appending"; that claim is removed and
replaced with the assert guidance, since leaving it would stop an agent adding
the check that actually works.

Removing the read-back also removes its abort-guard gap (a run cancelled
between the clear and the read was reported as a step failure rather than a
skip) and its unsettled single-read race.

Tests: dropped the verification cases (one of which was vacuous — the android
adapter strips `value` from password nodes, so the password guard was
unreachable), pinned that a clear adds no extra tree read, and added coverage
for the run-report label and the recording summary, including a clear-only step
that previously rendered as `<- "undefined"` when the summary regressed.

Docs: corrected the platform scope on the argent-device-interact capability row
(Chromium was missing), softened "one device round trip" to "a single tool
call", noted that a clear-only `type` sends no Enter, dropped the API-30
framing now that the Android guard probes the device rather than a version, and
updated the argent-test-ui-flow login example, which still demonstrated the
bare-append pattern the other skills now warn against. `flow-execute`'s
description also documents `type`'s options for the first time.
The Android backend falls back to a measured delete run where `input` has no
`keycombination`, so the version caveat the skill docs carried is gone. Only
Vega and TV targets still reject it.
Review of #581 surfaced two ways the `type` directive's `clear` could
destroy data it was meant to replace. Both reproduced on a Pixel 3a
against Bluesky, and both re-verified fixed on the same device.

Clear and text now ride ONE `keyboard` call. Every backend validates the
whole request before touching the device precisely so a rejected call
leaves no trace — `assertTypeableAndroidText`, and the per-character
resolves in the chromium and simulator-server backends, all run ahead of
the clear. Issuing them separately stepped outside that guarantee:
`{ into: field, text: "José", clear: true }` cleared, then rejected the
text, leaving the field EMPTY where the identical args as a single call
left it holding its original value. Enter stays a separate call — the
`typeTv` argument for splitting applies to `key`, which it rejects
outright, and not to `clear`, which it rejects just as early.

`waitForFocus` now reports its outcome instead of discarding it, and a
clear whose focus landed elsewhere fails the step rather than emptying
whatever else holds it — previously reported as a pass, labelled as a
clear, on a field it never touched. The outcome keys off whether any node
in the tree reported focus, not off the source: an iOS build whose
injected framework omits `firstResponder` reports none at all, and
conflating that with "focus is elsewhere" refused every clear on the
platform.

`runType` also reclassifies a dispatch that rejects mid-cancellation as
an aborted skip, matching `runRotate` and `runLaunch`; a cancelled run
no longer surfaces a raw backend message as a step failure.

Docs: `type` does not "append" — the focusing tap lands the caret mid
field, so on Android the new text is spliced INTO the old value (forty
`A`s typed with `ZZZ` gives `AAA…ZZZ…AAA`), while on iOS it appends.
The suggested `assert` on a field's text does not work everywhere
either: iOS never exposes a field's contents to the flow tree, and a
Chromium `<input>` only does so without an aria-label or placeholder.
Both stated per-platform now, with the Vega rejection attributed to the
directive gate that actually performs it, and the recording-cleanup rule
carrying a recorded Enter across as `submit: true`.

The run report says `(clear first)` rather than `(cleared first)`: the
label is stamped before the step runs and rides every outcome, so the
past tense claimed a clear on steps that failed, errored or were skipped.
The `type` directive's docs were corrected in bb6e3fe, which left four
other live surfaces teaching the model it replaced — including the
`keyboard` tool's own description, which sits next to `flow-execute`'s in
the same MCP tool list and stated the opposite of it.

Typing does not REPLACE; where the new text lands depends on the caret,
which is only at the end if that is where focus left it. Tapping a long
value to focus it puts the caret where you tapped, which is how the
Pixel 3a repro produced `AAA…ZZZ…AAA`. Stated that way here rather than
per-platform, since the raw `keyboard` tool does no focusing tap of its
own and inherits whatever caret the caller set up.

`argent-test-ui-flow`'s worked example kept its `user@example.com`
+`user@example.com` result as the caret-at-the-end case, rather than
dropping it, and names the splice as the other outcome.

Also notes why the recording summary's `(cleared)` keeps the past tense
the run report just gave up: every recorded step ran live as it was
added, so there it is accurate.
…e tap wrap

Second review pass, all four items proven by mutation rather than reading.

Two arms of the new code were asserted by nothing. `dispatchOrAbort`
returning false on ANY error — turning every backend rejection into a
silent "run aborted" skip — passed all 3131 tests; `runRotate` already
carried that positive control, `runType` did not, so it has one now.
Likewise the focus-wait's source gate: mapping a non-focus-reporting
source to "unconfirmed" instead of "unobservable" passed everything,
while refusing `clear` on every target behind such a source — the same
platform-wide outage the outcome split exists to prevent. Both mutations
now fail.

The `gesture-tap` wrap is reverted. It was not needed by any of the
fixes, nothing asserted it, and it singled the focus tap out for a guard
its five siblings (runTap, runLongPress, scrollIncrement, runPinch) do
not have.

`runRotate` now shares `dispatchOrAbort` rather than keeping the inline
copy the helper was written from, and the helper's doc no longer claims
`runLaunch` as a precedent: `runLaunch` returns a failure outcome instead
of rethrowing, so it deliberately is not a caller.

The clear-only cancellation test keeps its case but drops the rationale
about reaching the dispatch "by a different branch" — after the collapse
both shapes take the same one.

Per-platform assert guidance corrected again: on Chromium an assert on
the typed value hard-FAILS like iOS (it reads the accessible name); it is
the label-matching assert that passes vacuously. The name also falls back
through aria-labelledby, skips password fields, and covers `<textarea>`,
and Android only pairs hint with value when a contentDescription is
present — without one, most React Native `TextInput`s report the contents
alone and `equals` is correct.
…saw it

The focus outcome accumulated "saw focus on something" across every poll
round, which made the verdict a race. An app that blurs when the focusing
tap lands reports the PREVIOUS field as focused for however many rounds
precede the blur, so the same flow against the same app refused the clear
or passed it depending on whether round 1 beat the blur — one being a
hard step failure and the other a silent no-op.

The question the clear is about to act on is whether something else holds
focus NOW, so the outcome now reflects the most recent successful read.
An app that blurs lands in the documented residual deterministically, and
a target that never takes focus while another field keeps it is still
refused. A window in which every read throws still reports "unobservable"
rather than claiming focus was seen, and the type's doc no longer says
that bucket means only "the tree cannot report focus" — it also covers
"the tree could not be read".

Also records, where the decision is made, that the overlap test confirms
by construction against a focus-flagged node large enough to cover the
target (an Android host WebView, an iOS first-responder container, a
Chromium focus-trap div). On Chromium that is benign — one activeElement
per document, so a focused container means no input holds focus and the
clear no-ops. The destructive shape needs two focused nodes at once,
which only the Android/iOS adapters can emit; it has not been observed on
a real device, so it is documented rather than guessed at.
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