From 1f97dfc0ce614aabf5449e6c0fe8e4d6a1a355be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 08:53:46 +0200 Subject: [PATCH 1/2] fix(settle): decide the broad-transition confirmation by the transition alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arming bar was `requestedQuietMs >= DEFAULT_STABLE_QUIET_MS`, so the confirmation that #1831 added to hold a modal dismissal for 1.5s was a function of the window the caller asked for rather than of the transition in front of the loop. Three consequences, all reported as #3470: - `--settle-quiet 499` behaved differently from 500, with no rule that explains the boundary - a caller passing `settleQuietMs: 150` (tester-army/e2e) got no guard at all, on exactly the dismissal the guard exists for - a shorter window read as "trust a coherent transitional tree sooner", which is not what `--settle-quiet` asks for: it asks for a tighter verdict, not a different set of transitions to distrust Arming now reads the transition only, and a confirmed replacement is held for the caller's own window, floored at 1.5s. The trigger stays exactly as on main: a complete-viewport post-action projection or a tiny presented baseline, plus fewer than half the node identities carried over. The six #1831 cases run at 500ms and 150ms alike. The provider-backed scenario is a real dialog dismissal, so it now holds the floor at its 25ms window too, which is what that wait is worth on a runner round trip: measured 1510ms over 6 captures, against 1511ms over 6 for the same scenario at the default window on main — the same verdict at the same price, no longer window-dependent. --- .../src/flag-definitions-action.ts | 3 +- .../runtime/settle-confirmation-floor.test.ts | 55 +++++ .../interaction/runtime/settle.test.ts | 4 +- .../runtime/stable-capture.test.ts | 160 ++++++++++++- .../interaction/runtime/stable-capture.ts | 8 +- .../post-action-observation-grammar.ts | 5 +- .../settle-observation.test.ts | 223 +++++++++--------- 7 files changed, 344 insertions(+), 114 deletions(-) create mode 100644 src/commands/interaction/runtime/settle-confirmation-floor.test.ts diff --git a/packages/command-registry/src/flag-definitions-action.ts b/packages/command-registry/src/flag-definitions-action.ts index ceb6766d2b..fe7fcb190d 100644 --- a/packages/command-registry/src/flag-definitions-action.ts +++ b/packages/command-registry/src/flag-definitions-action.ts @@ -210,7 +210,8 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ type: 'int', min: 0, usageLabel: '--settle-quiet ', - 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, }, diff --git a/src/commands/interaction/runtime/settle-confirmation-floor.test.ts b/src/commands/interaction/runtime/settle-confirmation-floor.test.ts new file mode 100644 index 0000000000..26dd9887aa --- /dev/null +++ b/src/commands/interaction/runtime/settle-confirmation-floor.test.ts @@ -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); + }); +} diff --git a/src/commands/interaction/runtime/settle.test.ts b/src/commands/interaction/runtime/settle.test.ts index de5e1afb53..d50a8cd797 100644 --- a/src/commands/interaction/runtime/settle.test.ts +++ b/src/commands/interaction/runtime/settle.test.ts @@ -307,7 +307,7 @@ 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 }, }); assert.equal(result.kind, 'ref'); @@ -315,7 +315,7 @@ test('longpress --settle rides the same observation path', async () => { 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); }); diff --git a/src/commands/interaction/runtime/stable-capture.test.ts b/src/commands/interaction/runtime/stable-capture.test.ts index a714cc5491..5c960bd1c8 100644 --- a/src/commands/interaction/runtime/stable-capture.test.ts +++ b/src/commands/interaction/runtime/stable-capture.test.ts @@ -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 = { @@ -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, @@ -213,8 +359,14 @@ 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`, + ); }); test('settle keeps the default quiet window for a backend-less overlapping local mutation', async () => { diff --git a/src/commands/interaction/runtime/stable-capture.ts b/src/commands/interaction/runtime/stable-capture.ts index 0451e3a4fc..4088514dc9 100644 --- a/src/commands/interaction/runtime/stable-capture.ts +++ b/src/commands/interaction/runtime/stable-capture.ts @@ -41,6 +41,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; @@ -252,7 +258,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); diff --git a/src/commands/post-action-observation-grammar.ts b/src/commands/post-action-observation-grammar.ts index 45b6eb0612..dd1b250d35 100644 --- a/src/commands/post-action-observation-grammar.ts +++ b/src/commands/post-action-observation-grammar.ts @@ -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 }), }); diff --git a/test/integration/provider-scenarios/settle-observation.test.ts b/test/integration/provider-scenarios/settle-observation.test.ts index 555faa56b7..e37a59c67a 100644 --- a/test/integration/provider-scenarios/settle-observation.test.ts +++ b/test/integration/provider-scenarios/settle-observation.test.ts @@ -9,6 +9,7 @@ import { simctlDeviceLifecycleHandler, } from './providers.ts'; import { createProviderTranscript, type ProviderScenarioProviderEntry } from './transcript.ts'; +import { PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS } from './test-timeouts.ts'; const APP = 'com.example.app'; const DEVICE_ID = PROVIDER_SCENARIO_IOS_SIMULATOR.id; @@ -172,112 +173,124 @@ function tapEntry(x: number, y: number): ProviderScenarioProviderEntry { }; } -test('Provider-backed integration press --settle returns the settled diff and fresh refs', async () => { - const runnerTranscript = createProviderTranscript([ - // snapshot -i: issues refs; the first capture after open settles: one extra read - snapshotEntry(BEFORE_NODES), - snapshotEntry(BEFORE_NODES), - // press label=Continue --settle: resolution capture, tap, settle captures - snapshotEntry(BEFORE_NODES), - tapEntry(200, 322), - quietSnapshotEntry(SETTLED_NODES), - // press @e2 (the Done ref from the settled diff): tap on the stored tree - tapEntry(200, 522), - ]); - const appleRunnerProvider = createAppleRunnerProviderFromTranscript( - runnerTranscript, - 'ios.runner', - ); - const appleTool = createRecordingAppleToolProvider({ - simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ - { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, - ]), - }); - - await withProviderScenarioResource( - async () => - await createProviderScenarioHarness({ - appleRunnerProvider: () => appleRunnerProvider, - appleToolProvider: () => appleTool.provider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], - }), - async (daemon) => { - const open = await daemon.callCommand('open', [APP], { - platform: 'ios', - udid: DEVICE_ID, - }); - assertRpcOk(open); - - const snapshot = await daemon.callCommand('snapshot', [], { - snapshotInteractiveOnly: true, - }); - assertRpcOk(snapshot); - - const press = await daemon.callCommand('press', ['label=Continue'], { - settle: true, - settleQuietMs: 25, - // Coverage instrumentation can slow provider-backed daemon dispatch - // enough that a 2s wall-clock budget expires before the two quiet - // captures complete. The contract under test is the settled response, - // not timeout pressure. - timeoutMs: 10_000, - }); - const pressData = assertRpcOk(press); - const settle = pressData.settle as { - settled: boolean; - captures: number; - refsGeneration?: number; - diff?: { - summary: { additions: number; removals: number; unchanged: number }; - lines: Array<{ kind: string; text: string; ref?: string }>; +test( + 'Provider-backed integration press --settle returns the settled diff and fresh refs', + { timeout: PARALLEL_PROVIDER_SCENARIO_TIMEOUT_MS }, + async () => { + const runnerTranscript = createProviderTranscript([ + // snapshot -i: issues refs; the first capture after open settles: one extra read + snapshotEntry(BEFORE_NODES), + snapshotEntry(BEFORE_NODES), + // press label=Continue --settle: resolution capture, tap, settle captures + snapshotEntry(BEFORE_NODES), + tapEntry(200, 322), + quietSnapshotEntry(SETTLED_NODES), + // press @e2 (the Done ref from the settled diff): tap on the stored tree + tapEntry(200, 522), + ]); + const appleRunnerProvider = createAppleRunnerProviderFromTranscript( + runnerTranscript, + 'ios.runner', + ); + const appleTool = createRecordingAppleToolProvider({ + simctl: simctlDeviceLifecycleHandler('com.apple.CoreSimulator.SimRuntime.iOS-18-0', [ + { name: PROVIDER_SCENARIO_IOS_SIMULATOR.name, udid: DEVICE_ID }, + ]), + }); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + appleRunnerProvider: () => appleRunnerProvider, + appleToolProvider: () => appleTool.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + }), + async (daemon) => { + const open = await daemon.callCommand('open', [APP], { + platform: 'ios', + udid: DEVICE_ID, + }); + assertRpcOk(open); + + const snapshot = await daemon.callCommand('snapshot', [], { + snapshotInteractiveOnly: true, + }); + assertRpcOk(snapshot); + + const press = await daemon.callCommand('press', ['label=Continue'], { + settle: true, + settleQuietMs: 25, + // Coverage instrumentation can slow provider-backed daemon dispatch + // enough that a 2s wall-clock budget expires before the two quiet + // captures complete. The contract under test is the settled response, + // not timeout pressure. + timeoutMs: 10_000, + }); + const pressData = assertRpcOk(press); + const settle = pressData.settle as { + settled: boolean; + captures: number; + waitedMs: number; + refsGeneration?: number; + diff?: { + summary: { additions: number; removals: number; unchanged: number }; + lines: Array<{ kind: string; text: string; ref?: string }>; + }; + tail?: Array<{ ref: string; role: string; label?: string }>; + hint?: string; }; - tail?: Array<{ ref: string; role: string; label?: string }>; - hint?: string; - }; - assert.ok(settle, 'press --settle must return a settle observation'); - assert.equal(settle.settled, true); - // Snapshot-floor economy: a quiet UI settles on the two identical - // captures the verdict needs, or three when the second lands a hair short - // of the quiet window. Anything above that is a regression in what settle - // spends; pinning a single number would assert the runner's speed. - assert.ok( - settle.captures >= 2 && settle.captures <= 3, - `settle should cost 2-3 captures, got ${settle.captures}`, - ); - assert.equal(typeof settle.refsGeneration, 'number'); - assert.deepEqual(settle.diff?.summary, { additions: 1, removals: 2, unchanged: 1 }); - const added = settle.diff?.lines.find((line) => line.kind === 'added'); - assert.match(added?.text ?? '', /Done/); - assert.equal(added?.ref, 'e2'); - // The added line already hands back a fresh target: the diff's own refs - // are the actionable payload, so the tail stays off. - assert.equal(settle.tail, undefined); - // The settled tree is never serialized into the response. - assert.equal(pressData.nodes, undefined); - - // ADR 0014: the settle issued a PARTIAL frame authorizing exactly its - // emitted ref, so the follow-up consumes it in pinned form — acting - // directly on the stored settled tree with no fresh snapshot round trip - // and no stale-refs warning. The plain `@e2` would require a complete - // frame. - const callsBeforeFollowUp = runnerTranscript.calls.length; - const followUp = await daemon.callCommand('press', [`@e2~s${settle.refsGeneration}`], {}); - const followUpData = assertRpcOk(followUp); - assert.equal(followUpData.warning, undefined); - assert.equal(followUpData.x, 200); - assert.equal(followUpData.y, 522); - // The round trip settle exists to remove: the follow-up spends a tap and - // nothing else. Asserted directly, since a quiet-UI entry would happily - // serve a stray capture. - assert.deepEqual( - runnerTranscript.calls.slice(callsBeforeFollowUp).map((call) => call.command), - ['ios.runner.tap'], - ); - - runnerTranscript.assertComplete(); - }, - ); -}); + assert.ok(settle, 'press --settle must return a settle observation'); + assert.equal(settle.settled, true); + // The pressed dialog's own button disappears with it, so only the + // Application root survives: fewer than half the node identities carry + // over, which is the broad replacement #1831 confirms for 1.5s — at this + // 25ms window too, since #3470. That wait is the contract below, so the + // capture count is the confirmation's, not the quiet UI's 2-3. + assert.ok( + settle.waitedMs >= 1_500, + `short quiet window switched the dismissal confirmation off: ${settle.waitedMs}ms`, + ); + // Same reasoning one level down: pinning a single number would assert the + // runner's speed against the confirmation's own deadline arithmetic. + assert.ok( + settle.captures >= 2, + `confirmation settled on fewer than two captures, got ${settle.captures}`, + ); + assert.equal(typeof settle.refsGeneration, 'number'); + assert.deepEqual(settle.diff?.summary, { additions: 1, removals: 2, unchanged: 1 }); + const added = settle.diff?.lines.find((line) => line.kind === 'added'); + assert.match(added?.text ?? '', /Done/); + assert.equal(added?.ref, 'e2'); + // The added line already hands back a fresh target: the diff's own refs + // are the actionable payload, so the tail stays off. + assert.equal(settle.tail, undefined); + // The settled tree is never serialized into the response. + assert.equal(pressData.nodes, undefined); + + // ADR 0014: the settle issued a PARTIAL frame authorizing exactly its + // emitted ref, so the follow-up consumes it in pinned form — acting + // directly on the stored settled tree with no fresh snapshot round trip + // and no stale-refs warning. The plain `@e2` would require a complete + // frame. + const callsBeforeFollowUp = runnerTranscript.calls.length; + const followUp = await daemon.callCommand('press', [`@e2~s${settle.refsGeneration}`], {}); + const followUpData = assertRpcOk(followUp); + assert.equal(followUpData.warning, undefined); + assert.equal(followUpData.x, 200); + assert.equal(followUpData.y, 522); + // The round trip settle exists to remove: the follow-up spends a tap and + // nothing else. Asserted directly, since a quiet-UI entry would happily + // serve a stray capture. + assert.deepEqual( + runnerTranscript.calls.slice(callsBeforeFollowUp).map((call) => call.command), + ['ios.runner.tap'], + ); + + runnerTranscript.assertComplete(); + }, + ); + }, +); test('Provider-backed integration never-settled press --settle does not issue diff refs', async () => { const loadingNodes = (label: string) => [ From bee9e1f3406e9fa7a2f5d5ed5d71355105b92877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 08:55:11 +0200 Subject: [PATCH 2/2] fix(settle): sample an extended confirmation on its own cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poll cadence derived from the requested window once, before the loop ran, so the 1.5s confirmation this branch extends that window to was spent sampling at the caller's grid. At `--settle-quiet 25` that is 109 capture round trips to reach a verdict the default grid reaches in 10 — a confirmation whose cost is 11x the reads it needs, on the tightest windows where settle is asked for at all. Cadence now follows the window in force. Both directions hold: a tight window still gets a grid that can measure it, and an extended wait gets the default grid rather than the caller's. Pinned by the capture count in the 25ms case: 10 here, 109 with the recompute removed (mutation-checked). --- .../interaction/runtime/stable-capture.test.ts | 7 +++++++ .../interaction/runtime/stable-capture.ts | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/commands/interaction/runtime/stable-capture.test.ts b/src/commands/interaction/runtime/stable-capture.test.ts index 5c960bd1c8..7e3dbc05e4 100644 --- a/src/commands/interaction/runtime/stable-capture.test.ts +++ b/src/commands/interaction/runtime/stable-capture.test.ts @@ -367,6 +367,13 @@ test('settle confirms a broad replacement on a caller window shorter than the co 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 () => { diff --git a/src/commands/interaction/runtime/stable-capture.ts b/src/commands/interaction/runtime/stable-capture.ts index 4088514dc9..ce39bcf2a7 100644 --- a/src/commands/interaction/runtime/stable-capture.ts +++ b/src/commands/interaction/runtime/stable-capture.ts @@ -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 @@ -88,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; @@ -131,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({