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
3 changes: 2 additions & 1 deletion packages/command-registry/src/flag-definitions-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
type: 'int',
min: 0,
usageLabel: '--settle-quiet <ms>',
usageDescription: 'Settle: quiet window the UI must hold to count as settled (default 500ms)',
usageDescription:
'Settle: quiet window the UI must hold to count as settled (default 500ms). On iOS, a broad screen replacement such as a modal dismissal is confirmed for at least 1500ms whatever window is asked for, within the --timeout settle deadline',
projectConfig: true,
recorded: false,
},
Expand Down
55 changes: 55 additions & 0 deletions src/commands/interaction/runtime/settle-confirmation-floor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { ref } from './selector-read-utils.ts';
import {
buttonSnapshot,
createSettleDevice,
welcomeSnapshot,
} from './__tests__/settle-device-fixtures.ts';

// #3470: the broad-transition confirmation owns the WAIT, not just the verdict, and it used to
// switch itself off whenever the caller asked for a window under the default. On the interaction
// commands' shared settle path that meant a `settleQuietMs: 150` caller (tester-army/e2e) got no
// guard at all while the default got a 1.5 s one. The same dismissed surface now settles at both
// requested windows: the response still reports the window the caller asked for, and the confirmed
// replacement sets the wait. `stable-capture.test.ts` pins that floor at the loop; this pins it on
// the observation the caller reads.
//
// Budgets are injected (fake clock) — no real waiting.

function pressThatDismissesTheWholeTree() {
const before = buttonSnapshot();
let captures = 0;
const device = createSettleDevice({
stored: before,
captureSnapshot: () => {
captures += 1;
// A ref press resolves against the tree the session already holds and captures nothing before
// the action, so every call here is the settle loop's own sample: capture 1 still shows the
// pre-action tree, captures 2+ the tree the dismissal reveals.
return { snapshot: captures === 1 ? before : welcomeSnapshot() };
},
});
return device;
}

for (const requestedQuietMs of [500, 150]) {
test(`a broad replacement on the interaction route holds the floor at ${requestedQuietMs}ms requested`, async () => {
const result = await pressThatDismissesTheWholeTree().interactions.press(ref('@e1'), {
session: 'default',
settle: { quietMs: requestedQuietMs, timeoutMs: 5_000 },
});

const settle = result.settle;
assert.ok(settle);
assert.equal(settle.settled, true);
// The window the caller asked for is what the response reports…
assert.equal(settle.quietMs, requestedQuietMs);
// …while the confirmed replacement sets the wait's floor (#3470).
assert.ok(
settle.waitedMs >= 1_500,
`confirmation did not extend the settle: waitedMs=${settle.waitedMs}`,
);
assert.ok(settle.diff);
});
}
4 changes: 2 additions & 2 deletions src/commands/interaction/runtime/settle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,15 +307,15 @@ test('longpress --settle rides the same observation path', async () => {
const result = await device.interactions.longPress(ref('@e1'), {
session: 'default',
durationMs: 400,
settle: { quietMs: 100, timeoutMs: 1_000 },
settle: { quietMs: 100, timeoutMs: 2_000 },
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
});

assert.equal(result.kind, 'ref');
const settle = result.settle;
assert.ok(settle);
assert.equal(settle.settled, true);
assert.equal(settle.quietMs, 100);
assert.equal(settle.timeoutMs, 1_000);
assert.equal(settle.timeoutMs, 2_000);
assert.ok(settle.diff);
});

Expand Down
167 changes: 163 additions & 4 deletions src/commands/interaction/runtime/stable-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,149 @@ const BROAD_TRANSITION_PARAMS = {
broadTransitionBaselineNodes: elementThreadsNoticeSnapshot.nodes,
};

