Skip to content
Draft
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
11 changes: 10 additions & 1 deletion packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,18 @@
* snapshot-quality.ts so SnapshotNode can reference it without a cyclic import;
* snapshot-quality.ts (the validation logic) re-exports it for existing callers.
*/
/**
* Which capture STRATEGY produced a snapshot, within one platform's plan —
* distinct from `SnapshotBackend`, which names the platform channel
* (`xctest`/`android`/…). The iOS plan walks these in order, so a single
* session can change strategy mid-sequence; two strategies do not return
* comparable views of one screen (#1569).
*/
export type SnapshotCaptureBackend = 'tree' | 'queries' | 'private-ax';

export type SnapshotQualityVerdict = {
state: 'healthy' | 'recovered' | 'sparse';
backend: 'tree' | 'queries' | 'private-ax';
backend: SnapshotCaptureBackend;
reason?: string;
// 'deferred' = the penalty circuit breaker pre-selected a non-XCTest backend; nothing new
// degraded on THIS capture (no repeated warning, no settle budget reset).
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/__tests__/direct-ios-selector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ test('isLocalIosRunnerSession: iOS local sessions are eligible, Android and unde

test('isLocalIosRunnerSession: skipPendingPostGestureStabilization:true excludes a pending session (the tap fast path)', () => {
const pending = makeSession('ios', {
postGestureStabilization: { action: 'scroll', markedAt: Date.now() },
postGestureStabilization: { action: 'scroll', positionals: [], markedAt: Date.now() },
});
assert.equal(
isLocalIosRunnerSession(pending, { skipPendingPostGestureStabilization: true }),
Expand All @@ -118,7 +118,7 @@ test('isLocalIosRunnerSession: skipPendingPostGestureStabilization:true excludes

test('isLocalIosRunnerSession: skipPendingPostGestureStabilization:false keeps a pending session eligible (the offscreen double-check)', () => {
const pending = makeSession('ios', {
postGestureStabilization: { action: 'scroll', markedAt: Date.now() },
postGestureStabilization: { action: 'scroll', positionals: [], markedAt: Date.now() },
});
assert.equal(
isLocalIosRunnerSession(pending, { skipPendingPostGestureStabilization: false }),
Expand Down
9 changes: 8 additions & 1 deletion src/daemon/__tests__/post-gesture-stabilization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,10 @@ test('scope drift accepts stale but is vetoed from claiming no-effect (#1601 P1
});

test('formatGestureNoEffectWarning names the gesture and the raw-drag escape hatch', () => {
// Positionals echo verbatim: the warning names the gesture the agent just
// issued, and `scroll down 1` is what they issued.
const scrollWarning = formatGestureNoEffectWarning('scroll', ['down', '1']);
assert.match(scrollWarning, /scroll down produced no visible change/);
assert.match(scrollWarning, /scroll down 1 produced no visible change/);
assert.match(scrollWarning, /swipe x1 y1 x2 y2/);
assert.match(scrollWarning, /already at its edge/);

Expand All @@ -248,6 +250,11 @@ test('formatGestureNoEffectWarning names the gesture and the raw-drag escape hat
assert.match(bareWarning, /swipe produced no visible change/);
});

test('formatGestureNoEffectWarning keeps swipe coordinates instead of dropping every positional', () => {
const swipeWarning = formatGestureNoEffectWarning('swipe', ['10', '20', '30', '40']);
assert.match(swipeWarning, /^swipe 10 20 30 40 produced no visible change/);
});

test('capturePostGestureStabilizedResult trusts a quiet signature once content genuinely differs from the baseline (iOS)', async () => {
vi.useFakeTimers();
const session = makeSession('ios');
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/handlers/__tests__/interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -849,7 +849,7 @@ test('click simple iOS id selector waits for snapshot path after pending gesture
const sessionStore = makeSessionStore();
const sessionName = 'ios-direct-selector-after-swipe';
const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' });
session.postGestureStabilization = { action: 'swipe', markedAt: Date.now() };
session.postGestureStabilization = { action: 'swipe', positionals: [], markedAt: Date.now() };
sessionStore.set(sessionName, session);

mockDispatch.mockImplementation(async (_device, command, positionals) => {
Expand Down Expand Up @@ -3107,7 +3107,7 @@ test('is simple iOS selector falls back to snapshot while gesture stabilization
const sessionStore = makeSessionStore();
const sessionName = 'is-selected-ios-stabilizing';
const session = makeIosSession(sessionName, { appBundleId: 'com.example.app' });
session.postGestureStabilization = { action: 'swipe', markedAt: Date.now() };
session.postGestureStabilization = { action: 'swipe', positionals: [], markedAt: Date.now() };
sessionStore.set(sessionName, session);

mockDispatch.mockImplementation(async (_device, command) => {
Expand Down
2 changes: 2 additions & 0 deletions src/daemon/handlers/__tests__/snapshot-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,7 @@ test('captureSnapshot retries pending tap outcome before post-gesture stabilizat
};
session.postGestureStabilization = {
action: 'click',
positionals: [],
markedAt: Date.now(),
};

Expand Down Expand Up @@ -1361,6 +1362,7 @@ test('captureSnapshot composes post-gesture stabilization with Android freshness
};
session.postGestureStabilization = {
action: 'click',
positionals: [],
markedAt: Date.now(),
};

Expand Down
53 changes: 30 additions & 23 deletions src/daemon/interaction-outcome-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,28 @@ export function areInteractionSurfaceSignaturesStable(
const a = left[index];
const b = right[index];
if (!a || !b || a.key !== b.key) return false;
if (Math.abs(a.x - b.x) > RECT_TOLERANCE_PX) return false;
if (Math.abs(a.y - b.y) > RECT_TOLERANCE_PX) return false;
if (Math.abs(a.width - b.width) > RECT_TOLERANCE_PX) return false;
if (Math.abs(a.height - b.height) > RECT_TOLERANCE_PX) return false;
if (!rectsWithinTolerance(a, b)) return false;
}
return true;
}

/**
* Shared rect-tolerance comparison for the surface-stability checks in this
* module. Entry rects are already rounded by `buildInteractionSurfaceEntry`,
* so one `RECT_TOLERANCE_PX` band absorbs residual drift consistently.
*/
function rectsWithinTolerance(
a: Pick<InteractionSurfaceSignature[number], 'x' | 'y' | 'width' | 'height'>,
b: Pick<InteractionSurfaceSignature[number], 'x' | 'y' | 'width' | 'height'>,
): boolean {
return (
Math.abs(a.x - b.x) <= RECT_TOLERANCE_PX &&
Math.abs(a.y - b.y) <= RECT_TOLERANCE_PX &&
Math.abs(a.width - b.width) <= RECT_TOLERANCE_PX &&
Math.abs(a.height - b.height) <= RECT_TOLERANCE_PX
);
}

/**
* Baseline classifier for post-gesture baseline distrust (#1542 defect 2),
* reusing this module's three-valued `InteractionSurfaceChange` vocabulary.
Expand Down Expand Up @@ -251,14 +265,7 @@ export function classifyBaselineSurfaceEvidence(
continue;
}
shared += 1;
if (
Math.abs(seen.entry.x - now.entry.x) > RECT_TOLERANCE_PX ||
Math.abs(seen.entry.y - now.entry.y) > RECT_TOLERANCE_PX ||
Math.abs(seen.entry.width - now.entry.width) > RECT_TOLERANCE_PX ||
Math.abs(seen.entry.height - now.entry.height) > RECT_TOLERANCE_PX
) {
return 'changed';
}
if (!rectsWithinTolerance(seen, now)) return 'changed';
}
if (shared === 0) return 'ambiguous';
const addedSinceBaseline = after.size > shared;
Expand All @@ -277,11 +284,11 @@ export function classifyBaselineSurfaceEvidence(
*/
function identifiedContent(
signature: InteractionSurfaceSignature,
): Map<string, { entry: InteractionSurfaceSignature[number] }> {
const content = new Map<string, { entry: InteractionSurfaceSignature[number] }>();
): Map<string, InteractionSurfaceSignature[number]> {
const content = new Map<string, InteractionSurfaceSignature[number]>();
for (const entry of signature) {
if (!entry.identity || !entry.discriminating) continue;
if (!content.has(entry.identity)) content.set(entry.identity, { entry });
if (!content.has(entry.identity)) content.set(entry.identity, entry);
}
return content;
}
Expand All @@ -299,6 +306,13 @@ function identifiedContent(
* tolerance) vetoes that shape: any appeared or vanished real element kills
* the claim. Scope drift between baseline and capture vetoes too — silence
* is the safe failure mode for a message that steers the agent's next move.
*
* Matches on `key`, not the flip-tolerant `identity`
* `classifyBaselineSurfaceEvidence` uses — deliberately the opposite choice.
* That oracle must not lose evidence to a volatile-state flip; this runs only
* after it already returned `'unchanged'`, and a veto wants precision over
* recall: any flip makes the keys mismatch and returns `false`, withholding
* the claim rather than falsifying anything.
*/
export function haveIdenticalDiscriminatingSurfaces(
left: InteractionSurfaceSignature,
Expand All @@ -312,14 +326,7 @@ export function haveIdenticalDiscriminatingSurfaces(
for (const entry of leftEntries) {
const other = rightByKey.get(entry.key);
if (!other) return false;
if (
Math.abs(entry.x - other.x) > RECT_TOLERANCE_PX ||
Math.abs(entry.y - other.y) > RECT_TOLERANCE_PX ||
Math.abs(entry.width - other.width) > RECT_TOLERANCE_PX ||
Math.abs(entry.height - other.height) > RECT_TOLERANCE_PX
) {
return false;
}
if (!rectsWithinTolerance(entry, other)) return false;
}
return true;
}
Expand Down
53 changes: 16 additions & 37 deletions src/daemon/post-gesture-stabilization.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { emitDiagnostic } from '../utils/diagnostics.ts';
import { isApplePlatform, isMobilePlatform } from '@agent-device/kernel/device';
import type { CommandFlags } from '../core/dispatch.ts';
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import type { SnapshotCaptureBackend, SnapshotState } from '@agent-device/kernel/snapshot';
import { sleep } from '../utils/timeouts.ts';
import {
areInteractionSurfaceSignaturesStable,
Expand Down Expand Up @@ -67,47 +67,28 @@ function clearPostGestureStabilization(session: SessionState | undefined): void
export type PostGestureStabilityVerdict = 'trust' | 'distrust' | 'accept-stale';

/**
* Pure decision at the heart of defect 2's fix. Called only once a quiet
* AX-signature match has already been observed (two consecutive post-gesture
* polls agree); decides whether that agreement is trustworthy "settled"
* evidence or a stale-but-consistent AX read that happens to still equal the
* pre-gesture baseline.
* Pure decision at the heart of defect 2's fix (#1542). Called only once a
* quiet AX-signature match has already been observed (two consecutive
* post-gesture polls agree); decides whether that agreement is trustworthy
* "settled" evidence or a stale-but-consistent AX read that happens to still
* equal the pre-gesture baseline.
*
* - `trust`: accept immediately — the platform doesn't need baseline distrust
* (Android is fresh by construction), there is no usable baseline, or the
* quiet signature genuinely differs from the pre-gesture baseline (real
* movement occurred).
* baseline comparison did not return `'unchanged'` (real movement, or no
* comparable evidence either way).
* - `distrust`: the quiet signature still equals the baseline AND the bounded
* distrust cap has not expired — keep polling, do not accept as final.
* - `accept-stale`: the distrust cap expired and the signature still equals
* the baseline. A genuinely inert gesture (e.g. scroll already at an edge)
* is the honest read at this point, so it is accepted — but flagged, so a
* stale-accept is distinguishable from an ordinary settle in diagnostics.
*
* The baseline comparison is `classifyBaselineSurfaceEvidence` — a
* subset-tolerant, three-valued classifier reusing this codebase's existing
* `InteractionSurfaceChange` vocabulary (`'changed' | 'unchanged' |
* 'ambiguous'`), not whole-array equality and not a boolean. Two reasons,
* both live-verified on #1542 checkout-form.ad before shipping:
*
* 1. Scope drift: the pre-gesture baseline and the post-gesture quiet capture
* are routinely fetched by different callers with different snapshot
* scopes (e.g. a broad text-search capture vs. an interactive-only
* selector capture), so their signatures can differ in length/membership
* even when the element that matters never moved. Whole-array equality
* made the verdict `trust` on the very first quiet match every time,
* because the arrays never lined up — never once catching the real
* staleness this check exists for.
* 2. Non-discriminating overlap: a shared-any-entry boolean match is fooled
* the opposite way — the viewport root (Application/Window) is always
* present and its rect is invariant under any gesture, so a broad
* pre-gesture baseline and a narrow post-gesture capture can share ONLY
* the root even after a real, successful scroll swapped every actual
* element. `classifyBaselineSurfaceEvidence` excludes the root and
* keyboard chrome from the overlap it counts as evidence
* (`isNonDiscriminatingSurfaceNode`), so that case classifies as
* `'ambiguous'` (no comparable evidence) rather than `'unchanged'` (a
* match) — `'ambiguous'` falls through to `trust` below, same as `'changed'`.
* What "equals the baseline" means — and why it is a three-valued classifier
* rather than whole-array equality or a boolean — is owned by
* `classifyBaselineSurfaceEvidence` in interaction-outcome-policy.ts. That
* rationale lives there, at the classifier; this function only consumes its
* `InteractionSurfaceChange` verdict.
*/
export function decidePostGestureStabilityVerdict(params: {
needsBaselineDistrust: boolean;
Expand All @@ -128,7 +109,7 @@ export function decidePostGestureStabilityVerdict(params: {
type CapturedSurface<T> = {
value: T;
signature: InteractionSurfaceSignature;
backend: string | undefined;
backend: SnapshotCaptureBackend | undefined;
};

async function captureInteractionSurface<T>(
Expand Down Expand Up @@ -265,9 +246,7 @@ export async function capturePostGestureStabilizedResult<T>(params: {
* raw `swipe` worked where scroll/fling/pan all silently no-opped).
*/
export function formatGestureNoEffectWarning(action: string, positionals: string[]): string {
const gesture = [action, ...positionals.filter((value) => !/^[\d.-]+$/.test(value))]
.join(' ')
.trim();
const gesture = [action, ...positionals].join(' ').trim();
return (
`${gesture} 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 — ' +
Expand All @@ -294,7 +273,7 @@ function buildAcceptedStabilizedResult<T>(
value: current.value,
gestureNoEffect: {
action: pending.action,
positionals: pending.positionals ?? [],
positionals: pending.positionals,
},
};
}
Expand Down
20 changes: 10 additions & 10 deletions src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import type {
DaemonRequest as WireRequest,
} from '@agent-device/kernel/contracts';
import type { DeviceInfo, Platform, PlatformSelector } from '@agent-device/kernel/device';
import type { Rect, SnapshotState } from '@agent-device/kernel/snapshot';
import type { Rect, SnapshotCaptureBackend, SnapshotState } from '@agent-device/kernel/snapshot';
import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts';
// Type-only import; erased at runtime. ref-frame.ts imports SessionState from
// here, so this back-edge must stay type-only to avoid a runtime cycle.
Expand Down Expand Up @@ -239,9 +239,9 @@ export type InteractionSurfaceEntry = {

export type PostGestureStabilization = {
action: string;
/** The gesture's own positionals (e.g. scroll direction) — wording input for
* the #1600 no-effect warning; never re-dispatched. */
positionals?: string[];
/** The gesture's own positionals — wording input for the #1600 no-effect
* warning; never re-dispatched. Always set by the only writer. */
positionals: string[];
markedAt: number;
/**
* Pre-gesture interaction-surface signature, captured from the session's
Expand All @@ -255,13 +255,13 @@ export type PostGestureStabilization = {
*/
baselineSignature?: InteractionSurfaceEntry[];
/**
* Snapshot backend that produced `baselineSignature`. Backends do not return
* comparable views of one screen — on the same iOS screen private AX returns
* the scrolled-away content the tree backend prunes — so a quiet capture from
* a different backend can only be re-baselined against, never concluded from
* (#1569).
* Capture strategy that produced `baselineSignature`. Two strategies do not
* return comparable views of one screen — on the same iOS screen private AX
* returns the scrolled-away content the tree strategy prunes — so a quiet
* capture from a different one can only be re-baselined against, never
* concluded from (#1569).
*/
baselineBackend?: string;
baselineBackend?: SnapshotCaptureBackend;
};

export type PendingInteractionOutcome = {
Expand Down
Loading