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
14 changes: 9 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@
- Fixed (mobile): a read taken right after a `scroll`, `swipe`, or `gesture swipe` no longer reports
a definite miss when the surface never settled. When post-gesture stabilization ran out of budget
on a surface still moving, `is visible` answered a plain `selector_not_found` and `is absent`
passed. That capture now carries `unsettledGesture`: `is`, `get`, `find`, and `wait` report it (in
`error.details` or `data`) with an appended warning, `snapshot` appends the warning, `is absent`
refuses with `observation: "unsettled"`, `wait absent` keeps polling, and the next read captures
afresh. Click, press, and fill by selector do not disclose it yet. A failed read now also carries
`targetActivation` in `error.details`, the same place as `unsettledGesture`.
passed. The capture now carries `postGestureOutcome` (`{ kind, gesture: { action, positionals } }`)
with `kind: "unsettled"`, and so does a re-capture taken at once to recover or widen it. A proven
no-effect gesture rides the same field with `kind: "no-effect"`; before, its warning reached only
`snapshot`. `is`, `get`, `find`, `wait`, and every interaction that captured it (`click`, `press`,
`fill`, and the other touch and gesture commands) report the field in `data` or `error.details`
with an appended warning; `snapshot` appends the warning. `is absent` refuses an unsettled capture
with `observation: "unsettled"`, `wait absent` keeps polling, and the next read captures afresh.
A failed read also carries `targetActivation` in `error.details`, and a failed interaction now
keeps the disclosure sentences in its hint.
- Fixed (ios): `open` on a local Simulator now waits for the launched app's discovery before it
decides whether the app is observable. On a loaded host `simctl spawn launchctl list` outlasts one
1.5 s discovery wait slice, and the launch observation read that slice as an unobservable app, so
Expand Down
65 changes: 26 additions & 39 deletions packages/capture-kit/src/post-gesture-stability.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import { sleep } from '@agent-device/host-kit/retry';
import type { PostGestureAction } from '@agent-device/kernel/snapshot';
import type { PostGestureAction, PostGestureOutcome } from '@agent-device/kernel/snapshot';

/**
* Pure post-gesture stability mechanics: the quiet-window polling loop and the
Expand Down Expand Up @@ -59,41 +59,37 @@ export type PostGestureStabilityHooks<T, S extends readonly unknown[]> = {

export type PostGestureStabilityOutcome<T> = {
value: T;
/** Present when the deadline expired while the last two captures still disagreed. */
unsettledGesture?: PostGestureAction;
/**
* Present ONLY when the accept-stale verdict is corroborated by full-surface
* evidence (`surfacesIdentical`). The bare verdict is NOT enough — it is
* subset-tolerant by design, and a successful scroll that replaced every
* list cell under fixed chrome still reads accept-stale (#1601 review P1).
* Callers surface this to the agent: a diagnostics-only signal let one
* benchmark run burn 40 calls re-issuing scrolls that moved nothing (#1600).
* `unsettled` when the deadline expired while the last two captures still disagreed.
* `no-effect` ONLY when the accept-stale verdict is corroborated by full-surface evidence
* (`surfacesIdentical`): the bare verdict is subset-tolerant by design, and a successful scroll
* that replaced every list cell under fixed chrome still reads accept-stale (#1601 review P1).
* Callers surface it to the agent: a diagnostics-only signal let one benchmark run burn 40 calls
* re-issuing scrolls that moved nothing (#1600).
*/
gestureNoEffect?: PostGestureAction;
postGestureOutcome?: PostGestureOutcome;
};

function describePostGestureAction(gesture: PostGestureAction): string {
return [gesture.action, ...gesture.positionals].join(' ').trim();
}