// Each #1831 case below runs at BOTH windows. The confirmation used to switch itself off for a
// requested window under the default, so `--settle-quiet 150` — what tester-army/e2e passes — got no
// guard at all while 500 got a 1.5 s one (#3470).
for (const requestedQuietMs of [500, 150]) {
const confirmationFloor = Math.max(requestedQuietMs, 1_500);

test(`settle confirms a broad screen replacement beyond a self-consistent transitional tree (quiet ${requestedQuietMs}ms)`, async () => {
const { runtime } = transitionRuntime();

const outcome = await runStableCaptureLoop(
runtime,
{ session: 'default' },
{ ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs },
);

assert.equal(outcome.settled, true);
assert.equal(
outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'),
false,
);
assert.ok(
outcome.waitedMs >= confirmationFloor,
`settled transitional tree after ${outcome.waitedMs}ms`,
);
});

test(`settle confirms a broad screen replacement when capture recovers to private AX (quiet ${requestedQuietMs}ms)`, async () => {
const { runtime } = transitionRuntime({ captureBackend: 'private-ax' });

const outcome = await runStableCaptureLoop(
runtime,
{ session: 'default' },
{ ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs },
);

assert.equal(outcome.settled, true);
assert.equal(
outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'),
false,
);
assert.ok(
outcome.waitedMs >= confirmationFloor,
`settled transitional tree after ${outcome.waitedMs}ms`,
);
});

test(`settle confirms a broad replacement that begins after the first post-action capture (quiet ${requestedQuietMs}ms)`, async () => {
const { runtime } = transitionRuntime({
captureBackend: 'private-ax',
firstCaptureKeepsBaseline: true,
settledAtMs: 1_200,
});

const outcome = await runStableCaptureLoop(
runtime,
{ session: 'default' },
{ ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs },
);

assert.equal(outcome.settled, true);
assert.equal(
outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'),
false,
);
assert.ok(
outcome.waitedMs >= confirmationFloor,
`settled delayed transition after ${outcome.waitedMs}ms`,
);
});

test(`settle confirms against the pre-action tree when the stored snapshot already advanced (quiet ${requestedQuietMs}ms)`, async () => {
const { runtime } = transitionRuntime({
sessionSnapshot: { ...elementTransientRoomSnapshot, backend: undefined, producer: undefined },
settledAtMs: 1_200,
});

const outcome = await runStableCaptureLoop(
runtime,
{ session: 'default' },
{ ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs },
);

assert.equal(outcome.settled, true);
assert.equal(
outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'),
false,
);
assert.ok(
outcome.waitedMs >= confirmationFloor,
`settled advanced-session transition after ${outcome.waitedMs}ms`,
);
});

test(`settle confirms an iOS broad replacement when capture omits snapshot backend provenance (quiet ${requestedQuietMs}ms)`, async () => {
const { runtime } = transitionRuntime({
omitSnapshotBackend: true,
settledAtMs: 1_200,
});

const outcome = await runStableCaptureLoop(
runtime,
{ session: 'default' },
{
...BROAD_TRANSITION_PARAMS,
quietMs: requestedQuietMs,
// Presented iOS modals can be complete interaction captures without retaining the
// Application root in their projection.
broadTransitionBaselineNodes: elementThreadsNoticeSnapshot.nodes.slice(1),
},
);

assert.equal(outcome.settled, true);
assert.equal(
outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'),
false,
);
assert.ok(
outcome.waitedMs >= confirmationFloor,
`settled backend-less transition after ${outcome.waitedMs}ms`,
);
});

test(`settle confirms a tiny modal replacement through a partial post-action projection (quiet ${requestedQuietMs}ms)`, async () => {
const { runtime } = transitionRuntime({ omitViewportRoot: true, settledAtMs: 1_200 });

const outcome = await runStableCaptureLoop(
runtime,
{ session: 'default' },
{ ...BROAD_TRANSITION_PARAMS, quietMs: requestedQuietMs },
);

assert.equal(outcome.settled, true);
assert.equal(
outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'),
false,
);
assert.ok(
outcome.waitedMs >= confirmationFloor,
`settled partial transitional tree after ${outcome.waitedMs}ms`,
);
});
}

