Skip to content

Commit 673ea45

Browse files
committed
refactor(contracts): state the scroll keyboard refusal details once and keep the runner's message
The Apple scroll owner rebuilt the refusal per command, discarding the runner's measured message and carrying an unmeasured variant of the error builder for it. The shared reason and hint are now one frozen object in scroll-gesture; the Apple owner adds it to the runner's own error (matched on the typed runner code, transport details kept), and the error builder takes a plain measured occlusion, which only Android produces in-process. The help text names the behaviour in one clause; the hint carries the recovery at the moment it matters.
1 parent 1b8548a commit 673ea45

6 files changed

Lines changed: 61 additions & 103 deletions

File tree

‎packages/contracts/src/scroll-gesture.test.ts‎

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,6 @@ test('an unusable keyboard frame fails open instead of refusing every scroll', (
306306

307307
test('the occlusion refusal is keyed on its reason, not on its message', () => {
308308
const error = scrollKeyboardOccludesSurfaceError('down', {
309-
kind: 'occluded',
310309
keyboardMinY: 564,
311310
visibleHeight: 40,
312311
viewportHeight: 874,
@@ -319,20 +318,3 @@ test('the occlusion refusal is keyed on its reason, not on its message', () => {
319318
assert.equal(error.details?.viewportHeight, 874);
320319
assert.match(String(error.details?.hint), /keyboard dismiss/);
321320
});
322-
323-
test('an unmeasured refusal still names the same reason, so error text never gates recovery', () => {
324-
// The iOS runner refuses in its own coordinate space and reports only its typed runner code, so
325-
// the Apple owner rebuilds this error without numbers. Matching on `reason` has to yield the
326-
// same key with or without a measurement, or the message becomes the discriminator.
327-
const unmeasured = scrollKeyboardOccludesSurfaceError('down');
328-
const measured = scrollKeyboardOccludesSurfaceError('down', {
329-
kind: 'occluded',
330-
keyboardMinY: 564,
331-
visibleHeight: 40,
332-
viewportHeight: 874,
333-
});
334-
assert.equal(unmeasured.details?.reason, measured.details?.reason);
335-
assert.equal(unmeasured.details?.keyboardMinY, undefined);
336-
assert.equal(unmeasured.details?.visibleHeight, undefined);
337-
assert.ok(!String(unmeasured.message).includes('px of'), 'no fabricated measurement may appear');
338-
});

‎packages/contracts/src/scroll-gesture.ts‎

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -359,12 +359,23 @@ export const SCROLL_KEYBOARD_MIN_VISIBLE_FRACTION = 0.15;
359359
export const SCROLL_KEYBOARD_ACCESSORY_ALLOWANCE = 12;
360360

361361
/**
362-
* The one reason a directional scroll refuses to swipe at all (#2500). The iOS runner answers with
363-
* its own runner error code for the same condition; that code is the Apple runner's wire
364-
* vocabulary and lives with it, not here, because Android raises this reason locally.
362+
* The one reason a directional scroll refuses to swipe at all (#2500), with the hint every owner
363+
* publishes beside it: Android measures the occlusion in this process, and the iOS runner answers
364+
* with its own runner code that the Apple scroll owner joins to these same details, so a
365+
* caller branches on `reason` and reads one hint whichever owner refused. Avoidance never dismisses
366+
* the keyboard: a dismiss drops focus, which breaks a `type`/`scroll`/`type` loop, is not idempotent
367+
* across platforms, and mutates state session-action provenance does not record. So the hint names
368+
* the tradeoff instead of paying it.
365369
*/
366370
export const SCROLL_KEYBOARD_OCCLUDES_SURFACE_REASON = 'scroll_keyboard_occludes_surface';
367371

372+
export const SCROLL_KEYBOARD_OCCLUDES_SURFACE_DETAILS = Object.freeze({
373+
reason: SCROLL_KEYBOARD_OCCLUDES_SURFACE_REASON,
374+
hint:
375+
'The on-screen keyboard covers the surface this scroll would swipe, so it cannot reach it. ' +
376+
'Run `keyboard dismiss` and retry, accepting that it drops focus (re-tap the field to keep typing), or scroll before focusing the field.',
377+
});
378+
368379
export type ScrollKeyboardClip =
369380
/** No keyboard, or one that does not own this surface: swipe the whole viewport. */
370381
| { kind: 'unobstructed' }
@@ -377,8 +388,10 @@ export type ScrollKeyboardClip =
377388
*/
378389
| { kind: 'occluded'; keyboardMinY: number; visibleHeight: number };
379390

380-
/** The numbers a refusing owner can name about the surface it declined to swipe. */
381-
export type ScrollKeyboardOcclusion = Extract<ScrollKeyboardClip, { kind: 'occluded' }> & {
391+
/** The numbers a refusing owner names about the surface it declined to swipe. */
392+
export type ScrollKeyboardOcclusion = {
393+
keyboardMinY: number;
394+
visibleHeight: number;
382395
viewportHeight: number;
383396
};
384397

@@ -421,38 +434,26 @@ export function clipScrollViewportAboveKeyboard(
421434
}
422435

423436
/**
424-
* The refusal a scroll reports when the keyboard owns the surface. `avoidanceNeverDismisses` is the
425-
* point: a dismiss drops focus, which breaks a `type`/`scroll`/`type` loop, is not idempotent
426-
* across platforms (Android's ESC loop can throw `UNSUPPORTED_OPERATION`), and mutates state
427-
* session-action provenance does not record. So the caller names the tradeoff instead of paying it.
428-
*
429-
* `occlusion` is optional because the owner that measured the frame may be the runner rather than
430-
* this process: the iOS XCTest runner refuses in its own coordinate space and reports the typed
431-
* runner code, and re-deriving its numbers here would be a second source of truth.
437+
* The refusal an owner that measured the keyboard in this process reports. The iOS runner measures
438+
* in its own coordinate space and answers with its runner code instead; the Apple scroll owner
439+
* adds the same `SCROLL_KEYBOARD_OCCLUDES_SURFACE_DETAILS` to that error.
432440
*/
433441
export function scrollKeyboardOccludesSurfaceError(
434442
direction: ScrollDirection,
435-
occlusion?: ScrollKeyboardOcclusion,
443+
occlusion: ScrollKeyboardOcclusion,
436444
): AppError {
437445
const percent = Math.round(SCROLL_KEYBOARD_MIN_VISIBLE_FRACTION * 100);
438-
const measured =
439-
occlusion === undefined
440-
? 'the keyboard leaves too little visible surface for a swipe'
441-
: `the keyboard leaves ${occlusion.visibleHeight}px of ${occlusion.viewportHeight}px visible, below the ${percent}% needed for a swipe`;
442-
return new AppError('COMMAND_FAILED', `scroll ${direction} refused: ${measured}`, {
443-
reason: SCROLL_KEYBOARD_OCCLUDES_SURFACE_REASON,
444-
...(occlusion === undefined
445-
? {}
446-
: {
447-
keyboardMinY: occlusion.keyboardMinY,
448-
visibleHeight: occlusion.visibleHeight,
449-
viewportHeight: occlusion.viewportHeight,
450-
}),
451-
minVisibleFraction: SCROLL_KEYBOARD_MIN_VISIBLE_FRACTION,
452-
hint:
453-
'The on-screen keyboard covers the surface this scroll would swipe, so it cannot reach it. ' +
454-
'Run `keyboard dismiss` and retry, accepting that it drops focus (re-tap the field to keep typing), or scroll before focusing the field.',
455-
});
446+
return new AppError(
447+
'COMMAND_FAILED',
448+
`scroll ${direction} refused: the keyboard leaves ${occlusion.visibleHeight}px of ${occlusion.viewportHeight}px visible, below the ${percent}% needed for a swipe`,
449+
{
450+
keyboardMinY: occlusion.keyboardMinY,
451+
visibleHeight: occlusion.visibleHeight,
452+
viewportHeight: occlusion.viewportHeight,
453+
minVisibleFraction: SCROLL_KEYBOARD_MIN_VISIBLE_FRACTION,
454+
...SCROLL_KEYBOARD_OCCLUDES_SURFACE_DETAILS,
455+
},
456+
);
456457
}
457458

458459
function isMeasurableRect(rect: Rect): boolean {

‎packages/platform-apple/src/core/__tests__/scroll.test.ts‎

Lines changed: 17 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,6 @@ import {
77
withAppleScrollKeyboardOcclusion,
88
} from '../scroll.ts';
99

10-
const RUNNER_OCCLUSION_CODE = 'SCROLL_KEYBOARD_OCCLUDES_SURFACE';
11-
12-
function runnerOcclusionError(): AppError {
13-
return new AppError(
14-
'COMMAND_FAILED',
15-
'scroll down refused: the keyboard leaves 28pt of surface',
16-
{
17-
runnerErrorCode: RUNNER_OCCLUSION_CODE,
18-
logPath: '/tmp/runner.log',
19-
},
20-
);
21-
}
22-
2310
test('a clipped runner frame yields travel and evidence for the band actually swiped', () => {
2411
// The runner clips the interaction frame above the keyboard and reports the clipped axis, so the
2512
// TS recomputation of `pixels` must be honest about the shorter travel (#2500). Reading the
@@ -76,34 +63,29 @@ test('avoidance from a runner that reports no keyboard edge is still avoidance',
7663
assert.equal('keyboardMinY' in result, false);
7764
});
7865

79-
test('the runner keyboard refusal becomes the typed reason a caller can branch on', () => {
80-
const mapped = withAppleScrollKeyboardOcclusion(runnerOcclusionError(), 'down');
66+
test('the runner keyboard refusal gains the shared reason and hint and keeps its own message', () => {
67+
const runnerError = new AppError(
68+
'COMMAND_FAILED',
69+
'scroll down refused: the keyboard leaves 28pt of visible surface above it',
70+
{ runnerErrorCode: 'SCROLL_KEYBOARD_OCCLUDES_SURFACE', logPath: '/tmp/runner.log' },
71+
);
72+
const mapped = withAppleScrollKeyboardOcclusion(runnerError);
8173
assert.ok(mapped instanceof AppError);
8274
assert.equal(mapped.code, 'COMMAND_FAILED');
75+
assert.equal(mapped.message, runnerError.message);
8376
assert.equal(mapped.details?.reason, SCROLL_KEYBOARD_OCCLUDES_SURFACE_REASON);
8477
assert.match(String(mapped.details?.hint), /keyboard dismiss/);
85-
});
86-
87-
test('the mapped refusal keeps the transport diagnostics the original error carried', () => {
88-
const mapped = withAppleScrollKeyboardOcclusion(runnerOcclusionError(), 'down');
89-
assert.ok(mapped instanceof AppError);
9078
assert.equal(mapped.details?.logPath, '/tmp/runner.log');
91-
assert.equal(mapped.details?.runnerErrorCode, RUNNER_OCCLUSION_CODE);
79+
assert.equal(mapped.details?.runnerErrorCode, 'SCROLL_KEYBOARD_OCCLUDES_SURFACE');
9280
});
9381

94-
test('the nearest negatives stay untouched, so only the refusal code renames an error', () => {
95-
// Same code, different classification: a generic scroll failure must not read as an occlusion, or
96-
// the caller would be told to dismiss a keyboard that is not in the way.
97-
const generic = new AppError(
98-
'COMMAND_FAILED',
99-
'scroll could not resolve a usable interaction frame',
100-
{
101-
logPath: '/tmp/runner.log',
102-
},
103-
);
104-
assert.equal(withAppleScrollKeyboardOcclusion(generic, 'down'), generic);
105-
const transport = new Error('socket hang up');
106-
assert.equal(withAppleScrollKeyboardOcclusion(transport, 'down'), transport);
82+
test('only the refusal code is renamed, so a generic scroll failure never reads as an occlusion', () => {
83+
const generic = new AppError('COMMAND_FAILED', 'scroll could not resolve a usable frame', {
84+
logPath: '/tmp/runner.log',
85+
});
86+
assert.equal(withAppleScrollKeyboardOcclusion(generic), generic);
10787
const alert = new AppError('COMMAND_FAILED', 'no alert', { runnerErrorCode: 'ALERT_NOT_FOUND' });
108-
assert.equal(withAppleScrollKeyboardOcclusion(alert, 'down'), alert);
88+
assert.equal(withAppleScrollKeyboardOcclusion(alert), alert);
89+
const transport = new Error('socket hang up');
90+
assert.equal(withAppleScrollKeyboardOcclusion(transport), transport);
10991
});

‎packages/platform-apple/src/core/scroll.ts‎

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import {
66
} from '@agent-device/contracts/scroll-command';
77
import {
88
type ScrollDirection,
9+
SCROLL_KEYBOARD_OCCLUDES_SURFACE_DETAILS,
910
buildScrollGesturePlan,
10-
scrollKeyboardOccludesSurfaceError,
1111
} from '@agent-device/contracts/scroll-gesture';
1212
import { AppError } from '@agent-device/kernel/errors';
1313
import { SCROLL_KEYBOARD_OCCLUDES_SURFACE_RUNNER_CODE } from '../runner/runner-contract.ts';
@@ -22,26 +22,19 @@ export type NormalizedScrollOptions = {
2222
export type AppleScrollOptions = ScrollExecutionOptions;
2323

2424
/**
25-
* Turns the runner's keyboard-occlusion refusal into the reason a caller acts on (#2500).
26-
*
27-
* The runner owns the live keyboard frame and declines to place a swipe it cannot keep above the
28-
* keys; it answers with a typed runner code rather than prose so nothing here has to read an error
29-
* message. The numbers stay on the runner's side of the boundary — re-deriving them from a frame
30-
* this process does not hold would be a second source of truth — so the reason and hint are the
31-
* evidence, joined to whatever the transport already recorded (`logPath`, `runnerErrorCode`).
25+
* Gives the runner's keyboard-occlusion refusal the shared reason and hint (#2500). The runner
26+
* measured the keyboard in its own coordinate space, so its message and transport details
27+
* (`runnerErrorCode`, `logPath`) are kept as they are; only the details every platform publishes
28+
* are added, matched on the typed runner code rather than on error text.
3229
*/
33-
export function withAppleScrollKeyboardOcclusion(
34-
error: unknown,
35-
direction: ScrollDirection,
36-
): unknown {
30+
export function withAppleScrollKeyboardOcclusion(error: unknown): unknown {
3731
if (!(error instanceof AppError)) return error;
3832
if (error.details?.['runnerErrorCode'] !== SCROLL_KEYBOARD_OCCLUDES_SURFACE_RUNNER_CODE) {
3933
return error;
4034
}
41-
const refusal = scrollKeyboardOccludesSurfaceError(direction);
42-
return new AppError(refusal.code, refusal.message, {
43-
...(error.details ?? {}),
44-
...(refusal.details ?? {}),
35+
return new AppError(error.code, error.message, {
36+
...error.details,
37+
...SCROLL_KEYBOARD_OCCLUDES_SURFACE_DETAILS,
4538
});
4639
}
4740

‎packages/platform-apple/src/interactions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,7 @@ async function runAppleScroll(
445445
runnerOpts,
446446
);
447447
} catch (error) {
448-
throw withAppleScrollKeyboardOcclusion(error, direction);
448+
throw withAppleScrollKeyboardOcclusion(error);
449449
}
450450

451451
return normalizeAppleScrollResultWithResolvedFrame(runnerResult, direction, iosOptions);

‎src/commands/interaction/metadata.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const interactionCommandDescriptions = {
6868
'Move input focus to explicit screen coordinates without entering text. Prefer semantic interactions when a snapshot ref or selector is available; use type or fill after focus.',
6969
type: 'Append text to the currently focused input. Use fill when the existing field value should be replaced, and focus first when no input is active.',
7070
scroll:
71-
'Scroll in a direction, or toward the top/bottom edge of scrollable content. Set until to a selector to reach an off-screen target in one command rather than a scroll-and-check loop. The optional amount is the finger-path fraction of the viewport axis, honored up to 0.8 of it; directional scrolls reduce release momentum, while app scroll physics determine the final content offset. A visible keyboard shortens the swiped band rather than dismissing it, which lowers the reported pixels; when too little surface is left to swipe, the command refuses with scroll_keyboard_occludes_surface and leaves focus alone.',
71+
'Scroll in a direction, or toward the top/bottom edge of scrollable content. Set until to a selector to reach an off-screen target in one command rather than a scroll-and-check loop. The optional amount is the finger-path fraction of the viewport axis, honored up to 0.8 of it; directional scrolls reduce release momentum, while app scroll physics determine the final content offset. A visible keyboard shortens the swiped band instead of being dismissed; when too little is left, the command refuses with scroll_keyboard_occludes_surface.',
7272
get: 'Read text or accessibility attributes from a snapshot ref or selector without changing the app. Use format text for visible content or attrs for the element attribute map.',
7373
is: 'Check whether a selector satisfies a UI predicate such as visible, hidden, exists, absent, editable, selected, focused, or text. `absent` passes only when one readable, complete, unscoped, full-depth accessibility capture has zero matches. Use wait when the condition may appear asynchronously.',
7474
find: 'Find by text/label/value/role/id and run action',

0 commit comments

Comments
 (0)