Files
software-mansion__argent/packages/tool-server/test/keyboard-backend-fidelity.test.ts
Hubert Gancarczyk 20b5e8b4f0 fix(tool-server): reject a keyboard call carrying both text and key (#579)
## Problem

`keyboard` accepted `text` and `key` in one call, and that shape has no
meaning a caller can rely on. The same request reads two ways:

- `{ text: "hello", key: "enter" }` — "type, then submit"
- `{ text: "abc", key: "backspace" }` — "delete, then type" reads just
as naturally

Whichever order a backend picks, the other reading silently corrupts the
field. The backends did not even agree: `typeAndroidPhone` pressed
`params.key` **before** `params.text`, while simulator-server, chromium
and vega all did the reverse and the tool's own description promised
"the text is typed first and the key is pressed after it".

## Change

**`keyboard` takes `text` or `key`, never both.** A combined call is
rejected in `execute`, above the platform dispatch, so it resolves no
`ARGENT_SECRET_*` value and reaches no device — no backend has to defend
against the shape.

**To mix text and a key, use `run-sequence`.** That is what the error
message, both parameter descriptions, the tool description and the skill
docs now say:

```json
{ "udid": "<UDID>", "steps": [
  { "tool": "keyboard", "args": { "text": "hello world" } },
  { "tool": "keyboard", "args": { "key": "enter" } }
]}
```

Two separate calls work too, but cost an extra round-trip — and are
**not** equivalent when the text carries a secret (see below).

### Why the rule is prose, not JSON Schema

An earlier revision hand-wrote `inputSchema` with `not: { required:
["text", "key"] }`. That is no longer legal: #782 banned every top-level
combinator repo-wide, `not` included, because the Messages API rejects a
request whose tool schemas declare one — and that 400 fails **every tool
in the request**, not just this one.
`tool-input-schema-contract.test.ts` enforces it across the catalog.

So the constraint is restated in **both** fields' `.describe()` (the
`boot-device` house rule) and in the tool description.
`keyboard-text-key-exclusive.test.ts` pins both texts, and pins that the
advertised schema stays combinator-free.

### What the error message carries

Three things a caller needs and cannot get elsewhere:

1. **The literal retry**, not an ellipsis the Android backend cannot
type.
2. **The TV caveat.** A TV target rejects `key` outright
(`platforms/tv.ts`), so the prescribed `{ key: "enter" }` step cannot
succeed there. The guard runs above the dispatch on purpose, so it would
otherwise swallow that diagnosis; the caveat is carried statically
rather than by an async TV probe.
3. **The secret steer**, only when `text` holds a `{{secret:…}}`
placeholder. The auto-screenshot skip is decided per tool call from the
whole request, so:

   | call shape | auto-screenshot |
   | --- | --- |
   | one `run-sequence` carrying both steps | skipped |
   | `{ text: "{{secret:…}}" }` alone | skipped |
| a separate `{ key: "enter" }` | **not** skipped — captured after the
key lands, and can hand the still-visible secret back as pixels |

The check is the syntactic `.includes` `flow-utils.ts` already uses, so
the guard still resolves nothing. A plain combined call does not carry
the sentence, with a positive control pinning that.

### Simplifications the rule makes possible

With at most one of the two ever reaching a backend, the
resolve-then-inject splits that existed only to order a combined call
fold back into their injectors (`android-input.ts`, `vega-input.ts`),
and each backend collapses to two exclusive branches.

### Fixed alongside

`run-sequence`'s description claimed "No screenshot is captured
automatically". `shouldAutoScreenshot("run-sequence")` is `true` with a
15000 ms settle cap, so that line was already false — and this change
makes it load-bearing, since the secret guidance rests on it.

## Compatibility

A stored flow or scripted `run-sequence` whose step is `{ tool:
keyboard, args: { text: "…", key: "enter" } }` now errors on replay, and
the steps after it are skipped. The recorder never emitted the combined
shape (`flow-add-step` executes the call before it appends the step), so
the blast radius is hand-authored flows and scripted `run-sequence`
calls — which is exactly what the old worked example taught. The fix is
to split the step in two.

## Test coverage

