feat(keyboard): clear the focused field - #976
Conversation
`keyboard { udid, clear: true }` empties whatever holds keyboard focus.
Exactly one of `text` / `key` / `clear` per call; combinations go through
`run-sequence`.
iOS and Android send 100 backspaces interleaved with 100 forward-deletes
to the focused field - one bounded burst, no caret move, no selection, no
chord, no read-back, no per-framework branch. Both keys join lines at a
boundary, so a multi-line field empties from wherever the tap left the
caret, and pressing either on an empty side is a no-op. Android sends the
whole burst as one `adb shell input keyevent`; iOS writes it over
simulator-server HID at a 2ms cadence with a 300ms settle, holding no
modifier at any point.
That replaces the select-all chord the earlier attempts were built
around, which a widget can silently ignore: Flutter on Android swallows
`Ctrl+A` from `input keycombination` outright, React Native misses it
intermittently, API 31/32 sends no metaState at all, and on iOS the held
Left-GUI latches if anything throws mid-chord. Once the primitive can
no-op you need read-backs, length measurement, budgets and fallbacks;
this one cannot.
Chromium gets the DOM equivalent - one `Runtime.evaluate` that finds the
focused editable (descending through shadow roots) and runs
`execCommand('selectAll'); execCommand('delete')`. It delivers no keydown
at all, and a clear aimed at nothing fails with
`KEYBOARD_CLEAR_NO_EDITABLE_FOCUS` instead of deleting from whatever the
page focuses by default.
TV targets and Vega reject `clear` before touching the device.
Documented limit: a field holding more than 100 characters on either side
of the caret keeps the remainder; call `clear` again.
Supersedes #580, #581, #831, #821.
`execCommand("delete")` answers false for exactly the five date/time input
types - date, datetime-local, month, week, time - which hold a structured
value it cannot remove while passing every editability signal the script
can read. Discarding that return value reported `cleared: true` for a
field that still held its date, and the caller's next step then typed the
replacement into the retained value: the exact data bug clearing exists
to prevent.
Measured on Chrome 151: `delete` is true for every element that ends up
empty - including one that was already empty, where `selectAll` is false
- and false for those five alone. So the script now reads it, and a
refused delete raises its own code, `KEYBOARD_CLEAR_UNSUPPORTED_FIELD`,
with its own repair: one `key: "backspace"` on the already-focused field
empties it (verified end to end). The focus refusal keeps
`KEYBOARD_CLEAR_NO_EDITABLE_FOCUS` and its "tap the field first" remedy,
which is the wrong advice for this case.
The refusals now name the input's type, so `<input type=checkbox>` and
`<input type=date>` are distinguishable in the message.
`execCommand("delete")` answers true and fires no `beforeinput`, which is
exactly the hook an editor with its own document model reconciles on: Lexical
and CKEditor 5 accept the delete and then restore every character from that
model. The tool answered `cleared: true` for a field that still held its value,
and the next `keyboard { text }` typed into it.
Verified on Chrome 151 against Lexical 0.21.0: `{ clear: true }` returned
`{ typed: "", keys: 0, cleared: true }` with the editor still reading
"HELLO WORLD", and typing "Z" produced "HELLO WORLDZ".
The clear now reads the focused field back in a SECOND evaluate and refuses when
the value survived. Two evaluates rather than one: an editor restores at the
microtask checkpoint that ends the first script, so a read-back folded into the
clear script sees the emptied field and is fooled — measured against CKEditor's
shape, where a same-script read answers "" and the next evaluate answers
"HELLO".
`injectAndroidClear` ran under `ADB_INPUT_TIMEOUT_MS`, a 15s budget whose own comment sizes it for "a single injection". The burst is 200 injections in one command, and `input` injects with INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH, so the adb child blocks on the app once per event. Measured on a Pixel 7 AVD (API 36) driving a debug Flutter field: 14.9s on an idle host — already at the cap — and 16.3s with four busy loops on the guest, where adb was SIGKILLed and the field went from 300 characters to 200 while the caller was told only `ANDROID_ADB_COMMAND_FAILED`. The same run now completes in 22.0s and reports `cleared: true`. The burst gets `ADB_CLEAR_TIMEOUT_MS` (90s), and a failure that still happens is re-stated as `KEYBOARD_CLEAR_UNCONFIRMED`, naming the clear and the partially emptied field — the one thing an agent has to know before typing again. The 200 keycodes are dropped from that message, which `formatSubprocessFailure` and Node's nested `Command failed:` each repeated into agent context (1.5KB down to 375 characters).
`document.designMode = "on"` and `<body contenteditable>` make the document its
own editing host, and `document.activeElement` defaults to `<body>` — so the
editability test passed, `selectAll` + `delete` ran against an unbounded editing
host, and the entire page was destroyed and reported as `cleared: true`. No
prior interaction was needed: a clear issued as the very first action did it.
Verified on Chrome 151 against a `designMode` page: `{ clear: true }` used to
answer `{ cleared: true }` and take the body from 288 characters and 7 ids down
to 85 and 1. It now refuses, the page is byte-identical afterwards, and the
message says the whole document is the editing host rather than repeating the
generic "nothing editable has focus".
Refused by identity rather than by editability, so it stays a refusal even for a
page that means `<body contenteditable>` as its editor. A real field on the same
`designMode` page still clears — checked on the same fixture.
…as a dead runtime `clear` is the only `keyboard` operation that waits on the renderer main thread — `text` and `key` go through `Input.dispatchKeyEvent`, which the browser process acknowledges in ~50ms whatever the renderer is doing. So it is the only one that meets the CDP client's 10s wait, and that wait does not cancel the request: the delete still runs once the renderer is free. The caller was told `DEBUGGER_CDP_REQUEST_TIMEOUT`, from the debugger taxonomy, advising "restart the app, then reconnect and retry once" — wrong twice over, since the app is fine and a retry lands a second delete on a field the first one may already have emptied. Verified on Chrome 151 with the renderer busy for 13s: the call used to fail at 10s with `DEBUGGER_CDP_REQUEST_TIMEOUT` and the field was empty 9s later. It now fails with `KEYBOARD_CLEAR_UNCONFIRMED`, whose message says the delete is not cancelled and to read the field back before clearing or typing again. The read-back evaluate gets the same treatment under its own failure_stage.
`pressKey` is a bare `proc.stdin?.write(...)` with no callback, and the
blueprint registered no `error` listener on that socket — unlike the repo's two
other stdin-writing spawn sites. A write racing the child's death emits EPIPE
with nothing listening, which `index.ts`'s `uncaughtException` handler turns
into a `crashShutdown` of the whole tool-server. One tool-server is shared by
every agent session on the machine, so all of them lose their devices at once.
Latent before `clear`, which is what makes it ordinary: the burst writes 400
lines at a fixed 2ms cadence with `delayMs` deliberately not applicable, while
typing only reaches that density if a caller passes an unusually small
`delayMs`. The trigger on the other side is routine too — every session is told
to call `stop-simulator-server` when it ends.
Reproduced against a booted iPhone SE (18.3) by racing `{ clear: true }` with
`stop-simulator-server` on the same udid: the tool-server process died in 2 of 5
attempts with `Uncaught exception: Error: write EPIPE`, while the clear itself
still answered `200 {"cleared":true}`. It now survives 8 of 8. The child's death
already reaches callers through the `terminated` event, so the listener only has
to stop the crash.
Chromium classification (all verified on Chrome 151):
- A focused `readonly` / `disabled` field, a non-text `<input>` and a `<select>`
were all reported as `KEYBOARD_CLEAR_NO_EDITABLE_FOCUS`, whose message says to
tap the field — which is already focused, so an agent loops. Each now carries
its own reason and lands in `KEYBOARD_CLEAR_UNSUPPORTED_FIELD`, the code the
follow-up commit added for exactly this trap on date inputs, with a repair
that is not "tap it again". The field kind is read before `readonly`, so a
`<input type=checkbox readonly>` is blamed on the thing that matters.
- A field inside a CLOSED shadow root was refused although the browser's own
editing commands reach it: `el.shadowRoot` is null there, so the descent
stopped on the host. The host is now tried, and `delete`'s answer is the
verdict — measured: true and the inner field empties, false and the page is
untouched. It cannot be read back through the closed root, so that one success
is reported unverified rather than pretending otherwise.
- A page that replaces `document.execCommand` made the evaluate throw, leaving
`result.value` undefined, which read as "no element has keyboard focus". The
script now catches and reports the page's own error.
- A refused `delete` left the selection `selectAll` had made — and on a field
Chrome then refuses, `selectAll` selects the WHOLE DOCUMENT, not the field, so
the whole page stayed highlighted into the next screenshot-diff. Dropped
before returning.
Elsewhere:
- `makeIosRemoteImpl` had no tvOS probe, so a remote Apple TV took the 400-event
burst instead of the documented refusal. `key` and `clear` now route to
`typeTv`; `text` keeps its existing MoQ HID path.
- `{ key: "", clear: true }` is still rejected on shape, but the message no
longer prescribes a `{ key: "enter" }` the caller never asked for and whose
retry fails.
- The three exclusivity rejections shared one `failure_stage`, so clear-misuse
could not be measured apart from the older text+key case. One stage each; the
shared error CODE stays, as disclosed.
- The secret note appended to that error explained the type-then-submit split
even for `{ clear, text }`, where there is no later Enter to screenshot.
- `{ clear: false }` — a shape this PR newly makes legal — was announced and
logged as "Pressing a key". It and `{}` now get their own no-op wording.
- Two new comments asserted the opposite of the code: `key-codes.ts` said
`delete` maps to usage 42 "on every platform" (it is the FORWARD delete on
chromium and Vega), and `android-input.ts` said neither burst keycode is
reachable through `ANDROID_NAMED_KEYCODES` (`backspace` is 67). The burst now
reads its backspace from that table instead of redeclaring it.
- `platforms/tv.ts` justified the refusal with "a TV has no hardware keyboard
focus", which Android TV contradicts — its `api.type` IS `adb shell input`.
The real reason is that `TvControlApi` has no delete verb and neither backend
is measured.
- `vega-vvd-e2e.yml` watched only `keyboard/index.ts`, so the deferred Vega
follow-up — which edits `platforms/vega.ts` alone — would not have run the job
asserting today's refusal.
- The `clear` prose now matches the code: Chromium reads the field back, the
mobile backends do not, `keys` counts key presses, and the focus tap needs
~500ms because `run-sequence` waits 100 and no backend checks focus.
Each gap below was proved by mutating the source and re-running: every mutation
used to leave the suite green.
Unit:
- The ios-remote clear path had no test at all. Reverting `makeIosRemoteImpl` to
`typeSimulatorServer` — deleting the clear routing for every remote simulator
— broke nothing; it now fails two cases, and the remote tvOS refusal is pinned
with it.
- `{ clear: false }` was pinned only on Android and TV, and only through the
guard in index.ts, which sends every shape to one android udid. Widening
`params.clear === true` to `!== undefined` in chromium.ts, vega.ts or ios.ts
was green; each now goes red.
- `CLEAR_KEY_PAIRS = 100` was never pinned, only the symbol: setting it to 3 was
green, while "100 backspaces… 100 forward-deletes" and "`keys` is 200" are
caller-facing contract. The literal 200 is now stated on both key backends.
- The timing contract was unpinned. Deleting `await sleep(CLEAR_SETTLE_MS)` — a
settle that exists solely to stop the auto-screenshot racing the deletions —
was green, and `CLEAR_KEY_CADENCE_MS` was order-asserted only. One timed lower
bound now covers both.
- The two failure codes were pinned only at the platform-impl boundary. They now
also travel through `createKeyboardTool(...).execute()`, and through
failure-classification.test.ts alongside the two older keyboard codes.
- The exclusivity message was asserted for one of its three combinations; all
three, the three-way join, the per-combination `failure_stage` and both
appended notes are now covered.
- `clear`'s own `.describe()` was asserted nowhere, and nothing checked that
`clear` appears in the advertised schema at all.
- Android TV routing for a clear was never run as a route — only `typeTv`'s own
rejection was, called directly — so hoisting the clear early-return above the
`isAndroidTv` probe would have fired 200 keyevents at a TV with nothing red.
- `injectAndroidClear`'s `timeoutMs` was not asserted, which after the burst got
its own budget is the one adb budget worth pinning.
- The Chromium script mock could not represent designMode, a closed shadow root,
a page that breaks `execCommand`, or the leftover selection. It can now, and
the two comments about `HTMLInputElement.type` say what the real DOM does.
- Nothing covered the flow layer: a `tool: keyboard {clear:true}` step now runs,
a refused one fails the run with its reason in the report, and a tap→clear→type
recording keeps its order.
E2E (`scripts/e2e-full`):
- New `35-ios.sh` tier. No CI path sent a real HID delete to a real simulator —
the iOS burst was pinned exclusively against an in-memory recorder array.
Verified green 11/11 against a booted iPhone SE (18.3), using Settings' own
search field so nothing has to be installed.
- The chromium tier's two refusal cases both used bare `assert_reject`, which
passes on any non-zero exit — so if the tap at the date input drifted, that
case silently became a duplicate of the unfocused one. New
`assert_reject_matching` pins each to a phrase only its own branch produces
(the CLI prints the message, not the code, so the code is not available here).
Two more refusals join them, the fixture gains a readonly field and a
model-restoring rich-text editor, the post-clear assertion now requires the
node to still exist with an empty label, and `await_ui` replaces the unwaited
`open-url`, which resolves on commit rather than on load. Verified green 12/12
against Chrome 151 with the tier's own fixture.
- `argent-tv-interact` gave one reason for a refusal that has two, and
contradicted itself in the same bullet: it said a TV has no hardware-keyboard
focus while asserting that named `key` presses work on Vega. Vega refuses
because nothing has measured a delete on a VVD yet, and `keyboard
{key:"backspace"}` IS supported there — far cheaper than the on-screen
keyboard the bullet sent the reader to. Both `references/live-authoring.md`
and the two `flow-yaml` references repeated that gap; they also named only
Vega where Apple TV and Android TV refuse too, which matters most for Android
TV, whose serial is indistinguishable from a phone's.
- The polish table's "focus tap + `tool: keyboard` folds into `type:`" was never
scoped to an ADJACENT pair, while the recording rule two sections earlier puts
a clear between them. Folding anyway leaves the clear first with nothing
focused: a hard refusal on Chromium, a 200-key burst into whatever is focused
on iOS and Android.
- `reliability-and-recovery.md` put the clear exactly where the 500ms focus
delay used to be and dropped the delay, in the one flow form the same section
says takes no settle. Its "this form cannot read the field back" was also
ambiguous between the injection-free form and the clear itself.
- `argent-device-interact` paired two true clauses into a false one: "it does not
matter where the tap left the cursor" next to "up to 100 characters on each
side" reads as "any field up to 200 characters clears in one call". That holds
only for a centred caret, and a tap into a filled field lands at the end —
measured on device, a 250-character field keeps 150. Its "pasting with no
focused field is a silent no-op, as with `keyboard`" is also now only true of
`text` and `key`.
- `argent-test-ui-flow`'s rewritten login example clears the email field because
the app remembers it, then types into the password field with no clear.
- The `paste` tool description never mentioned the hazard this PR spends four
documents on, though `paste` inserts at the caret exactly as typing does.
- `docs/features/interacting-with-apps.mdx` stated the mobile mechanism
unconditionally, one paragraph above the Limits bullet that correctly scopes
it, and "A longer field" there had no referent now that the bound is per side.
- `scripts/ci/vega-vvd-test.sh` matched only
`TOOL_CAPABILITY_UNSUPPORTED_OPERATION`, which every capability refusal
returns — a regression making `keyboard` wholly unsupported on Vega would have
passed it.
`npx docusaurus build` exits 0 and `node scripts/grade-skills.mjs` reports 16/16
skills at 10.0.
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ocused node `designMode = "on"` and `<body contenteditable>` make every element inside the document report `isContentEditable === true`, so the identity test on `document.activeElement` only fired when focus happened to be on <body>/<html>. An `autofocus` <button> — or any page script moving focus to a descendant — sailed past it and past the `editable` test, and `selectAll` + `delete` then emptied the whole page and reported `cleared: true`. Walk up to the outermost editable ancestor and test THAT identity. <input> and <textarea> inherit the flag too but hold their own value, so they stay exempt and a real field on such a page is still clearable. The refusal now names the focused element as well: the host swallows every descendant, so this fires for a focused <button> exactly as it does for <body>.
The opaque-host path ran selectAll + delete on any hyphenated tag with no open
shadow root and took `delete === true` as proof, skipping the read-back
entirely. Both halves of that are false on Chrome 151:
* `execCommand` acts on the document SELECTION, not on the focused element.
With the standard rich-text toolbar shape (mousedown + preventDefault +
focus()) holding focus while the selection stayed in a neighbouring editor,
the clear emptied THAT editor and reported `cleared: true`.
* `delete` answers true whether or not it removed anything, and an opaque host
cannot be read back — so the success was unfalsifiable.
The hyphen also admitted every plain light-DOM custom element, not only closed
shadow hosts. Refuse instead, before anything is selected, and extend the same
reason to a non-hyphenated host whose empty light subtree marks a closed shadow
root — that one used to get "tap the field first", a loop for an element that
already has focus. A closed-shadow field keeps the documented repair: select
with `gesture-drag` and type over.
`verifiable` goes with it: every accepted clear is now read back.
…sent The MCP adapter caps each non-longRunning fetch at 30s, and that timeout is not a cancellation: `fetchWithReconnect` retries on any error, its own AbortError included, up to five attempts. Nothing on the device side consults the abort, so one slow `keyboard` call became up to five CONCURRENT invocations at the same device, and the caller saw "This operation was aborted" rather than any of the tool's own wording. Measured through the real stdio adapter against a 40s call: five overlapping `toolInvoked keyboard` entries, an error after 154s, and a field holding 492 characters instead of the 100 asked for. With the declaration: one invocation, success after 41s, 100 characters. Both slow shapes belong to this tool — an Android `clear` under its 90s adb budget, and any `text` paced with `delayMs`.
The read-back re-derived the focused element and compared it with the clear's
own result by a coarse label ("input type=text"), so two fields of one kind
were indistinguishable. On an auto-advancing OTP / PIN / card-segment form the
page moves focus in its own `input` handler, and the NEXT field's contents were
attributed to the one that had just been cleared: the tool returned 400
KEYBOARD_CLEAR_UNSUPPORTED_FIELD, "still holds 1 character ... nothing was
cleared", against a field that was in fact empty — every statement false, the
prescribed repair wrong, and no auto-screenshot to see it by.
The clear now stashes the element it ran against on the page's own main world,
which is what survives between two `returnByValue` evaluates, and the read-back
compares identity and drops it. Nothing reaches the DOM, CSS or a screenshot.
The read-back looked at whatever held focus when it ran, so the two commonest restoring-editor shapes escaped it entirely: an editor that hands focus to a hidden IME buffer on every edit (ProseMirror / Slate / Quill) and a field that blurs on change both leave focus with nothing to read, and `remaining: null` — documented in the script as "not evidence of anything" — was then reported as affirmative evidence. Measured on Chrome 151: both returned `cleared: true` with the value byte-identical. It now reads the element the clear ran against, and only while that element is still connected — a page that REPLACED the field leaves a detached node holding the old value, which is no evidence about what is on screen. Content with no text of its own escaped too: an inline image, attachment chip, embed or table has `textContent.length` 0 before AND after, so a restored one read as an emptied field. Those are counted, and the message names what survived instead of quoting "0 characters".
…cument's
A text control keeps a selection separate from the document's, and
`execCommand("selectAll")` acts on the document's. With a page selection
anchored elsewhere — the everyday copy-to-clipboard button that highlights a
code block while keeping focus in the field — it selected the whole DOCUMENT,
`delete` refused, and an ordinary `<input type=text>` came back as
KEYBOARD_CLEAR_UNSUPPORTED_FIELD telling the caller it was a date input. Both
prescribed repairs were measured no-ops on it; the one action that worked was
the one the classification steers away from.
`.select()` selects that control's own value and nothing else. Measured on
Chrome 151: it empties the field the document-wide selection made unclearable,
it throws for no input type, and it still leaves `delete` answering false for
exactly the five date/time types — the distinction the refusal reads. A
contenteditable has no separate selection to hijack and `selectAll` is what
reaches into an open shadow root, so it keeps that path.
…used it A `contenteditable` holding a `contenteditable="false"` block — a locked header, an embed, a node view, a mention chip — cannot be cleared, and got one of two wrong answers depending only on where the block sat. With the block first the delete is refused and the field was told it is a date input, with a prescribed `backspace` that is a measured no-op on it. With the block last the delete is accepted, the content survives, and the read-back asserted that a page listener restored it — on a page with no listeners at all (zero `input` events counted, `innerHTML` byte-identical). The date/time wording is now gated on the type the script already read, and the restored-value message states the observation and both shapes that produce it rather than asserting one.
…ts value The read-back tested only whether the field was empty, so a page that rewrites the value on `input` — a currency, phone or card mask — was reported as "nothing was cleared ... the value the field still holds". Both halves are false: the caller's value is already destroyed, and what is quoted back is the mask's own seed. Measured: "1,234.56" -> "0.00" and "+1 (555) 010-9999" -> "+1 ", each reported as a field that kept its value. The clear now captures a content signature before it deletes and the read-back compares, so the two cases are separable. Only the boolean crosses back — the value itself never leaves the renderer, because a cleared field may have held a credential. The reformatted case gets its own stage and its own wording; the restored case now says outright that the value is the one it held before.
`pressKey` writes to the simulator-server's stdin with no callback, and the
EPIPE listener that keeps a racing write from crash-shutting the shared
tool-server also left nothing to tell the caller the burst went nowhere:
`terminated` reaches the next `resolveService`, not the call already holding
the resolved api — which is the one making the `cleared: true` claim.
Measured on a booted simulator with a 250-character field: `kill -9` of the
simulator-server 50ms into the clear delivered 9 of 200 keys and the tool
answered `{ keys: 200, cleared: true }`. Now the death is recorded on the pipe
and `pressKey` refuses afterwards, and the clear re-states a cut-short burst as
KEYBOARD_CLEAR_UNCONFIRMED with the same "may be PARTIALLY emptied — read the
field back" wording the Android burst already had. The tool-server still
survives the EPIPE.
Nothing on the clear path consulted the abort signal `dispatchByPlatform` already forwards, so a cancelled or disconnected clear kept driving the device for the rest of its 200 keys — the same shape `gesture-swipe` checks `ctx.signal` for, and for the reason quoted there: the deletions land in whatever is sent to that device next. Measured on a booted simulator with a 250-character field: a client gone at 150ms left the full 100 deletions running (250 -> 150), and now leaves 34. An abandoned burst reports the keys it actually sent and drops `cleared` — the field is emptied by however many got through, which is exactly the state that claim must not be made for.
`injectAndroidClear` runs one `adb shell input keyevent <200 codes>` under a 90s budget and `adbShell`/`runAdb` had no way to carry an abort, so an abandoned call blocked for that whole budget and nothing killed the adb child. Both options objects take an optional `signal` now, threaded to `execFile`, which already kills the child on it; every existing call site is unchanged. What it does and does not stop, measured on an API 36 emulator against a 100-character native EditText: with the client gone at 150ms the field is byte-identical afterwards, where the same call used to empty it. With the client gone at 1s the guest completed all 100 deletions — the abort kills the host-side client, not an injection already running on the device. That limit is now stated where the burst is issued, since the whole burst is one command by design.
`paste` wraps its device work in a per-device queue and its comment says why:
two concurrent calls on one device let the second land inside the first's
unserialized steps while both report success. `keyboard` had no such guard, and
`clear` turns a per-keystroke window into 700ms on iOS and 2-90s on Android.
Measured on a booted simulator with a 250-character field: `{ clear: true }`
and, 200ms later, `{ text: "HELLO" }` left the field at `…aaaaaaaaaaLO` — "HEL"
eaten by backspaces still in flight — with both calls reporting success. Now the
same pair leaves `…aaaaaaaHELLO`.
The queue moves to a shared util and both tools use one map per device, because
the hazard is the device's single focused field rather than any one tool's
steps: a paste racing a clear corrupts the value exactly as two clears would.
The request-shape guards stay ahead of it, so a caller's own mistake is not made
to wait out another session's burst.
…r refuses Two mirror-image findings, one mechanism. The delete-refused branch dropped the page selection rather than restoring it, so a call that reported "nothing was cleared" still took the page from a highlighted code block to no selection at all — visible state the next screenshot and every screenshot-diff registers. The script-error branch did not even drop it, leaving the select-all highlight on screen: the exact hazard its sibling's comment names. The page's ranges are now cloned before anything is selected and restored on both refusals, per range, so a range whose nodes the delete removed does not take the rest with it.
`el.disabled === true` was tested on any focused element, so a component library that exposes `disabled` as a plain property on a non-form host — `ce.disabled = true` on a `<div contenteditable>` — made a perfectly clearable field refuse with "nothing can be until the app enables it", a repair the caller cannot act on. It fails closed, so only the diagnosis was wrong. Gated on `<input>` / `<textarea>` / `<select>`, where the two are IDL attributes rather than an ordinary JS property. Measured on Chrome 151: that editor now clears, and a readonly input still reports readonly.
A `disabled` control cannot become `document.activeElement` at all — measured on Chrome 151, a real `gesture-tap` on one leaves focus on <body> — so the clear answers KEYBOARD_CLEAR_NO_EDITABLE_FOCUS, "Tap the field first", the agent taps the same field, and the identical error comes back. The `disabled` diagnosis that exists to say "tapping it again will not help" is unreachable for every standard form control, so this refusal is the only message that can carry it.
Only a timeout was re-stated, so a connection close rejected with DEBUGGER_CDP_CONNECTION_CLOSED and was rethrown raw, carrying the debugger taxonomy's "restart the app, then reconnect and retry once" — a retry that lands a SECOND delete on a field the first may already have emptied. The cdp-client's own comment at that rejection site is the argument against it: a request rejected there was already delivered and may have taken effect. Reproduced by killing the browser while the clear's read-back was pending: the call answered DEBUGGER_CDP_CONNECTION_CLOSED / "CDP connection closed", and now answers KEYBOARD_CLEAR_UNCONFIRMED with "read the field back first". Narrow on purpose: DEBUGGER_CDP_NOT_CONNECTED means the socket was already down when the send was attempted, so nothing was delivered and it keeps its own code.
…andatory `keyboard`'s own parameter description says the focus tap needs at least 500ms before a clear, because `run-sequence` waits only 100ms between steps and no backend checks focus — "a burst that arrives early deletes from the PREVIOUSLY focused element and still reports success". Both canonical copy-paste recipes showed the tap with no `delayMs` at all, and they are the ones an agent follows onto a slow app. Both now carry `delayMs: 500` and say why. The run-sequence example was run verbatim against a Chromium target: 3/3 steps, field replaced.
The message prescribes splitting a combined call into `{ clear: true }` then
`{ text }`, and its caveat named only Apple TV / Android TV — so on a Vega VVD a
caller was sent into a first step `platforms/vega.ts` rejects outright. `clear`'s
own `.describe()` already says "or Vega"; this message now says what to do
instead, and that `key` does work there.
…r names
The message told an agent that a separate `{ clear: true }` call's "screenshot
is taken before the replacement is typed, with the field still showing whatever
it held". The MCP auto-screenshot fires only after a SUCCESSFUL call, and a
clear returns after its burst plus the settle, so that capture shows the emptied
field. The advice (keep both steps in one `run-sequence`) is right; the reason
was not.
The real exposure is narrower and stated instead: the skip is decided per call
from the whole request, so a clear-only call is captured, and on iOS and Android
the burst is bounded at 100 characters per side — a field that held a longer
secret is captured with the remainder still in it.
…like The map keyed on the caller's raw string while every other consumer PARSES it, so two spellings of one target got two queues and the serialization silently did nothing. `parseChromiumCdpPort` reads `chromium-cdp-09333` and `chromium-cdp-9333` as the same port, and an iOS UDID is case-insensitive to the same degree. A/B on the Chrome fixture, two concurrent `keyboard` calls addressed as `chromium-cdp-9444` and `chromium-cdp-09444`: raw-string key ABABABABABABBAAB normalised key AAAAAAAABBBBBBBB The key is normalised in one place and used by all three entry points, so a hold taken under one spelling is seen by a call made under the other. Genuinely different devices stay apart — pinned by a case, since the cheapest wrong fix is a key that collapses them.
`serializedPerDevice` deletes its map entry only when the finished task is still the tail. Delete it unconditionally and the second task's own entry goes with it, so a third call finds an empty map and runs BESIDE the second instead of after it — and every case in this file queued exactly two tasks, one short of the shape that can observe it. Mutation-checked: with `deviceQueues.get(key) === next` removed the new case fails (`a b c` becomes `a`, `b`, `c` overlapping) and the rest of the file stays green.
…e buys No case ever had two `holdDeviceQueue` calls in flight at once, so nothing observed the property the store exists for. The one re-entrancy case only proves the store survives a single chain, which a module-level variable does too. The new case runs two sequences holding different devices, and asserts that the earlier one's reach for the other device WAITS rather than reading as re-entrant. Mutation-checked against the module-level variable the source comment rejects: the new case fails (`a:uses-b` jumps inside sequence B's hold).
No case anywhere put a `paste` step in a `run-sequence`, so removing `"paste"` from `DEVICE_QUEUE_TOOLS` — the membership that makes a paste sequence take the device's keyboard hold at all — stayed green. Every other queue case reaches it through a `keyboard` step. Mutation-checked: with the set narrowed to `["keyboard"]` the new case fails.
`clear` is the only boolean any allowed tool takes, and every pass-through case carried only numbers and strings. Commit 5d90da2 deleted the one case that read a `{ clear: true }` step's args, on the grounds that a `{ text: "hi" }` case covers it — it does not: a runner that dropped boolean args while injecting the udid turns `{ clear: true }` into a no-op and left the ENTIRE 5095-test suite green. The existing two-step queue case now asserts the args the `keyboard` step actually received. Mutation-checked with the runner filtering boolean entries out of `toolArgs`: the whole suite is green except this case (the lens load-flake aside).
… count `expect(new Set(stages).size).toBe(4)` pins distinctness only. Three of the four values occur exactly once in the repo — at their production site — and `failure_stage` is an unconstrained string, so each name was free and, worse, so was each shape-to-stage MAPPING: swapping the ternary's two two-field branches changes which bucket a client's telemetry lands in with nothing going red, and separability is that code's entire purpose. Mutation-checked with those two branches swapped: the case fails.
`failure_exit_code` is set only for `typeof err.code === "number"`, and the one case that claims this ground builds its fixture with `code: null` — a SIGKILL — so the spread that forwards it could be deleted with all 297 tests green. The two spread branches need two fixtures. Mutation-checked: with the spread removed the new exit-status case fails and the SIGKILL case still passes.
`keyboard_clear_android_burst` and `keyboard_android_runtime_kind` each occur once in the repo — at their production site — and `failure_stage` is an unconstrained string, so either could be reassigned to a Chromium or iOS value with the suite green. The iOS burst stage and all four Chromium stages are pinned; these two were not. Mutation-checked twice: reassigning the burst stage to `keyboard_clear_chromium_restored`, and the runtime-kind stage to `keyboard_ios_runtime_kind`, each fails exactly one of the new cases.
All four clear wrappers carry `getFailureSignal(err)?.error_kind ?? "subprocess"`, and nothing asserted it: every iOS and Vega fixture threw a plain Error or already said "subprocess", so the fallback and the read were indistinguishable and hardcoding the literal passed everywhere. On iOS that meant the blueprint's EPIPE guard was never composed with the burst wrapper at all. - iOS: a `FailureError` from the pipe guard whose kind is "timeout" is carried through, its code stays reachable on the cause chain, and a plain Error still falls back to "subprocess". - Android: the existing SIGKILL fixture now pins "timeout" — `runAdb`'s own verdict for the 90s cap. - Vega: a wrapped adb timeout is carried. - Apple TV: `sendLine`/`sendJson` reject with plain Errors on every path, so the read half is dead code there; the reachable answer is pinned instead, and the comment says why. Mutation-checked with `error_kind: "subprocess"` hardcoded in all three live wrappers: three cases fail.
Both new iOS refusals rest on the probe answering `undefined`, and that answer only ever came from a `vi.fn`: every keyboard test mocks the module, and the probe's own cases only ask about UDIDs the mocked listing carries. Held apart, both halves pass a probe that falls back to "mobile" — which is the exact silent-corruption the refusal exists to prevent. Three cases close it: the probe answers `undefined` for a UDID the listing does not carry and for an `xcrun` that fails, and the iOS impl refuses a clear on that UDID with `KEYBOARD_TARGET_KIND_UNKNOWN` while resolving no service at all. Mutation-checked with `return kind ?? "mobile"` in the probe: all three fail, and `keyboard-backend-fidelity` / `keyboard-text-key-exclusive` stay green — which is the gap.
Both script tests build their expected signature by eval-ing the exported `CONTENT_SIGNATURE_JS`, and every case compares a node against ITSELF — so the oracle agrees with whatever that source says. Deleting the end-trim and the zero-width strip from it left them all green, and those two decide the production shape: an editor that reseeds a zero-width space, or leaves trailing whitespace, into a field whose value is otherwise the one the clear was aimed at reads as "the page rewrote the value" — that the caller's value is already destroyed — for a field nothing rewrote. An equivalence class pins it: two textually different nodes that must hash alike, with an interior edit as the control so a constant signature cannot pass. Mutation-checked with the trim and the strip removed: the new case fails.
Every `deleteAnswer: false` case was an `<input>`, which takes `el.select()` and never reaches the select-all — so on the one shape where the restore has something to undo (a contenteditable whose delete is refused after the select-all replaced the page's range) there was no case at all, and the restore was only ever observed as a call count rather than as the range it put back. That also leaves the clone's ORDER unpinned: the page's ranges are cloned before the select-all replaces them, and cloning after would restore the select-all's own page-wide highlight — the exact thing the restore exists to remove. Mutation-checked with the clone moved below the select-all: the new case fails, and so does the throws-between-the-two case.
`run()` installs a fresh mutable `window` unconditionally, so the frozen object the case set never reached the script — and the script is sloppy-mode (indirect eval, as `Runtime.evaluate` is), where assigning onto a frozen object fails SILENTLY rather than throwing. The case asserted the same thing as the plain text-input success row, and deleting the try/catch it guards kept it green. `run` now takes the page's own `window`, and the case supplies one with a throwing setter — the hardened-global / Proxy shape, which is what actually reaches the catch. It also asserts the record was NOT left behind, so "the stash failed and the clear still succeeded" is observed rather than assumed. Mutation-checked with the try/catch removed: the case fails.
`typeTv` touches `device` exactly once on the clear path — the service lookup — and the case never observed it, so the Apple TV and Android TV rows were byte-equivalent and the second could not fail unless the first did. The comment describing the difference between the two backends described something the case structurally could not see. Both rows now assert the lookup was made with their own device id. Mutation-checked with the lookup's argument replaced by a constant: the Android TV row fails.
Two in `keyboard-backend-fidelity.test.ts`: - The aborted-burst case armed its abort with a wall-clock wait and then asserted `result.keys === events.length / 2`. Both operands are outputs of the same run, and that identity holds at EVERY exit of the loop — so it could not tell an abort that fired far too early from one that fired far too late. The abort is now driven from inside `pressKey` at a chosen key, and the count is a literal. - `expect(ios.clearVerified).toBeUndefined()` passed with the whole feature deleted, since no key backend sets the property at all — and it would pass for an explicit `clearVerified: undefined`, a different claim. `Object.hasOwn` on both sides instead. Mutation-checked: with `clearVerified` never set, the flag case fails; with the burst's top-of-loop abort check removed, the two pass-down cases fail.
The case titled "so the two cannot drift apart" compared the burst's first code with `String(ANDROID_NAMED_KEYCODES.backspace)` — `String(X)` against `String(X)` for the same live X — so it could not detect the drift it names. Set the map entry to 4 and it stayed green. Both sides are literals now (67 = KEYCODE_DEL, 112 = KEYCODE_FORWARD_DEL), with the map's own value pinned beside them. Mutation-checked with the map entry changed to 4: this case fails along with the four that already pinned literals — which is what it was supposed to do all along.
…ed it Chromium does check focus, and fails with `KEYBOARD_CLEAR_NO_EDITABLE_FOCUS`. The `clear` parameter's own `.describe()` says so correctly; commit cf1ff3b fixed the flow doc and the skill and left this third copy — the tool description itself — saying the opposite.
The row said the clear "was delivered and the outcome is unknown … never retry blind". The same code is raised for states where NOTHING was delivered and the field is provably unchanged — an abort before the burst was sent, an adb refusal before delivery, a first key the iOS or tvOS transport refused, and a Vega injection of 0 of 200 — where the repair is the opposite: fix the device and send the clear again, with nothing to read back. The row now splits the two and tells the reader the message says which.
The row promised "the clear then works on either kind". On the remote arm the code says the opposite: `makeIosRemoteImpl` raises the same `KEYBOARD_TARGET_KIND_UNKNOWN`, and once the kind resolves to `tv` it refuses `clear` and `key` outright. The row never mentioned `ios-remote` at all, so following it there is a second failure.
The `KEYBOARD_CLEAR_UNSUPPORTED_FIELD` row sent every non-date/time cause to `gesture-drag` and type over the selection. The source messages deliberately do not: a `readonly` field ignores every edit including that one, and a `disabled` control cannot take keyboard focus at all, so both name the app's own control instead. `gesture-drag` is prescribed there only for an `<iframe>`, an opaque host, a restoring editor and a page-wide editing host. The row now splits the repairs the way the messages do.
`live-authoring.md` said the recorded clear works on Vega, Apple TV and
Android TV. Three documents in this same PR say the opposite —
`references/flow-yaml.md` ("Apple TV and Android TV are not flow targets
… do not author them"), `argent-qa-flows/SKILL.md` ("out of scope") and
`rules/argent.md` ("no saved-flow support; report that limitation").
Vega is a flow target and keeps the sentence.
`live-authoring.md` said `delayMs: 500` "is the only form the settle can take here", while `reliability-and-recovery.md` prescribes a standalone `wait: 500` between the tap and the clear. Both pages are reachable from the same authoring path, and the code says both forms are valid at replay: `wait` is a first-class directive the runner sleeps on, and `delayMs` is a legal sibling of `tool:` that sleeps before its step. The real constraint is narrower: `wait` cannot be RECORDED — `flow-add-step` refuses it as "a flow directive, not a tool" — so `delayMs` is the only settle the recorder can emit, and a hand-added `wait` is the form for polish. Reworded to say that, and to point at the other page rather than contradict it.
…roken
The login example drove tap → `{ clear: true }` → `{ text }` as bare
calls with no settle. `argent-device-interact/SKILL.md`, added in this
same PR, names that exact shape as the failure mode: bare calls wait 0ms
between them and hold no keyboard, so the clear can land before focus
moves and then empties the previously focused element while reporting
success.
Each tap-clear-type triple is now one `run-sequence` with `delayMs: 500`
on the tap, which is the shape the other skill prescribes.
"the agent reads the field again after the clear, so it knows that the field is empty" is only true when that read answers. The tool withholds `clearVerified` whenever it cannot be taken — a page that replaced the field, one that sealed `window`, or one that stopped exposing a value to read — and reports the accepted delete alone.
`packages/docs/CLAUDE.md` reserves "tool names, parameters, values, limits and exact behaviour" for `reference/` and keeps `features/` conceptual. The clear's per-target limits, its `keys` / `cleared` / `clearVerified` shape and its five failure codes landed in `features/interacting-with-apps.mdx` instead, and `reference/tools.mdx` — a one-line-per-tool index — said only "Type text, press a special key, or clear the focused field". The codes appear nowhere in `docs/` at all. Adds a `Clearing a text field` section to `reference/tools.mdx` with the per-target table, the codes and their repairs, and the per-device serialization; the `features/` bullets keep the concept and link to it. `npx docusaurus build` passes, so the new anchor resolves.
… taught § 8 "Action Sequencing with `run-sequence`" was unchanged, while this PR gives a sequence containing `keyboard` or `paste` a device-wide keyboard hold — which is the whole reason the tap-clear-type recipe belongs in one call. It was documented only inside the keyboard section, where a reader looking up `run-sequence` would not find it. Says what the hold covers (first step through the last keyboard/paste step), what waits on it (the other session's keyboard, paste, and anything of theirs that can move focus), and what does not take one at all.
"It fails there with `KEYBOARD_CLEAR_NO_EDITABLE_FOCUS`" followed a sentence listing the five key-injecting backends, so "there" read as those five — while the table twenty lines below marks the code Chromium only. An earlier commit had the referent explicit; reordering the clauses lost it.
The PR added an `ios` phase, `--ios-udid` and `E2E_IOS_UDID`, and updated `run-e2e.sh`'s own `-h` text — and left every part of the README that describes the harness: the phase table, the "iOS / tvOS / Vega tiers are intentionally out of scope" line, the "Providing a device" section and the `--phase` list. Also says the thing that matters for a release gate: `ios` is in the darwin default and a skip does not fail the run, so a default run is green with zero coverage from that tier unless a UDID is named. The skip itself stays green — every other tier here skips the same way, and the file opens by promising that a partial run still produces a meaningful report — but the operator is now told to read the skip count.
`holdDeviceQueue` still described the behaviour it had before the hold was narrowed and before focus movers learned to wait: "across a WHOLE batch", "holds the device's keyboard for its whole duration", and "a `gesture-tap` never waits". All three are now false, and this is the comment a reader reaches first.
latekvo
left a comment
There was a problem hiding this comment.
[Diplomat, Opus 4.8]: Reviewed the keyboard clear feature and the new per-device serialization layer across claims-vs-code, nearest-twin / symmetry, non-happy paths (abort / partial burst / transport-death), inputs, reachability, and call lifetime, plus a mutation-minded look at the new constants and an absence sweep of the awaitDeviceHold focus-mover contract. Individual notes are inline.
| * Wait out another session's hold on this device, if there is one. | ||
| * | ||
| * Called by the tools that move keyboard focus (the gestures, `button`, | ||
| * `tv-remote`, `launch-app`, `restart-app`, `open-url`). A call made INSIDE the |
There was a problem hiding this comment.
[Diplomat, Opus 4.8]: This caller list (and the actual wiring — awaitDeviceHold is imported into exactly these twelve tools and no others) omits two tools that also move keyboard focus:
chromium-tabsre-points the shared per-device CDP that akeyboard { clear }evaluates against. Its own description says selecting a tab makes it "the active one … every other tool then acts on it," andchromium-serverre-pointscdpin place on a tab switch "so every subsystem that capturedcdpfollows the new page automatically" — which includesserver.evaluate, the pathclearChromiumruns on.chromium-tabs.executecalls noawaitDeviceHold, so a second session'schromium-tabs { action: "select", tab: "t2" }landing inside a first session'srun-sequence[tap t1's field,keyboard { clear }] hold switches the active tab before the clear evaluates: the clear then runs against t2 and, when t2 has a focused editable, empties it and returnscleared: true/clearVerified: truewhile the field the sequence tapped keeps its value. Its nearest twinopen-url(a strictly smaller active-tab operation) does wait the hold out.reinstall-appuninstalls the running app first (dropping keyboard focus) — the same terminate/focus-move class asrestart-appandlaunch-app, both listed here — but calls noawaitDeviceHold, so it can tear the focused app down inside another session's keyboard hold and leave that session's clear running against the home screen.
Traced at 63bc63e: chromium-tabs/index.ts execute → chromium-server/index.ts (cdp re-point + evaluate), grep -rl awaitDeviceHold (twelve tools, neither of these), against open-url / restart-app.
| // editor and answered true. | ||
| // * \`delete\` answers true whether or not it removed anything, so its | ||
| // return value is not evidence, and an opaque host cannot be read back | ||
| // to get any. \`cleared\` on this backend means the field was SEEN empty; |
There was a problem hiding this comment.
[Diplomat, Opus 4.8]: This says that on the Chromium backend cleared "means the field was SEEN empty," but in the shipped result that is clearVerified's meaning, not cleared's. types.ts documents Chromium cleared as "the delete was ACCEPTED," and clearChromium's unverified return emits { cleared: true } with no clearVerified in the three read-back-declined cases (field replaced, window sealed, target went read-only → remaining: null) — cleared: true while nothing saw the field empty. The parallel wording further down ("cleared alone means … 'read back empty' here") reads the same way and is contradicted a few lines later by "the delete was still accepted, so cleared stands — but nothing saw the field empty." Compared against the cleared / clearVerified docs in types.ts and the verified / unverified returns in this file.
| // same undefined, and no probe timed out there. What both causes share is | ||
| // that the device is not in the listing. | ||
| error_kind: "not_found", | ||
| failure_command: "xcrun_simctl", |
There was a problem hiding this comment.
[Diplomat, Opus 4.8]: failure_command: "xcrun_simctl" is stamped unconditionally, but refuseUnknownKind is also called from makeIosRemoteImpl (with remote = true), whose probe is getRemoteSimulatorRuntimeKind → simctlListDevices → execFileAsync("sim-remote", …) — the sim-remote binary, not xcrun / CoreSimulator. So keyboard { clear } on an ios-remote UDID whose sim-remote listing omits it (or misses its budget) raises a KEYBOARD_TARGET_KIND_UNKNOWN failure whose telemetry attributes it to an xcrun command that never ran; the human-readable message is correct (it interpolates the probe arg) but the structured field ignores the remote parameter. FAILURE_COMMANDS has no sim-remote entry but does carry "unknown". Checked against sim-remote.ts and registry/src/errors.ts.
|
|
||
| A remote Apple TV (an `ios-remote` tvOS id) refuses `clear` and `key`. The tvOS daemons drive a simulator in the Argent host's own CoreSimulator set, and they do not reach a device behind sim-remote. Use a local Apple TV simulator instead. | ||
|
|
||
| A failed clear carries one of five codes: |
There was a problem hiding this comment.
[Diplomat, Opus 4.8]: "A failed clear carries one of five codes" and the table lists five, but a clear can fail with a sixth. keyboard's execute routes the whole clear dispatch through serializedPerDevice, whose guarded() throws KEYBOARD_DEVICE_BUSY when the queue wait exceeds DEVICE_QUEUE_MAX_WAIT_MS (30s — reachable, since the same file budgets an Android clear up to 90s) and when the request aborts while queued. With one shared tool-server and two sessions (the documented default), session B's keyboard { clear } behind session A's >30s hold fails with KEYBOARD_DEVICE_BUSY — a code this table, and the docs generally, never mention. Traced keyboard/index.ts → device-serial.ts (guarded / deviceBusyError); grep of packages/docs shows the code is absent.
Empties the focused text field in one call:
Exactly one of
text/key/clearper call; combinations go throughrun-sequence. Works for native, React Native and Flutter apps on Android, iOS and Chromium.Supersedes #580 -> #581 -> #831 / #821 (and argent-private #31), which together came to ~6.1k production lines, ~11.6k test lines, 0 docs and ~15 tuning constants. That size was a symptom of the mechanism, not of sloppiness: every layer there existed to defend a select-all chord that a widget may silently ignore, and once the primitive can no-op you need read-backs, length measurement, budgets, fallbacks and outcome taxonomies. This replaces the primitive instead.
Why the chord cannot be the primitive
Ctrl+Afrominput keycombinationdeterministically - the trailingDELthen removes one character.keycombinationsends nometaState, soTextView.onKeyShortcutnever sees a shortcut at all.{ text: "w" }becomes Cmd+W and a throw in between latches Command.Meta+A/Ctrl+Aselects nothing on macOS builds.What it does instead
iOS and Android send 100 backspaces interleaved with 100 forward-deletes to whatever holds keyboard focus - one bounded burst, no caret move, no selection, no chord, no read of the field, no per-framework branch. Backspace at a line start joins lines and forward-delete at a line end does too, so a multi-line field empties from wherever the tap left the caret; pressing either key on an empty side is a no-op, so over-sending is harmless. Android sends the whole burst as ONE
adb shell input keyevent 67 112 ...(multi-codekeyeventhas been accepted since API 19, so the API-31/32 metaState bug and the API-30 no-keycombinationfallback simply do not arise). iOS writes it over simulator-server HID at a 2 ms cadence with a 300 ms settle, holding no modifier at any point.Chromium gets the DOM equivalent - one
Runtime.evaluatethat finds the focused editable (descending through shadow roots), thenexecCommand('selectAll'); execCommand('delete'). It is one round trip, it delivers no keydown at all so a page shortcut cannot cancel it, and the DOM tells us up front whether anything editable has focus - so a clear aimed at nothing 400s withKEYBOARD_CLEAR_NO_EDITABLE_FOCUSinstead of deleting from whatever the page focuses by default.It also reads what
execCommand('delete')answers, which is the one place this could still have reported a failure as a success. Chromium's five date/time input types (date,datetime-local,month,week,time) pass every editability signal the script can read - not in the denylist, not readonly or disabled, plain<input>- and keep their value anyway, because it is structured rather than text.deletereturns false for exactly those five and true for everything that ends up empty, including a field that was already empty (whereselectAllreturns false). A refused delete therefore raises its own code,KEYBOARD_CLEAR_UNSUPPORTED_FIELD, with its own repair - onekey: "backspace"on the already-focused field empties it - rather than the "tap the field first" advice, which would loop an agent forever here.TV targets and Vega reject
clearbefore touching the device.Documented limit: a field holding more than 100 characters on either side of the caret keeps the remainder; call
clearagain. That is the same contract Maestro ships (50 by default), and it is stated in the parameter description, the tool description, the skill and the docs.Deliberately not here
assertthe field or its consequence, as they already do fortype.uiautomator dumpraces the Android helper for the single UiAutomation connection.setTextRPC (argent-private fix: reconnect tool-server after idle timeout #31) - the only exact/atomic option, but it costs a cross-repo protocol + versionCode bump, a spawn/idle-respawn window, and a fallback for devices where the helper cannot install. It stays parked as the future exact-clear path; nothing here depends on it.{ clear, text }in one call - that shape is what forced feat(tool-server):clearon the keyboard tool #580 to pre-validate the whole request and detect focus loss between the halves. Replace-a-value is arun-sequence.type: { clear }- follow-up.API
clear?: boolean. Onlytrueacts;falsereads as absent, like{}.text/key/clear->InvalidToolInputErrorbefore anything is sent, keeping theKEYBOARD_TEXT_AND_KEY_COMBINEDcode and naming which two the request carried.{ typed: "", keys: <key events sent>, cleared: true }- no field value, no length.delayMsdoes not apply to the burst (it has its own cadence).KEYBOARD_CLEAR_NO_EDITABLE_FOCUS(tap the field) andKEYBOARD_CLEAR_UNSUPPORTED_FIELD(the focused field kept its value).Verified on device
Driven through a branch build of the tool-server over HTTP.
EditTextclearlabrun-sequenceclear -> text -> enter left exactlyargent, not splicedEditTextclearlabinputwithinputType: deleteContentBackward(React's value tracker); readonly and disabled refused and untouched; nothing-focused refused; an already-empty field still reports success; an<input>inside a shadow root clearedKEYBOARD_CLEAR_UNSUPPORTED_FIELD, value left at2020-01-02; the message's own repair (key: "backspace") then emptied it, driven through the toolrun-sequenceclear -> text left exactlyargent.exampleclearrejected withTOOL_CAPABILITY_UNSUPPORTED_OPERATION;textstill worksNot re-driven on hardware here: Android TV and Vega (no AVD / VVD available on this host) - Android TV goes through the same
typeTvrejection that was verified live on Apple TV, and both are unit-tested; and the API 24 / API 30 emulators, which the measurement pass covered (a multi-codeinput keyeventis accepted on API 24, and a 60-80 key burst clears a nativeEditTextand a Flutter single- and multi-lineTextFieldon API 30 - the level wherekeycombinationCtrl+A is swallowed). The burst uses nokeycombinationat all, whichkeyboard-android.test.tspins on the exact argv, so the API-31/32 missing-metaState bug and the API-30 fallback cannot arise.Suites:
npm test -w @argent/tool-server(4883 passed, 1 skipped),-w @argent/registry(120),-w @argent/mcp(80),npm run lint,npm run build,npm run typecheck:tests -w @argent/tool-server,npm run typecheck:scripts,npm run test:scripts(92),npm run knip,node scripts/grade-skills.mjs(16/16 at 10.0),npx docusaurus build,prettier --write ..Docs and skills
reference/tools.mdxkeyboard row;features/interacting-with-apps.mdx(a paragraph under "Gestures and text", an example instruction, two limits).argent-device-interactgains a "Clearing a field" section, a capability row, and loses the triple-tap-to-select-all advice;argent-tv-interact,argent-test-ui-flow(login example clears before typing),argent-create-flowlive-authoring + flow-yaml updated.E2E harness
scripts/e2e-full: the Chromium tier gets before/afterdescribeevidence around a clear on a dedicated fixture field, the nothing-focused refusal, the exclusivity rejection and a clear -> textrun-sequence; the Android tier gets the contract cases (a headless tier has no focused text field to observe, so the per-framework evidence lives in the device matrix above).Follow-ups
type: { into, text, clear: true }- dispatch{ clear: true }before{ text }inrunType; the existingwaitForFocusgate is enough. ~40 lines + docs.clear: <n>if a real long-field case shows up.clearon the keyboard tool #580, feat(flow):clearon thetypedirective #581, feat(keyboard): clear Android fields atomically #831, fix(keyboard): read the field back, instead of trusting a select-all that exits 0 #821 and argent-private fix: reconnect tool-server after idle timeout #31 can be closed as superseded once this lands.