/**
* The agent-facing wording for a proven no-effect gesture. Names the exact
* gesture, admits the honest ambiguity (at-edge is a legitimate no-op the
* platform cannot distinguish), and hands over the one escape hatch that
* moved a stuck list when synthesized scrolls did not (#1600, element-18:
* raw `swipe` worked where scroll/fling/pan all silently no-opped).
* The agent-facing sentence for a post-gesture outcome, true whether the read that carries it found
* its target or not. A no-effect gesture admits the honest ambiguity (at-edge is a legitimate no-op
* the platform cannot distinguish) and hands over the escape hatch that moved a stuck list when
* synthesized scrolls did not (#1600: raw `swipe` worked where scroll/fling/pan all no-opped).
*/
export function formatGestureNoEffectWarning(action: string, positionals: string[]): string {
return (
`${describePostGestureAction({ action, positionals })} produced no visible change: the tree still matches its pre-gesture state. ` +
'Either the container is already at its edge, or it ignores synthesized scrolls — ' +
'a raw drag moves such lists: swipe x1 y1 x2 y2 (start inside the list).'
);
export function formatPostGestureOutcomeWarning({ kind, gesture }: PostGestureOutcome): string {
const named = [gesture.action, ...gesture.positionals].join(' ').trim();
return kind === 'unsettled'
? `The surface was still changing after ${named} when this tree was read, so it may not match where the surface comes to rest: an element missing from it is not proof of absence.`
: `${named} produced no visible change: the tree still matches its pre-gesture state. ` +
'Either the container is already at its edge, or it ignores synthesized scrolls — ' +
'a raw drag moves such lists: swipe x1 y1 x2 y2 (start inside the list).';
}

/** A miss on a tree read while a gesture's surface was still changing is not proof of absence. */
export function formatGestureUnsettledWarning(gesture: PostGestureAction): string {
return `The surface was still changing after ${describePostGestureAction(gesture)} when this was read, so an element missing from it may still be on screen. Read again before treating it as absent.`;
function postGestureOutcome(
kind: PostGestureOutcome['kind'],
pending: PostGestureAction,
): PostGestureOutcome {
return { kind, gesture: { action: pending.action, positionals: pending.positionals } };
}

/**
Expand Down Expand Up @@ -199,10 +195,7 @@ export async function runPostGestureStabilityLoop<T, S extends readonly unknown[
},
});
if (lastPairAgreed) return { value: previous.value };
return {
value: previous.value,
unsettledGesture: { action: pending.action, positionals: pending.positionals },
};
return { value: previous.value, postGestureOutcome: postGestureOutcome('unsettled', pending) };
}

type CapturedSurface<T, S> = {
Expand Down Expand Up @@ -266,13 +259,7 @@ function buildAcceptedOutcome<T, S extends readonly unknown[]>(
baselineSignature !== undefined &&
hooks.surfacesIdentical(baselineSignature, current.signature)
) {
return {
value: current.value,
gestureNoEffect: {
action: pending.action,
positionals: pending.positionals,
},
};
return { value: current.value, postGestureOutcome: postGestureOutcome('no-effect', pending) };
}
emitDiagnostic({
level: 'info',
Expand Down
25 changes: 23 additions & 2 deletions packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,13 +502,34 @@ export type SnapshotState = {
* the foreground instead (#2682). Consumers that surface this tree disclose the repair.
*/
targetActivation?: IosTargetActivation;
/** The gesture whose surface was still changing when stabilization gave up on this capture. */
unsettledGesture?: PostGestureAction;
/** What post-gesture stabilization proved about the gesture before this capture. */
postGestureOutcome?: PostGestureOutcome;
} & SnapshotStateProvenance;

/** The gesture a post-gesture outcome fact names: the command and its positionals. */
export type PostGestureAction = { action: string; positionals: string[] };

/**
* `unsettled`: the surface was still changing when the stabilization deadline expired.
* `no-effect`: the settled surface still matches the pre-gesture tree (#1600).
*/
export type PostGestureOutcome = {
kind: 'unsettled' | 'no-effect';
gesture: PostGestureAction;
};

/**
* A capture taken at once to recover or widen `previous` reads the same moment after the same
* gesture, so it carries that capture's outcome.
*/
export function inheritPostGestureOutcome<T extends SnapshotState>(
previous: SnapshotState,
recapture: T,
): T {
recapture.postGestureOutcome ??= previous.postGestureOutcome;
return recapture;
}

export type SnapshotUnchanged = {
ageMs: number;
nodeCount: number;
Expand Down
7 changes: 5 additions & 2 deletions packages/selectors/src/absence-observation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export function absenceCaptureOptionMessage(
export function classifyAbsenceObservation(
snapshot: Pick<
SnapshotState,
'backend' | 'nodes' | 'snapshotQuality' | 'truncated' | 'unsettledGesture'
'backend' | 'nodes' | 'snapshotQuality' | 'truncated' | 'postGestureOutcome'
>,
matches: readonly SnapshotNode[],
): AbsenceObservation {
Expand Down Expand Up @@ -100,7 +100,10 @@ export function classifyAbsenceObservation(
};
}
if (matchCount === 0) {
return { kind: snapshot.unsettledGesture ? 'unsettled' : 'absent', matches: 0 };
return {
kind: snapshot.postGestureOutcome?.kind === 'unsettled' ? 'unsettled' : 'absent',
matches: 0,
};
}
return { kind: 'present', matches: matchCount, firstMatch: firstMatch! };
}
Expand Down
12 changes: 8 additions & 4 deletions src/commands/capture/runtime/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
type CommandSessionStore,
} from '../../../runtime.ts';
import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures';
import { formatGestureUnsettledWarning } from '@agent-device/capture-kit/post-gesture-stability';
import type { PostGestureOutcome } from '@agent-device/kernel/snapshot';
import { formatPostGestureOutcomeWarning } from '@agent-device/capture-kit/post-gesture-stability';

test('runtime snapshot captures nodes and updates the session baseline', async () => {
let stored: Parameters<CommandSessionStore['set']>[0] | undefined;
Expand Down Expand Up @@ -789,17 +790,20 @@ test('runtime snapshot leaves the keyboard band off when the backend measured no
});

test('runtime snapshot warns when its tree was read on a surface still moving after a gesture', async () => {
const gesture = { action: 'scroll', positionals: ['down'] };
const outcome: PostGestureOutcome = {
kind: 'unsettled',
gesture: { action: 'scroll', positionals: ['down'] },
};
const device = createSnapshotOnlyDevice({
snapshot: {
...makeSnapshotState([{ index: 0, depth: 0, type: 'Window', label: 'Home' }], {
backend: 'xctest',
}),
unsettledGesture: gesture,
postGestureOutcome: outcome,
},
});

const result = await device.capture.snapshot({ session: 'default' });

assert.deepEqual(result.warnings, [formatGestureUnsettledWarning(gesture)]);
assert.deepEqual(result.warnings, [formatPostGestureOutcomeWarning(outcome)]);
});
6 changes: 3 additions & 3 deletions src/commands/capture/runtime/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
renderSnapshotQualityWarnings,
truncatedCaptureWarning,
} from '@agent-device/capture-kit/quality-warnings';
import { formatGestureUnsettledWarning } from '@agent-device/capture-kit/post-gesture-stability';
import { formatPostGestureOutcomeWarning } from '@agent-device/capture-kit/post-gesture-stability';
import { buildSnapshotVisibility } from '@agent-device/capture-kit/snapshot-visibility';
import { ANDROID_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/android-system-surface-disclosure';
import { formatReactNativeOverlayWarning } from '../../react-native/overlay.ts';
Expand Down Expand Up @@ -261,8 +261,8 @@ function buildSnapshotWarnings(params: {
);
}
warnings.push(...truncatedCaptureWarning(snapshotTruncationForResult(params.snapshot)));
if (params.snapshot.unsettledGesture) {
warnings.push(formatGestureUnsettledWarning(params.snapshot.unsettledGesture));
if (params.snapshot.postGestureOutcome) {
warnings.push(formatPostGestureOutcomeWarning(params.snapshot.postGestureOutcome));
}
warnings.push(...buildEmptyAndroidInteractiveWarnings(params));
if (!params.annotations.quality) {
Expand Down
8 changes: 7 additions & 1 deletion src/commands/interaction/runtime/resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import type {
SnapshotNode,
SnapshotState,
} from '@agent-device/kernel/snapshot';
import { findNodeByRef, normalizeRef } from '@agent-device/kernel/snapshot';
import {
findNodeByRef,
inheritPostGestureOutcome,
normalizeRef,
} from '@agent-device/kernel/snapshot';
import { resolveRectCenter } from '@agent-device/kernel/rect-center';
import type {
AgentDeviceRuntime,
Expand Down Expand Up @@ -390,7 +394,9 @@ async function resolveSelectorInteractionTarget(
params.pipeline,
);
if ((!resolved || !resolved.node.rect) && params.requireInteractive) {
const interactive = capture.snapshot;
capture = await captureInteractionSnapshot(runtime, options, false);
inheritPostGestureOutcome(interactive, capture.snapshot);
resolved = resolveActionSelector(
capture.snapshot.nodes,
selectorExpression,
Expand Down
5 changes: 4 additions & 1 deletion src/commands/interaction/runtime/wait-absent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,10 @@ test('wait absent does not take a miss on a surface still moving after a gesture
const unsettled = {
snapshot: {
...makeSnapshotState([]),
unsettledGesture: { action: 'scroll', positionals: ['down'] },
postGestureOutcome: {
kind: 'unsettled' as const,
gesture: { action: 'scroll', positionals: ['down'] },
},
},
};
const device = absentDevice([unsettled, snapshot('Removed')]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ test('a failure response appends the repair to its hint and keeps the original d
assert.equal(response.error.message, 'selector missed');
assert.equal(response.error.code, 'COMMAND_FAILED');
assert.equal(response.error.details?.blockedBy, 'android_foreground_surface');
assert.match(String(response.error.details?.hint), /prior state runningBackground/);
assert.match(String(response.error.hint), /prior state runningBackground/);
});

test('a capture with no repair leaves the response byte-identical', () => {
Expand All @@ -64,7 +64,7 @@ test('a capture with no repair leaves the response byte-identical', () => {
});

/**
* A failure carries the sentence in `error.details.hint`. That carrier is exactly where a route could
* A failure carries the sentence in `error.hint`. That carrier is exactly where a route could
* cheaply borrow the previous command's repair off the stored snapshot and blame it on this request,
* so the repair travels only on the request's own proof (#2682).
*/
Expand All @@ -77,7 +77,7 @@ test('a failure does not borrow a repair the stored snapshot happens to carry',
const response = withCaptureDisclosures({
response: failed,
consumedTree: { targetActivation: FACT },
activationProof: {},
captureProof: {},
});

assert.equal(response, failed);
Expand All @@ -95,7 +95,7 @@ test('surface and foreground disclosures ride one response together', () => {
iosSystemSurfaceBundleId: 'com.apple.SafariViewService',
targetActivation: FACT,
},
activationProof: { state: { targetActivation: FACT } },
captureProof: { targetActivation: FACT },
});
const data = dataOf(response);
assert.match(String(data.warning), /system web sign-in sheet/);
Expand All @@ -117,22 +117,22 @@ test('a repair that passes through two wrappers is named once in the failure hin
details: { hint: 'Use snapshot to see the current tree.' },
},
};
const proof = { state: { targetActivation: FACT } };
const proof = { targetActivation: FACT };

const once = withCaptureDisclosures({
response: missed,
consumedTree: { targetActivation: FACT },
activationProof: proof,
captureProof: proof,
});
const twice = withCaptureDisclosures({
response: once,
consumedTree: { targetActivation: FACT },
activationProof: proof,
captureProof: proof,
});

assert.equal(twice.ok, false);
if (twice.ok) return;
const hint = String(twice.error.details?.hint);
const hint = String(twice.error.hint);
assert.equal(
hint.split(iosTargetActivationDisclosure(FACT)).length - 1,
1,
Expand Down
14 changes: 7 additions & 7 deletions src/daemon/__tests__/capture-disclosure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ test('wait timeout for app text hidden behind a system surface discloses the occ
expect(response.ok).toBe(false);
if (response.ok) return;
expect(response.error.message).toMatch(/wait timed out for text: Bakery list/);
expect(String(response.error.details?.hint)).toContain(ANDROID_SYSTEM_SURFACE_DISCLOSURE);
expect(String(response.error.hint)).toContain(ANDROID_SYSTEM_SURFACE_DISCLOSURE);
});

test('sessionless read-only find still discloses the occluding system surface', async () => {
Expand Down Expand Up @@ -204,7 +204,7 @@ test('disclosure appends after an existing failure hint instead of replacing it'
);
expect(response.ok).toBe(false);
if (response.ok) return;
const hint = String(response.error.details?.hint);
const hint = String(response.error.hint);
expect(hint).toContain('prior hint text');
expect(hint).toContain(ANDROID_SYSTEM_SURFACE_DISCLOSURE);
expect(hint.indexOf('prior hint text')).toBeLessThan(
Expand Down Expand Up @@ -261,7 +261,7 @@ test('sessionless wait timeout still discloses the occluding system surface', as
expect(response.ok).toBe(false);
if (response.ok) return;
expect(sessionStore.get('default')).toBeUndefined();
expect(String(response.error.details?.hint)).toContain(ANDROID_SYSTEM_SURFACE_DISCLOSURE);
expect(String(response.error.hint)).toContain(ANDROID_SYSTEM_SURFACE_DISCLOSURE);
});

// --- #2438: an in-place iOS system surface (web sign-in sheet) discloses on the same shared seam ---
Expand Down Expand Up @@ -350,7 +350,7 @@ test('mutating find that misses on an in-place system surface still discloses it

expect(response?.ok).toBe(false);
if (response?.ok) return;
expect(String(response?.error.details?.hint)).toContain(WEB_SIGN_IN_DISCLOSURE);
expect(String(response?.error.hint)).toContain(WEB_SIGN_IN_DISCLOSURE);
});

test('the shared disclosure helper reports an iOS system surface on both outcomes', () => {
Expand All @@ -368,13 +368,13 @@ test('the shared disclosure helper reports an iOS system surface on both outcome
);
expect(failed.ok).toBe(false);
if (failed.ok) return;
expect(String(failed.error.details?.hint)).toContain(WEB_SIGN_IN_DISCLOSURE);
expect(String(failed.error.hint)).toContain(WEB_SIGN_IN_DISCLOSURE);
});

/**
* A timed-out `wait text` polled the device and the runner had to re-activate the session app to
* answer those polls (#2682). The disclosure arrives on the failure the same way the occlusion one
* does: in `error.details.hint`.
* does: in `error.hint`.
*/
test('wait timeout whose polls required a foreground repair discloses the repair', async () => {
const sessionStore = makeSessionStore();
Expand Down Expand Up @@ -412,7 +412,7 @@ test('wait timeout whose polls required a foreground repair discloses the repair

expect(response.ok).toBe(false);
if (response.ok) return;
expect(String(response.error.details?.hint)).toContain(
expect(String(response.error.hint)).toContain(
iosTargetActivationDisclosure(TARGET_ACTIVATION_FACT),
);
});
Loading
Loading