Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2011,7 +2011,7 @@ extension RunnerTests {
ok: false,
error: ErrorPayload(
code: "UNSUPPORTED_OPERATION",
message: "Unable to dismiss the iOS keyboard without a safe native dismiss control",
message: "Unable to dismiss the iOS keyboard: the keyboard exposes no dismiss key, and background taps are never attempted (no tap outside the keyboard can be proven side-effect-free)",
hint:
"The on-screen keyboard usually does not block agent-device interactions: press the next target directly instead of retrying dismiss. If that press fails or reports no visible effect, scroll the target into view, or use keyboard enter to press the return key when submission is wanted."
)
Expand All @@ -2023,7 +2023,8 @@ extension RunnerTests {
message: "keyboardDismiss",
visible: result.visible,
wasVisible: result.wasVisible,
dismissed: result.dismissed
dismissed: result.dismissed,
keyboardDismissMechanism: result.mechanism?.rawValue
)
)
case .keyboardReturn:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,34 @@ private enum KeyboardDismissObservationTiming {
static let settleRequiredConsecutiveMatches: Int = 3
}

// The mechanism that actually resigned the keyboard, disclosed to the caller
// (#1598) so a response never claims "dismissed" without saying how — a
// safe-area tap has a very different reliability/side-effect profile than
// tapping the keyboard's own Done key, and callers need to know which one
// fired.
enum RunnerKeyboardDismissMechanism: String {
case dismissKey
}

extension RunnerTests {
func isKeyboardVisible(app: XCUIApplication) -> Bool {
return visibleKeyboardFrame(app: app) != nil
}

func dismissKeyboard(app: XCUIApplication) -> (wasVisible: Bool, dismissed: Bool, visible: Bool) {
func dismissKeyboard(
app: XCUIApplication
) -> (wasVisible: Bool, dismissed: Bool, visible: Bool, mechanism: RunnerKeyboardDismissMechanism?) {
let keyboard = app.keyboards.firstMatch
let wasVisible = isKeyboardVisible(app: app)
guard wasVisible else {
return (wasVisible: false, dismissed: false, visible: false)
return (wasVisible: false, dismissed: false, visible: false, mechanism: nil)
}

#if os(tvOS)
_ = pressTvRemote(.menu)
sleepFor(0.2)
let visible = isKeyboardVisible(app: app)
return (wasVisible: true, dismissed: !visible, visible: visible)
return (wasVisible: true, dismissed: !visible, visible: visible, mechanism: visible ? nil : .dismissKey)
#else
if tapKeyboardDismissControl(app: app) {
_ = keyboard.waitForNonExistence(timeout: KeyboardDismissObservationTiming.timeout)
Expand All @@ -47,13 +58,20 @@ extension RunnerTests {
requiredConsecutiveMatches: KeyboardDismissObservationTiming.settleRequiredConsecutiveMatches
)
let visible = isKeyboardVisible(app: app)
return (wasVisible: true, dismissed: !visible, visible: visible)
return (wasVisible: true, dismissed: !visible, visible: visible, mechanism: visible ? nil : .dismissKey)
}

return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app))
// #1606 review P1 (twice): generic background-tap dismissal is
// deliberately UNSUPPORTED. No geometry or role query can prove a
// coordinate is side-effect-free — a full-screen unlabeled Pressable is
// indistinguishable from an inert backdrop, so a "safe-area" tap can
// navigate or submit while reporting a successful dismiss. The dismiss
// key is the only mechanism the runner can vouch for.
return (wasVisible: true, dismissed: false, visible: isKeyboardVisible(app: app), mechanism: nil)
#endif
}


