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 @@ -126,7 +126,7 @@ export function demoteNonUniqueLocalIdentity(
* role/id/label, NOT element identity, so two sibling rows both reading `label="Item"` are one
* identity — and an ambiguous identity answers nothing, the same fail-closed direction
* `demoteNonUniqueLocalIdentity` takes for a non-unique id. `#3471`'s moving-target gate pairs this
* with `movingTargetRectsStable` to ask whether one node held its position between two trees.
* with `areCapturedRectsAtSamePosition` to ask whether one node held its position between two trees.
*/
export function nodeRectByIdentity(
nodes: readonly IdentityPositionedNode[],
Expand Down
13 changes: 13 additions & 0 deletions packages/contracts/src/interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ export type SurfaceScopedNodes = {
surfaceBundleId?: string;
};

/** Which tree a resolved `@ref`'s node was read from (ADR 0014 step 5). */
export type RefObservationSource = 'observation' | 'authorized-frame';

export type ResolvedInteractionTarget =
| {
kind: 'point';
Expand All @@ -168,6 +171,16 @@ export type ResolvedInteractionTarget =
resolution?: ResolutionDisclosure;
recordingTarget?: RecordingTargetOverride;
preAction?: SurfaceScopedNodes;
/**
* ADR 0014 step 5: which tree the dispatched node was read from, reported by the
* reconciliation that chose it. `'observation'` means the session's newest capture, so the
* node's rect is a position that capture measured; `'authorized-frame'` means the older tree
* the ref was authorized against, whose rect says nothing about where the target sits now.
* Reported rather than re-derived downstream from node or rect identity, so no caller can
* disagree with the rule after the rule has decided. Absent on a node a caller carried in
* (#1654), which no reconciliation chose.
*/
adoptedFrom?: RefObservationSource;
}
| {
kind: 'selector';
Expand Down
73 changes: 5 additions & 68 deletions packages/contracts/src/moving-target-gate.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
MOVING_TARGET_GATE_RECT_TOLERANCE_PX,
MOVING_TARGET_GATE_SAMPLE_GAP_MS,
MOVING_TARGET_GATE_WINDOW_MS,
movingTargetGateDecision,
movingTargetRectsStable,
targetStillMovingHint,
} from './moving-target-gate.ts';

// #3471: the two judgements the moving-target gate makes, isolated from both callers so the window
// and the stability rule have exactly one reading. The runtime half (what a press does with these
// and the stability rule have exactly one reading. Whether two captures count as one position is
// kernel's `areCapturedRectsAtSamePosition`, tested beside the band it owns
// (`packages/kernel/src/rect-center.test.ts`). The runtime half (what a press does with these
// answers) is `src/commands/interaction/runtime/moving-target-gate.ts`; the Layer-3 scenarios behind
// the ADR 0011 `targetStability` cells drive the whole press.

const RECT = { x: 100, y: 200, width: 120, height: 44 };

function shifted(over: Partial<typeof RECT> = {}) {
return { ...RECT, ...over };
}

// --- is a second observation owed? ---

test('a session with no mutation clock is idle and owes nothing', () => {
Expand Down Expand Up @@ -123,61 +116,5 @@ test('a clock skew ahead of the mutation does not produce a negative wait', () =
assert.ok(decision.waitMs >= MOVING_TARGET_GATE_SAMPLE_GAP_MS);
});

// --- has the target stopped? ---

test('two observations at the same rect are a resting position', () => {
assert.equal(movingTargetRectsStable(RECT, shifted()), true);
});

test('capture rounding within the declared tolerance is not motion', () => {
assert.equal(MOVING_TARGET_GATE_RECT_TOLERANCE_PX, 1);
assert.equal(movingTargetRectsStable(RECT, shifted({ x: RECT.x + 1 })), true);
assert.equal(movingTargetRectsStable(RECT, shifted({ height: RECT.height + 1 })), true);
});

test('one axis moving is enough to still be moving', () => {
// The slide-in case from the #3471 repro (ADB Android): a translate animation keeps width and
// height constant, so only one axis says the target moved.
assert.equal(movingTargetRectsStable(RECT, shifted({ x: RECT.x + 2 })), false);
assert.equal(movingTargetRectsStable(RECT, shifted({ y: RECT.y + 2 })), false);
assert.equal(movingTargetRectsStable(RECT, shifted({ width: RECT.width + 2 })), false);
assert.equal(movingTargetRectsStable(RECT, shifted({ height: RECT.height + 2 })), false);
});

test('an observation that could not measure the target never agrees with one that could', () => {
// Absence is the surface having changed under the point, which is the exposure the gate exists
// to catch — so it can never be the observation that certifies rest.
assert.equal(movingTargetRectsStable(RECT, undefined), false);
assert.equal(movingTargetRectsStable(undefined, RECT), false);
assert.equal(movingTargetRectsStable(undefined, undefined), false);
assert.equal(movingTargetRectsStable(RECT, shifted({ width: Number.NaN })), false);
});

test('the stability comparison is symmetric', () => {
assert.equal(
movingTargetRectsStable(shifted({ y: 205 }), RECT),
movingTargetRectsStable(RECT, shifted({ y: 205 })),
);
});

// --- the refusal's published recovery ---

test('a refused selector press names the wait that recovers it', () => {
const hint = targetStillMovingHint({ selector: 'label="Slide target"' });

assert.match(hint, /wait 'label="Slide target"'/);
assert.match(hint, /same selector/);
assert.match(hint, /--settle/);
assert.match(hint, /nothing was tapped/);
});

test('a refused @ref press is sent to a selector, because the wait would retire its frame', () => {
// ADR 0014: the capture a `wait` takes expires the ref frame, so telling the caller to retry the
// same ref would trade a motion refusal for a stale-ref refusal.
const hint = targetStillMovingHint({ selector: undefined });

assert.match(hint, /snapshot -i/);
assert.match(hint, /selector/);
assert.match(hint, /ref frame/);
assert.doesNotMatch(hint, /wait '/);
});
// The refusal's published recovery is asserted in the runtime gate's suite, where the hint lives
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// beside the refusal that publishes it (`movingTargetHint`); this module decides, it does not speak.
85 changes: 17 additions & 68 deletions packages/contracts/src/moving-target-gate.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import type { Rect } from '@agent-device/kernel/snapshot';
import { normalizeRect } from '@agent-device/kernel/rect-center';
import type { SurfaceScopedNodes } from './interaction.ts';

/**
* The moving-target gate (#3471): a press must not dispatch at a point an in-flight transition has
* already vacated. A capture is one instant's photograph of a surface that may still be rearranging
* itself, and the ADB-backed Android helper is measured reporting frames in flight — so a press
* issued right after an action that moved the UI can resolve a point the target has already left and
* report success on the vacancy. The exposure is the runtime path's own capture-to-dispatch
* distance, so the gate is backend-neutral.
* The pure half of the moving-target gate (#3471): a press must not dispatch at a point an
* in-flight transition has already vacated. A capture is one instant's photograph of a surface that
* may still be rearranging itself, and the ADB-backed Android helper is measured reporting frames in
* flight — so a press issued right after an action that moved the UI can resolve a point the target
* has already left and report success on the vacancy. The exposure is the runtime path's own
* capture-to-dispatch distance, so the gate is backend-neutral.
*
* Two judgements decide when it is real, both pure over values the caller already holds so neither
* is re-derived per platform: {@link movingTargetGateDecision} asks whether this position is still
* owed a second observation, {@link movingTargetRectsStable} asks whether the target has stopped.
* The window is anchored on the mutation's END, so the gate waits out only what is LEFT of it —
* which is also why the refusal's hint names `--settle` as the caller-opted superset.
* Two judgements decide when it is real: {@link movingTargetGateDecision} asks whether this position
* is still owed a second observation, and kernel's `areCapturedRectsAtSamePosition` asks whether the
* target has stopped — that one lives in `@agent-device/kernel/rect-center` because the post-gesture
* no-effect comparison judges the same pair of capture rects and must not hold its own band. Both
* are pure over values the caller already holds, so neither is re-derived per platform. The window is
* anchored on the mutation's END, so the gate waits out only what is LEFT of it — which is also why
* the refusal's hint names `--settle` as the caller-opted superset.
*
* What cannot be decided over values — spending the window, taking the re-observations, and the
* refusal with its reason (`INTERACTION_ERROR_REASONS.targetStillMoving`) and hint — lives in
* `src/commands/interaction/runtime/moving-target-gate.ts`, which produces all three together.
*/

/** How long after a mutation its target positions stay owed a second observation. */
Expand All @@ -28,13 +32,6 @@ export const MOVING_TARGET_GATE_WINDOW_MS = 500;
*/
export const MOVING_TARGET_GATE_SAMPLE_GAP_MS = 50;

/**
* How far a rect may shift between the two observations and still count as the same position.
* Matches the tolerance the post-gesture surface comparison uses
* (`src/daemon/interaction-surface-signature.ts`), so capture rounding never reads as motion.
*/
export const MOVING_TARGET_GATE_RECT_TOLERANCE_PX = 1;

/**
* The tree a mutation resolved against, kept on the session with the instant that mutation
* finished, so the next action can ask whether its own coordinates are still in play.
Expand Down Expand Up @@ -86,51 +83,3 @@ export function movingTargetGateDecision(params: {
waitMs: Math.max(MOVING_TARGET_GATE_WINDOW_MS - ageMs, MOVING_TARGET_GATE_SAMPLE_GAP_MS),
};
}

/**
* Whether two consecutive observations put the target at the same position. A rect either
* observation could not measure is not stable: absence is the surface having changed under the
* point, which is the exposure the gate exists to catch.
*/
export function movingTargetRectsStable(
first: Rect | undefined,
second: Rect | undefined,
): boolean {
// `normalizeRect` is the repository's one "this rect reports a position" rule, so the gate can
// never measure a target the tap-point resolver itself would refuse to place. The tolerance stays
// here rather than in kernel's 0.5 px `areRectsApproximatelyEqual`: that one asks whether two
// values denote one point, this one asks whether capture rounding counts as motion, and 1 px is
// the band the post-gesture surface comparison already uses.
const a = normalizeRect(first);
const b = normalizeRect(second);
if (!a || !b) return false;
return (
Math.abs(a.x - b.x) <= MOVING_TARGET_GATE_RECT_TOLERANCE_PX &&
Math.abs(a.y - b.y) <= MOVING_TARGET_GATE_RECT_TOLERANCE_PX &&
Math.abs(a.width - b.width) <= MOVING_TARGET_GATE_RECT_TOLERANCE_PX &&
Math.abs(a.height - b.height) <= MOVING_TARGET_GATE_RECT_TOLERANCE_PX
);
}

/**
* The hint published beside a `target_still_moving` refusal — the recovery that works, with its
* wording owned here so every producer publishes one text. Pairing the two is a convention rather
* than something this module enforces: the reason is a literal in the vocabulary every interaction
* adapter reads (`INTERACTION_ERROR_REASONS.targetStillMoving` in
* `packages/selectors/src/interaction-error.ts`), so a producer could raise it without this hint.
* The runtime moving-target gate is the only producer today and publishes both together.
*
* A selector re-resolves against a fresh capture and can be retried as-is. An `@ref` cannot survive
* the wait: the capture a `wait` takes retires the ref frame (ADR 0014), which is the same reason
* the off-screen guard sends its `@ref` refusals to a selector.
*/
export function targetStillMovingHint(params: { selector: string | undefined }): string {
const reobserve = params.selector
? `Run wait '${params.selector}' and retry with the same selector`
: 'Run snapshot -i and retry with a selector (text=/id=) rather than this @ref — re-observing retires the ref frame';
return (
'The target was still moving when the transition window after the previous action ran out, so no resting ' +
`position could be confirmed and nothing was tapped. ${reobserve}; or pass --settle to have the action itself ` +
'wait for the UI to go quiet.'
);
}
55 changes: 54 additions & 1 deletion packages/kernel/src/rect-center.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { expect, test } from 'vitest';
import { interiorCoordinate, pointInsideRect } from './rect-center.ts';
import {
areCapturedRectsAtSamePosition,
interiorCoordinate,
pointInsideRect,
} from './rect-center.ts';

test('interiorCoordinate preserves one-pixel edge controls', () => {
expect(interiorCoordinate(0, 1)).toBe(0);
Expand All @@ -11,3 +15,52 @@ test('pointInsideRect clamps center point inside the rect bounds', () => {
y: 13,
});
});

const CAPTURED = { x: 100, y: 200, width: 120, height: 44 };

function shifted(over: Partial<typeof CAPTURED> = {}) {
return { ...CAPTURED, ...over };
}

test('two captures at the same rect report one position', () => {
expect(areCapturedRectsAtSamePosition(CAPTURED, shifted())).toBe(true);
});

test('a one-pixel shift is capture rounding, not motion', () => {
expect(areCapturedRectsAtSamePosition(CAPTURED, shifted({ x: CAPTURED.x + 1 }))).toBe(true);
expect(areCapturedRectsAtSamePosition(CAPTURED, shifted({ height: CAPTURED.height + 1 }))).toBe(
true,
);
});

test('the band is one pixel on every axis, and one axis is enough to move', () => {
// The slide-in case from the #3471 repro (ADB Android): a translate animation keeps width and
// height constant, so only one axis says the target moved.
expect(areCapturedRectsAtSamePosition(CAPTURED, shifted({ x: CAPTURED.x + 2 }))).toBe(false);
expect(areCapturedRectsAtSamePosition(CAPTURED, shifted({ y: CAPTURED.y + 2 }))).toBe(false);
expect(areCapturedRectsAtSamePosition(CAPTURED, shifted({ width: CAPTURED.width + 2 }))).toBe(
false,
);
expect(areCapturedRectsAtSamePosition(CAPTURED, shifted({ height: CAPTURED.height + 2 }))).toBe(
false,
);
});

test('a rect that reports no position never reports one position with another', () => {
// Absence is the surface having changed under the point, so it can never be the observation that
// certifies rest, for either the moving-target gate or the post-gesture no-effect claim.
expect(areCapturedRectsAtSamePosition(CAPTURED, undefined)).toBe(false);
expect(areCapturedRectsAtSamePosition(undefined, CAPTURED)).toBe(false);
expect(areCapturedRectsAtSamePosition(undefined, undefined)).toBe(false);
expect(areCapturedRectsAtSamePosition(CAPTURED, shifted({ width: Number.NaN }))).toBe(false);
expect(areCapturedRectsAtSamePosition(CAPTURED, shifted({ width: -1 }))).toBe(false);
});

test('the comparison is symmetric', () => {
expect(areCapturedRectsAtSamePosition(shifted({ y: 201 }), CAPTURED)).toBe(
areCapturedRectsAtSamePosition(CAPTURED, shifted({ y: 201 })),
);
expect(areCapturedRectsAtSamePosition(shifted({ y: 202 }), CAPTURED)).toBe(
areCapturedRectsAtSamePosition(CAPTURED, shifted({ y: 202 })),
);
});
28 changes: 28 additions & 0 deletions packages/kernel/src/rect-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { centerOfRect, type Point, type Rect } from '@agent-device/kernel/snapsh
const RECT_COMPARE_FIELDS = ['x', 'y', 'width', 'height'] as const;
const RECT_EQUALITY_TOLERANCE = 0.5;

/**
* The band within which two independently captured rects count as ONE position. Wider than
* {@link areRectsApproximatelyEqual}'s band, which asks whether two values denote one point: this
* one asks whether capture rounding counts as motion, and 1 px is the drift a backend's own
* rounding can produce on a node that never moved.
*/
const CAPTURE_RECT_TOLERANCE_PX = 1;

export function resolveRectCenter(rect: Rect | undefined): { x: number; y: number } | null {
const normalized = normalizeRect(rect);
if (!normalized) return null;
Expand Down Expand Up @@ -39,6 +47,26 @@ export function areRectsApproximatelyEqual(
);
}

/**
* Whether two observations put a target at the SAME POSITION — the one rule that decides whether
* capture rounding counts as motion, for every comparison of rects captured at different instants.
*
* Normalizes through {@link normalizeRect} first, so a rect that reports no position (absent,
* non-finite, inverted) never compares equal to anything: absence is the surface having changed
* under the point, never agreement.
*/
export function areCapturedRectsAtSamePosition(
first: Rect | undefined,
second: Rect | undefined,
): boolean {
const left = normalizeRect(first);
const right = normalizeRect(second);
if (!left || !right) return false;
return RECT_COMPARE_FIELDS.every(
(key) => Math.abs(left[key] - right[key]) <= CAPTURE_RECT_TOLERANCE_PX,
);
}

export function pointInsideRect(rect: Rect): Point {
return {
x: interiorCoordinate(rect.x, rect.width),
Expand Down
12 changes: 7 additions & 5 deletions packages/selectors/src/interaction-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,13 @@ export const INTERACTION_ERROR_REASONS = {
/** The target resolved, but another visible element covers it. */
targetCovered: 'target_covered',
/**
* A press or click refused because its target's rect was still changing when the transition window
* after the session's last mutating action ran out. `details.selector` or `details.ref` names the
* target, `details.rect` where the command resolved it and `details.movedToRect` where the last
* re-observation found it. Branch on this reason, not the message: the recovery is to wait for the
* surface and retry, never to retry the same point immediately. The owner is
* A press or click refused because it could not confirm a resting position for its target: the
* target's rect was still changing when the transition window after the session's last mutating
* action ran out, or a re-observation could not measure the position at all. `details.selector` or
* `details.ref` names the target, `details.rect` is where the command resolved it, and
* `details.movedToRect` where the last re-observation found it — present only when that look
* measured one. Branch on this reason, not the message: the recovery is to wait for the surface
* and retry, never to retry the same point immediately. The owner is
* `src/commands/interaction/runtime/moving-target-gate.ts`, which raises the reason beside the
* hint that names that recovery.
*/
Expand Down
Loading
Loading