- **`keyboard-text-key-exclusive.test.ts`** (new, replaces
`keyboard-key-order.test.ts`) — the rejection on all four backends with
nothing injected and no service resolved, plus a positive control per
backend that text alone and a key alone still work; shape-not-truthiness
(`{ text: "", key: "enter" }` rejects); at-most-one rather than
exactly-one (`{}` stays a no-op); rejection before secret resolution;
the run-sequence retry, TV and secret sentences, with a positive control
against an unconditional secret steer; and the two channels the
constraint actually reaches a client through.
- **`keyboard-backend-fidelity.test.ts`** — absorbs what key-order
uniquely pinned. The unknown-key 400 now names the offending key on iOS
and chromium here, on android in `keyboard-android.test.ts`, and on vega
in `vega-injection.test.ts` (tightened from the bare `/Unknown Vega
key/` prefix), so stripping the name is red on every backend.
- **`keyboard-android.test.ts`** — the combined-call tests are gone.
What they uniquely covered survives as single-parameter tests: the
multi-segment `%` codepoint count, the pressed key's identity against a
second literal keycode, and un-typeable text rejected with no on-device
side effect. The exhaustive 24-keycode loop now runs through
`injectAndroidNamedKey` and asserts the emitted `input keyevent <code>`,
so it pins the wire and not just the lookup.
- **`interaction-messages.test.ts`** — the two formatters see different
shapes now. `startedMsg` renders before the rejection, so it still words
four (including the combined one); `completedMsg` runs only after
success, so it words three. Both empty-request branches stay pinned.
- **`keyboard-tv.test.ts`** — the TV `key` rejection is independent of
this guard, which only ever sees requests carrying both. Its header says
so, since the exclusivity message's TV clause depends on it.
- **`scripts/e2e-full/phases/30-android.sh`** — the combined call
becomes an `assert_reject`, and the prescribed `run-sequence` remedy is
driven end to end.

## Live verification

Isolated tool-server built from this branch (own port, shared MCP server
untouched), against the Android settings search field on an API 36 AVD,
read back out of the view hierarchy rather than off a screenshot:

| Call | HTTP | Field / result |
| --- | --- | --- |
| seed `{ text: "XYZ" }` | 200 | `XYZ` |
| `{ text: "abc", key: "backspace" }` | **400** | field still `XYZ` —
byte-identical, nothing typed |
| `run-sequence` [`{text:"abc"}`, `{key:"backspace"}`] | 200,
`completed: 2` | `XYZab` — exactly what the combined call used to
produce |
| `{ text: "hello" }` | 200 | `{ typed: "hello", keys: 5 }` |
| `{ key: "enter" }` | 200 | `{ typed: "enter", keys: 1 }` |
| `{}` | 200 | `{ typed: "", keys: 0 }` — still a no-op |
| `{ text: "", key: "enter" }` | **400** | shape, not truthiness |
| `{ text: "{{secret:APP_PASSWORD}}", key: "enter" }` | **400** |
message carries the ONE-`run-sequence` steer and the
still-visible-secret reason |
| `{ text: "hi", key: "enter" }` | **400** | message does **not** carry
the secret sentence |

`GET /tools` reports `["properties", "required", "type"]` for `keyboard`
— no top-level combinator.

Suite: `packages/tool-server` 336 files / 4041 passed,
`packages/registry` 90 passed, `packages/argent-mcp` 79 passed. `tsc
--build`, `tsc --noEmit -p tsconfig.test.json`, eslint (0 warnings),
prettier and knip all clean.

---------

Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
2026-08-17 15:01:23 +02:00