// AX-free on purpose (screenshot bytes, not the accessibility tree) so it holds
// under the same AX degradation the synthesized gesture lane is built to survive.
// Bounded and self-terminating: returns as soon as `requiredConsecutiveMatches`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ struct DataPayload: Codable {
let visible: Bool?
let wasVisible: Bool?
let dismissed: Bool?
let keyboardDismissMechanism: String?
let orientation: String?
let gestureFallback: String?
let gestureFallbackMessage: String?
Expand Down Expand Up @@ -297,6 +298,7 @@ struct DataPayload: Codable {
visible: Bool? = nil,
wasVisible: Bool? = nil,
dismissed: Bool? = nil,
keyboardDismissMechanism: String? = nil,
orientation: String? = nil,
gestureFallback: String? = nil,
gestureFallbackMessage: String? = nil,
Expand Down Expand Up @@ -336,6 +338,7 @@ struct DataPayload: Codable {
self.visible = visible
self.wasVisible = wasVisible
self.dismissed = dismissed
self.keyboardDismissMechanism = keyboardDismissMechanism
self.orientation = orientation
self.gestureFallback = gestureFallback
self.gestureFallbackMessage = gestureFallbackMessage
Expand Down
2 changes: 1 addition & 1 deletion apple/runner/RUNNER_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Successful and failed responses use the same top-level envelope:
"ok": false,
"error": {
"code": "UNSUPPORTED_OPERATION",
"message": "Unable to dismiss the iOS keyboard without a safe native dismiss control"
"message": "Unable to dismiss the iOS keyboard: the keyboard exposes no dismiss key (background taps are never attempted)"
}
}
```
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,11 @@ export type KeyboardCommandResult = {
focusedResourceId?: string;
inputOwner?: 'app' | 'ime' | 'unknown';
message?: string;
/** iOS `dismiss` only (#1598): which mechanism actually resigned the
* keyboard — 'dismissKey' (tapped the keyboard's own Hide/Dismiss/Done
* key). Generic background-tap dismissal is deliberately unsupported
* (#1606 review): no query can prove a coordinate is side-effect-free,
* so the runner only ever taps the keyboard's own control. Absent when
* the keyboard was not dismissed. */
mechanism?: 'dismissKey';
};
3 changes: 3 additions & 0 deletions src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ export type BackendKeyboardResult = {
wasVisible?: boolean;
dismissed?: boolean;
attempts?: number;
/** iOS only: which mechanism resigned the keyboard (#1598) — 'dismissKey'
* (tapped the keyboard's own Hide/Dismiss/Done key); background-tap dismissal is deliberately unsupported (#1606 review). */
mechanism?: string;
};

export type BackendClipboardTextResult = {
Expand Down
2 changes: 1 addition & 1 deletion src/cli/parser/__tests__/cli-help-command-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ test('usageForCommand documents keyboard dismissal flow', async () => {
const help = await usageForCommand('keyboard');
assert.equal(help === null, false);
assert.match(help ?? '', /To hide the keyboard, use keyboard dismiss/);
assert.match(help ?? '', /taps safe controls like Done/);
assert.match(help ?? '', /taps the keyboard dismiss\/hide key when one is exposed/);
assert.match(help ?? '', /UNSUPPORTED_OPERATION/);
});

Expand Down
4 changes: 3 additions & 1 deletion src/cli/parser/__tests__/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,11 @@ test('usageForCommand resolves workflow help topic', async () => {
assert.match(help, /Empty replacement is not a supported clear-field command/);
assert.match(help, /do not plan fill <target> ""/);
assert.match(help, /To hide the keyboard, use keyboard dismiss/);
assert.match(help, /reports UNSUPPORTED_OPERATION rather than tapping elsewhere/);
assert.match(help, /no tap outside the keyboard can be proven side-effect-free/);
assert.match(
help,
/On iOS, if it returns UNSUPPORTED_OPERATION, there is no generic app-agnostic blur fallback/,
/On iOS, if it still returns UNSUPPORTED_OPERATION, both mechanisms were exhausted/,
);
assert.match(help, /On Android, keyboard dismiss first avoids navigation/);
assert.match(help, /use back only when normal back behavior is acceptable/);
Expand Down
4 changes: 2 additions & 2 deletions src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,8 @@ Text entry:
agent-device type "Handle with care" --delay-ms 80
Empty replacement is not a supported clear-field command: do not plan fill <target> "" or fill <target> ''. Prefer a visible clear/reset control; if the app exposes none, report the tool gap instead of inventing a clear command.
Debounced field with no result selector: agent-device wait 1000. Keyboard read-only: keyboard status/get. The on-screen keyboard usually does not block agent-device interactions; press the next target directly instead of dismissing. If that press fails or reports no visible effect, scroll the target into view or use keyboard enter when submission is wanted.
Only dismiss the keyboard when hiding it is the actual goal. To hide the keyboard, use keyboard dismiss. It taps safe controls like Done when available and verifies the keyboard closed.
On iOS, if it returns UNSUPPORTED_OPERATION, there is no generic app-agnostic blur fallback: do not assume a static text or heading is safe to press, because it can belong to a tappable parent. Use an app-provided dismiss control only when its action is explicitly intended; otherwise report that keyboard dismissal is unavailable.
Only dismiss the keyboard when hiding it is the actual goal. To hide the keyboard, use keyboard dismiss. It taps the keyboard's own dismiss/hide key when one is exposed (common on iPad, rare on iPhone) and verifies the keyboard closed. When no dismiss key exists it reports UNSUPPORTED_OPERATION rather than tapping elsewhere — no tap outside the keyboard can be proven side-effect-free. Then prefer submitting (type "\n" on single-line fields) or pressing a known on-screen control that does not mutate state.
On iOS, if it still returns UNSUPPORTED_OPERATION, both mechanisms were exhausted: do not assume a static text or heading is safe to press, because it can belong to a tappable parent. Use an app-provided dismiss control only when its action is explicitly intended; otherwise report that keyboard dismissal is unavailable.
On Android, keyboard dismiss first avoids navigation. If it returns UNSUPPORTED_OPERATION because the current IME needs back navigation, use back only when normal back behavior is acceptable; otherwise report that keyboard dismissal is unavailable.
Use plain fill/type first for ordinary login and form fields. If an iOS debounced or search-as-you-type field actually drops characters, or must receive incremental updates, retry with --delay-ms before trying clipboard paste; --delay-ms intentionally paces character entry.
iOS Allow Paste prompt cannot be exercised under XCUITest. To test paste-driven app behavior, prefill first with agent-device clipboard write "some text"; test the system prompt manually.
Expand Down
2 changes: 1 addition & 1 deletion src/commands/system/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ const orientationCliSchema = {
const keyboardCliSchema = {
usageOverride: 'keyboard [status|get|dismiss|enter|return]',
helpDescription:
'Inspect Android keyboard visibility/type or press/dismiss the device keyboard. To hide the keyboard, use keyboard dismiss. It taps safe controls like Done when available, verifies the keyboard closed, and reports UNSUPPORTED_OPERATION when no safe control is available.',
'Inspect Android keyboard visibility/type or press/dismiss the device keyboard. To hide the keyboard, use keyboard dismiss. It taps the keyboard dismiss/hide key when one is exposed, verifies the keyboard closed, and reports UNSUPPORTED_OPERATION when no dismiss key exists — background taps are never attempted.',
summary: 'Inspect, press, or dismiss the device keyboard',
positionalArgs: ['action?'],
} as const satisfies CommandSchemaOverride;
Expand Down
26 changes: 26 additions & 0 deletions src/commands/system/runtime/system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ test('runtime system commands call typed backend primitives', async () => {
]);
});

// #1598: the SDK-level keyboard.dismiss result must also disclose which
// mechanism the backend used, mirroring the CLI/daemon dispatch surface.
test('runtime keyboard dismiss discloses the mechanism reported by the backend', async () => {
const device = createAgentDevice({
backend: {
platform: 'ios',
setKeyboard: async (_context, options) => ({
action: options.action,
dismissed: true,
visible: false,
mechanism: 'legacySafeAreaTap',
}),
},
artifacts: createLocalArtifactAdapter(),
policy: localCommandPolicy(),
});

const keyboard = await device.system.keyboard({ action: 'dismiss' });

assert.equal(keyboard.kind, 'keyboardDismissed');
if (keyboard.kind === 'keyboardDismissed') {
assert.equal(keyboard.state.mechanism, 'legacySafeAreaTap');
}
assert.equal(String(keyboard.message), 'Keyboard dismissed');
});

test('runtime system commands validate options before backend calls', async () => {
const calls: unknown[] = [];
const device = createAgentDevice({
Expand Down
14 changes: 13 additions & 1 deletion src/commands/system/runtime/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,10 +409,22 @@ function normalizeKeyboardDismissResult(
action,
state,
...(backendResult ? { backendResult } : {}),
...successText(state.dismissed === false ? 'Keyboard already hidden' : 'Keyboard dismissed'),
...successText(keyboardDismissMessage(state)),
};
}

// Mirrors the CLI/daemon dispatch message (src/core/dispatch.ts) so both
// public surfaces disclose the same thing (#1598): only a dismiss-key tap is as
// trustworthy as tapping a real dismiss key, and callers should be able to
// tell the two apart from the message alone.
function keyboardDismissMessage(state: BackendKeyboardResult): string {
if (state.dismissed === false) return 'Keyboard already hidden';
if (state.mechanism === 'dismissKey') {
return 'Keyboard dismissed via its dismiss key';
}
return 'Keyboard dismissed';
}

function normalizeKeyboardStateResult(
action: 'status' | 'get',
state: BackendKeyboardResult,
Expand Down
74 changes: 74 additions & 0 deletions src/core/__tests__/dispatch-keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,77 @@ test('dispatch keyboard enter sends native iOS keyboard return command', async (
appBundleId: 'com.example.app',
});
});

// #1598: the response must disclose which mechanism the runner used to
// resign the keyboard — only the keyboard's own dismiss key is a mechanism the runner vouches for; an unrecognized wire value must degrade to the bare message rather than a false claim. The safe-area tap was removed (#1606 review):
// different reliability guarantees, and the CLI/SDK message must say which
// one actually fired rather than a bare "dismissed".
test('dispatch keyboard dismiss surfaces the dismissKey mechanism and message', async () => {
mockRunAppleRunnerCommand.mockResolvedValue({
message: 'keyboardDismiss',
wasVisible: true,
visible: false,
dismissed: true,
keyboardDismissMechanism: 'dismissKey',
});

const result = await dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, {
appBundleId: 'com.example.app',
});

assert.equal(result?.action, 'dismiss');
assert.equal(result?.dismissed, true);
assert.equal(result?.mechanism, 'dismissKey');
assert.equal(result?.message, 'Keyboard dismissed via its dismiss key');
});

test('dispatch keyboard dismiss degrades an unrecognized mechanism to the bare message', async () => {
mockRunAppleRunnerCommand.mockResolvedValue({
message: 'keyboardDismiss',
wasVisible: true,
visible: false,
dismissed: true,
keyboardDismissMechanism: 'legacySafeAreaTap',
});

const result = await dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, {
appBundleId: 'com.example.app',
});

assert.equal(result?.mechanism, 'legacySafeAreaTap');
assert.equal(String(result?.message), 'Keyboard dismissed');
});

test('dispatch keyboard dismiss omits mechanism when every mechanism failed', async () => {
mockRunAppleRunnerCommand.mockResolvedValue({
message: 'keyboardDismiss',
wasVisible: true,
visible: true,
dismissed: false,
});

const result = await dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, {
appBundleId: 'com.example.app',
});

assert.equal(result?.dismissed, false);
assert.equal(result?.mechanism, undefined);
assert.equal(result?.message, 'Keyboard already hidden');
});

test('dispatch keyboard dismiss omits mechanism when the keyboard was never visible', async () => {
mockRunAppleRunnerCommand.mockResolvedValue({
message: 'keyboardDismiss',
wasVisible: false,
visible: false,
dismissed: false,
});

const result = await dispatchCommand(IOS_DEVICE, 'keyboard', ['dismiss'], undefined, {
appBundleId: 'com.example.app',
});

assert.equal(result?.wasVisible, false);
assert.equal(result?.mechanism, undefined);
assert.equal(result?.message, 'Keyboard already hidden');
});
19 changes: 18 additions & 1 deletion src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,16 +511,33 @@ async function handleIosKeyboardCommand(
{ command: 'keyboardDismiss', appBundleId: context?.appBundleId },
runnerCtx,
);
const mechanism =
typeof result.keyboardDismissMechanism === 'string'
? result.keyboardDismissMechanism
: undefined;
return {
platform: 'ios',
action: 'dismiss',
wasVisible: result.wasVisible,
dismissed: result.dismissed,
visible: result.visible,
...successText(result.dismissed ? 'Keyboard dismissed' : 'Keyboard already hidden'),
mechanism,
...successText(iosKeyboardDismissMessage(result.dismissed === true, mechanism)),
};
}

// Discloses which mechanism actually resigned the keyboard (#1598): a
// Discloses that the keyboard's own dismiss key did the work (#1598); a bare
// "dismissed" would leave the caller unable to tell a vouched-for control tap
// from app-side coincidence.
function iosKeyboardDismissMessage(dismissed: boolean, mechanism: string | undefined): string {
if (!dismissed) return 'Keyboard already hidden';
if (mechanism === 'dismissKey') {
return 'Keyboard dismissed via its dismiss key';
}
return 'Keyboard dismissed';
}

async function handleSettingsCommand(
device: DeviceInfo,
interactor: Interactor,
Expand Down
44 changes: 44 additions & 0 deletions src/daemon/__tests__/session-event-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,50 @@ test('structural action events preserve navigation, viewport, keyboard, and gest
});
});

// #1598: the keyboard-dismiss mechanism must show up in both the human
// summary and the structured details, so a transcript reader can tell a
// vouched-for dismiss-key tap; unrecognized mechanisms are dropped (#1606).
test('keyboard dismiss action events drop an unrecognized mechanism', () => {
const keyboard = action('keyboard', {
action: 'dismiss',
platform: 'ios',
wasVisible: true,
visible: false,
dismissed: true,
mechanism: 'legacySafeAreaTap',
});

assert.equal(buildActionSummary(keyboard), 'Dismissed keyboard');
assert.deepEqual(buildActionDetails(keyboard), {
command: 'keyboard',
platform: 'ios',
action: 'dismiss',
visible: false,
wasVisible: true,
dismissed: true,
});
});

test('keyboard dismiss action events omit mechanism when the keyboard was already hidden', () => {
const keyboard = action('keyboard', {
action: 'dismiss',
platform: 'ios',
wasVisible: false,
visible: false,
dismissed: false,
});

assert.equal(buildActionSummary(keyboard), 'Keyboard was already hidden');
assert.deepEqual(buildActionDetails(keyboard), {
command: 'keyboard',
platform: 'ios',
action: 'dismiss',
visible: false,
wasVisible: false,
dismissed: false,
});
});

test('record and trace events expose only bounded artifact basenames', () => {
const trace = action(
'trace',
Expand Down
Loading
Loading