test('regular captures settle from visible semantics while offscreen rows churn', async () => {
let elapsedMs = 0;
const clock = {
Expand Down Expand Up @@ -203,8 +346,11 @@ test('settle keeps the default quiet window for a larger partial projection', as
assert.ok(outcome.waitedMs < 800, `partial projection settled after ${outcome.waitedMs}ms`);
});

test('settle honors an explicitly shorter quiet window across a broad replacement', async () => {
const { runtime } = transitionRuntime();
test('settle confirms a broad replacement on a caller window shorter than the confirmation floor', async () => {
// The counterpart of the loop above at the tightest window a caller realistically asks for. The
// 25 ms cadence still applies — the loop keeps sampling at that grid — but the confirmation floor,
// not the window, decides when a broad replacement is trusted (#3470).
const { runtime } = transitionRuntime({ settledAtMs: 1_200 });

const outcome = await runStableCaptureLoop(
runtime,
Expand All @@ -213,8 +359,21 @@ test('settle honors an explicitly shorter quiet window across a broad replacemen
);

assert.equal(outcome.settled, true);
assert.ok(outcome.captures <= 3, `short quiet window spent ${outcome.captures} captures`);
assert.ok(outcome.waitedMs < 800, `short quiet window settled after ${outcome.waitedMs}ms`);
assert.equal(
outcome.lastCapture?.snapshot.nodes.some((node) => node.label === 'action file'),
false,
);
assert.ok(
outcome.waitedMs >= 1_500,
`short quiet window switched the confirmation off: settled after ${outcome.waitedMs}ms`,
);
// Cadence follows the window in force, so the extended wait is sampled on the default grid:
// 10 captures here, 109 when the loop keeps polling at the caller's 25 ms. Each of those is a
// capture round trip, so the tight grid would turn a 1.5 s confirmation into ~1.5 s of reads.
assert.ok(
outcome.captures <= 12,
`confirmation sampled on the caller's tight grid: ${outcome.captures} captures`,
);
});

test('settle keeps the default quiet window for a backend-less overlapping local mutation', async () => {
Expand Down
25 changes: 21 additions & 4 deletions src/commands/interaction/runtime/stable-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ import { withoutSettleChrome } from '@agent-device/capture-kit/snapshot-chrome';

const STABLE_POLL_INTERVAL_MS = 300;
const STABLE_MIN_POLL_MS = 25;
// Never slower than the default poll, never faster than the minimum: a tight window gets a cadence
// that can measure it, a window the confirmation extends gets one that does not sample the whole
// extended wait at the caller's original grid.
function stableCapturePollMs(quietMs: number): number {
return Math.min(STABLE_POLL_INTERVAL_MS, Math.max(STABLE_MIN_POLL_MS, quietMs));
}
// Wake PAST the quiet deadline rather than exactly on it. `setTimeout(n)` can
// advance `Date.now()` by only n-1 — libuv times the sleep on the monotonic
// loop clock while `now()` reads the wall clock — so a capture landing on the
Expand All @@ -41,6 +47,12 @@ const QUIET_DEADLINE_EPSILON_MS = 2;
// Broad replacements (for example, dismissing a modal into a room) can expose a coherent but
// transitional AX tree for the default quiet window. Confirm those transitions longer without
// adding latency to local mutations whose pre/post trees substantially overlap.
//
// The confirmation is decided by the transition alone: a caller asking for a shorter quiet window
// asked for a tighter verdict, not for a different set of transitions to distrust. It used to arm
// only at `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS`, which made `--settle-quiet 499` behave
// differently from 500 and left `settleQuietMs: 150` callers (tester-army/e2e) with no guard at all
// (#3470). A confirmed replacement is held for the caller's own window, floored at 1.5 s.
const BROAD_TRANSITION_CONFIRMATION_QUIET_MS = 1_500;
export const DEFAULT_STABLE_QUIET_MS = 500;
export const DEFAULT_STABLE_TIMEOUT_MS = 10_000;
Expand Down Expand Up @@ -82,10 +94,12 @@ export async function runStableCaptureLoop(
const session = await runtime.sessions.get(options.session ?? 'default');
let transitionBaseline: StableCaptureSignal | undefined;
let preferredBackend = preferredSnapshotBackendForVerdict(session?.snapshot?.snapshotQuality);
// Cadence derives from the quiet window (never slower than the default
// Cadence derives from the quiet window in force (never slower than the default
// poll): a caller asking for a 50ms quiet window should not be forced onto a
// 300ms grid — and tests inject the budget instead of waiting real time.
const pollMs = Math.min(STABLE_POLL_INTERVAL_MS, Math.max(STABLE_MIN_POLL_MS, quietMs));
// 300ms grid, and a run the confirmation has extended to 1.5s should not spend
// that wait on the caller's tight grid — every sample is a capture round trip.
// Tests inject the budget instead of waiting real time.
let pollMs = stableCapturePollMs(quietMs);
let captures = 0;
let lastSignal: StableCaptureSignal | undefined;
let lastNodeCount = 0;
Expand Down Expand Up @@ -125,6 +139,9 @@ export async function runStableCaptureLoop(
requestedQuietMs: quietMs,
currentQuietMs: requiredQuietMs,
});
// Recomputed from the window in force rather than only when it changed: the window is a
// monotonic max, so this is the same value, and the cadence cannot drift from it.
pollMs = stableCapturePollMs(requiredQuietMs);
preferredBackend ??= preferredSnapshotBackendForVerdict(capture.snapshot.snapshotQuality);
const nowMs = now(runtime);
const recoveredDeadlineMs = extendedDeadlineAfterPrivateAxRecovery({
Expand Down Expand Up @@ -252,7 +269,7 @@ function stableCaptureNextQuietWindowMs(params: {
requestedQuietMs: number;
currentQuietMs: number;
}): number {
if (params.requestedQuietMs < DEFAULT_STABLE_QUIET_MS || !params.baseline) {
if (!params.baseline) {
return params.currentQuietMs;
}
const replacementSignal = stableCaptureReplacementSignal(params.snapshot, params.appBundleId);
Expand Down
5 changes: 4 additions & 1 deletion src/commands/post-action-observation-grammar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ const settleFields = () => ({
settle: booleanField(
'After the action, wait for the UI to go quiet and return the settled diff vs the pre-action tree in the same response. Best-effort; never fails the action.',
),
settleQuietMs: integerField('Settle: quiet window in milliseconds (default 500).', { min: 0 }),
settleQuietMs: integerField(
'Settle: quiet window in milliseconds (default 500). On iOS, a broad screen replacement such as a modal dismissal is confirmed for at least 1500 whatever window is asked for, within the timeoutMs settle deadline.',
{ min: 0 },
),
timeoutMs: integerField('Settle: wait deadline in milliseconds (default 10000).', { min: 1 }),
});

Expand Down
Loading
Loading