334 lines
14 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
import type { DeviceInfo } from "@argent/registry";
import { typeSimulatorServer } from "../src/tools/keyboard/simulator-server-keys";
import { makeChromiumImpl } from "../src/tools/keyboard/platforms/chromium";
import { vegaImpl } from "../src/tools/keyboard/platforms/vega";
vi.mock("../src/utils/vega-input", async (importOriginal) => ({
...(await importOriginal<typeof import("../src/utils/vega-input")>()),
injectVegaText: vi.fn(async () => {}),
injectVegaNamedKey: vi.fn(async () => {}),
}));
import { injectVegaNamedKey, injectVegaText } from "../src/utils/vega-input";
const IOS_SIM: DeviceInfo = { id: "TEST-UDID", platform: "ios", kind: "simulator" };
const CHROMIUM: DeviceInfo = { id: "chromium-cdp-9222", platform: "chromium", kind: "app" };
const VEGA: DeviceInfo = { id: "vega-serial", platform: "vega", kind: "vvd" };
// HID usage IDs (key-codes.ts): letters are 0x04 + (c - 'a'), so h=11, i=12.
// Written as literals, not read back out of the map under test.
const HID_H = 11;
const HID_I = 12;
const HID_ENTER = 40;
const HID_ESCAPE = 41;
const HID_LEFT_SHIFT = 225;
function registryWith(api: unknown) {
return { resolveService: vi.fn(async () => api) } as never;
}
/** Records the ordered HID stream the simulator-server backend emits. */
function hidRecorder() {
const events: Array<[direction: string, keyCode: number]> = [];
return {
events,
api: {
pressKey: (direction: "Down" | "Up", keyCode: number) => events.push([direction, keyCode]),
},
};
}
/**
* Records the ordered CDP events the chromium backend dispatches, WHOLE — every
* field, not a projection. A filtered view (`.filter(keyDown).map(key)`) cannot
* see a dropped keyUp, a zeroed `windowsVirtualKeyCode`, or a `char` emitted
* out of order, all of which the iOS section catches by pinning its full stream.
*/
function cdpRecorder() {
const events: Array<Record<string, unknown>> = [];
return {
events,
api: {
dispatchKeyEvent: async (e: Record<string, unknown>) => {
events.push(e);
},
},
};
}
// CDP descriptors (chromium-keys.ts), written as literals rather than read back
// out of the maps under test. Letters: code Key<UPPER>, vk = uppercase charcode.
const CDP_H = { key: "h", code: "KeyH", windowsVirtualKeyCode: 72 };
const CDP_H_UPPER = { key: "H", code: "KeyH", windowsVirtualKeyCode: 72 };
const CDP_I = { key: "i", code: "KeyI", windowsVirtualKeyCode: 73 };
const CDP_ENTER = { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 };
const CDP_ESCAPE = { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 };
// Single-parameter fidelity: does each backend emit exactly the action it was
// given? Every request here carries `text` or `key`, never both — the tool
// rejects the combined shape (keyboard-text-key-exclusive.test.ts), so one
// action per call is the only shape a backend ever sees, and there is no
// relative order left to pin.
//
// What a success shape cannot see is a backend that emits its one action
// wrongly: a prefix instead of the whole string, two presses per character, a
// modifier held across the lowercase remainder (the text is "hi", so it has no
// shift to lose), a named key that is always Enter whatever was asked for, a CDP
// event stream missing its releases or its `char`, or a `typed` echo that drops
// the key name. Those are pinned here, against literal expectations — never
// against the same map the code reads, which any value in that map would
// satisfy.
//
// The unknown-key 400 is pinned here too, WITH the offending name: it is what a
// caller needs to retry, and a bare `/Unknown key/` prefix leaves stripping it
// green. Only the two backends that throw from their own module are covered
// here — android's name is pinned in keyboard-android.test.ts and vega's in
// vega-injection.test.ts, since this file mocks `injectVegaNamedKey`, which is
// where vega's throw lives.
describe("keyboard backends — emit exactly the action they were given", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("simulator-server (iOS)", () => {
it("types every character once, with no modifier held on a lowercase run", async () => {
const { events, api } = hidRecorder();
const result = await typeSimulatorServer(registryWith(api), IOS_SIM, {
udid: IOS_SIM.id,
text: "hi",
delayMs: 0,
});
// The exact ordered stream, so a backend that types a prefix, doubles a
// press, or holds Shift for the whole string cannot answer a success
// shape — none of which `typed` can distinguish, since it just echoes the
// request back.
expect(events).toEqual([
["Down", HID_H],
["Up", HID_H],
["Down", HID_I],
["Up", HID_I],
]);
expect(result).toEqual({ typed: "hi", keys: 2 });
});
it("leaves the characters before an un-typeable one on the device", async () => {
// This backend validates per character inside the dispatch loop, so a
// rejection is NOT all-or-nothing: "h" is already pressed when "é"
// throws. The tool description tells agents exactly that, and nothing
// pinned it in either direction — hoisting a whole-string pre-check up
// here (the other reasonable design, and what Android/Vega/TV do) was
// green across the whole suite while making the description false.
const { events, api } = hidRecorder();
await expect(
typeSimulatorServer(registryWith(api), IOS_SIM, {
udid: IOS_SIM.id,
text: "hé",
delayMs: 0,
})
).rejects.toThrow(/No keycode for character "é"/);
expect(events).toEqual([
["Down", HID_H],
["Up", HID_H],
]);
});
it("shifts only the character that needs it", async () => {
const { events, api } = hidRecorder();
await typeSimulatorServer(registryWith(api), IOS_SIM, {
udid: IOS_SIM.id,
text: "Hi",
delayMs: 0,
});
// Positive control for the modifier: Shift wraps the "H" press and is
// released before "i" — so "no modifier held" above is a real observation
// about lowercase, not a backend that cannot shift at all.
expect(events).toEqual([
["Down", HID_LEFT_SHIFT],
["Down", HID_H],
["Up", HID_H],
["Up", HID_LEFT_SHIFT],
["Down", HID_I],
["Up", HID_I],
]);
});
// TWO keys, because one cannot tell "presses the key it was asked for" from
// "always presses Enter" — every other named-key assertion on this backend
// uses `enter`, so `await pressKeyCode(NAMED_KEYS.enter)` in place of the
// resolved code is green across the whole suite. With `escape` here it is
// red, and `key: "escape"` silently submitting the dialog the caller was
// dismissing stays caught.
it.each([
["enter", HID_ENTER],
["escape", HID_ESCAPE],
])("presses %s by its own keycode, not a hardcoded Enter", async (key, code) => {
const { events, api } = hidRecorder();
const result = await typeSimulatorServer(registryWith(api), IOS_SIM, {
udid: IOS_SIM.id,
key,
delayMs: 0,
});
// The literals are the point: comparing against NAMED_KEYS[key] would be
// satisfied by any value that map happens to hold.
expect(events).toEqual([
["Down", code],
["Up", code],
]);
// The whole result, not just `keys`: with no text given, `typed` echoes
// the key name, so `typed: params.text ?? ""` is caught here too.
expect(result).toEqual({ typed: key, keys: 1 });
});
it("names the offending key when it is unknown, and presses nothing", async () => {
const { events, api } = hidRecorder();
await expect(
typeSimulatorServer(registryWith(api), IOS_SIM, {
udid: IOS_SIM.id,
key: "bogus",
delayMs: 0,
})
).rejects.toThrow(/Unknown key "bogus"/);
expect(events).toEqual([]);
});
});
describe("chromium", () => {
it("emits the whole keyDown/char/keyUp triple per character, in order", async () => {
const { events, api } = cdpRecorder();
// "Hi", not "hi": with an all-lowercase fixture a `.toLowerCase()` on the
// way to `charToChromiumKey` emits the same stream, and neither `typed`
// (which echoes the unmutated request) nor `keys` (a count) can see a
// fold. That is the gap the sibling android test names — a case-sensitive
// login field silently receiving `passw0rd` on the `{{secret:…}}` path.
await makeChromiumImpl(registryWith(api)).handler(
{},
{ udid: CHROMIUM.id, text: "Hi", delayMs: 0 },
CHROMIUM
);
// The exact ordered stream, matching what the iOS section does with its
// HID events. Three projections (keyDown->key, char->text, keyDown->key
// for the named key) left four defect classes green against the whole
// suite: deleting either keyUp dispatch, forcing every
// `windowsVirtualKeyCode` to 0, and emitting `char` BEFORE `keyDown`.
//
// `char` carries the codepoint to the field, and the release matters to
// any `keyup` listener. `windowsVirtualKeyCode` has its consequence
// recorded at chromium-keys.ts:6-9 — apps on the deprecated keyCode API
// (React Native Web's Pressable) see `keyCode === 0` and silently drop
// the event — and no test under test/ asserted that field for this
// backend.
expect(events).toEqual([
{ type: "keyDown", ...CDP_H_UPPER },
{ type: "char", text: "H" },
{ type: "keyUp", ...CDP_H_UPPER },
{ type: "keyDown", ...CDP_I },
{ type: "char", text: "i" },
{ type: "keyUp", ...CDP_I },
]);
});
it("leaves the characters before an un-typeable one on the device", async () => {
// The mirror of the simulator-server case above: chromium also validates
// per character inside the loop, so the whole triple for "h" is already
// dispatched when "é" throws.
const { events, api } = cdpRecorder();
await expect(
makeChromiumImpl(registryWith(api)).handler(
{},
{ udid: CHROMIUM.id, text: "hé", delayMs: 0 },
CHROMIUM
)
).rejects.toThrow(/No CDP key descriptor for character "é"/);
expect(events).toEqual([
{ type: "keyDown", ...CDP_H },
{ type: "char", text: "h" },
{ type: "keyUp", ...CDP_H },
]);
});
// Two keys, for the same reason as the simulator-server pair above: pinning
// only `enter` cannot separate "dispatches the key it was asked for" from a
// branch that overwrites `named` with CHROMIUM_NAMED_KEYS.enter.
it.each([
["enter", CDP_ENTER],
["escape", CDP_ESCAPE],
])("dispatches %s itself, not a hardcoded Enter", async (key, desc) => {
const { events, api } = cdpRecorder();
const result = await makeChromiumImpl(registryWith(api)).handler(
{},
{ udid: CHROMIUM.id, key, delayMs: 0 },
CHROMIUM
);
// Whole stream again: a dropped keyDown is a no-op for any `keydown` or
// submit-on-Enter listener, and a dropped keyUp for any `keyup` one,
// while both still answer a success shape. A named key emits no `char`
// (chromium.ts:72-87) — pinning the exact pair records that too.
expect(events).toEqual([
{ type: "keyDown", ...desc },
{ type: "keyUp", ...desc },
]);
expect(result).toEqual({ typed: key, keys: 1 });
});
it("names the offending key when it is unknown, and dispatches nothing", async () => {
const { events, api } = cdpRecorder();
await expect(
makeChromiumImpl(registryWith(api)).handler(
{},
{ udid: CHROMIUM.id, key: "bogus", delayMs: 0 },
CHROMIUM
)
).rejects.toThrow(/Unknown key "bogus"/);
expect(events).toEqual([]);
});
});
// No android section: `adb shell input` is a command line rather than an event
// stream, so keyboard-android.test.ts pins the exact strings there — including
// the case-preservation property this file's iOS section covers as "shifts only
// the character that needs it", which on android has no modifier to observe and
// shows up only as the literal command line, and the unknown-key 400's name.
describe("vega", () => {
it("injects the text it was given, and nothing else", async () => {
// "Hi" for the same reason as the chromium fixture: an all-lowercase
// string cannot separate "injects the text it was given" from "injects a
// case-folded copy", at `vega.ts`'s call or inside `injectVegaText`.
await vegaImpl.handler({}, { udid: VEGA.id, text: "Hi" }, VEGA);
expect(vi.mocked(injectVegaText).mock.calls.map((c) => c[0])).toEqual(["Hi"]);
expect(injectVegaNamedKey).not.toHaveBeenCalled();
});
it("forwards the key it was given to the injector, not a hardcoded one", async () => {
// vega-injection.test.ts drives `injectVegaNamedKey` directly, so nothing
// there pins that the BACKEND passes `params.key` through — replacing it
// with a literal "enter" is green everywhere else. On Fire TV `escape`
// maps to KEY_BACK, so that slip turns "go back" into select on the
// focused tile.
const result = await vegaImpl.handler({}, { udid: VEGA.id, key: "escape" }, VEGA);
expect(vi.mocked(injectVegaNamedKey).mock.calls.map((c) => c[0])).toEqual(["escape"]);
expect(injectVegaText).not.toHaveBeenCalled();
expect(result).toEqual({ typed: "escape", keys: 1 });
});
});
});