From d97aba9fb8075f752e43f5c2668342aed0b2a593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 12:17:02 +0200 Subject: [PATCH] fix(scroll): observe the movement a directional scroll can claim --- .../interaction-outcome-policy.test.ts | 56 ++ src/daemon/__tests__/scroll-movement.test.ts | 501 +++++++++++++++ src/daemon/deferred-interaction-outcome.ts | 15 +- src/daemon/interaction-outcome-policy.ts | 54 +- src/daemon/scroll-movement.ts | 591 ++++++++++++++++++ 5 files changed, 1204 insertions(+), 13 deletions(-) create mode 100644 src/daemon/__tests__/scroll-movement.test.ts create mode 100644 src/daemon/scroll-movement.ts diff --git a/src/daemon/__tests__/interaction-outcome-policy.test.ts b/src/daemon/__tests__/interaction-outcome-policy.test.ts index 7dfbcef361..9cd0d34c5a 100644 --- a/src/daemon/__tests__/interaction-outcome-policy.test.ts +++ b/src/daemon/__tests__/interaction-outcome-policy.test.ts @@ -5,6 +5,7 @@ import { buildInteractionSurfaceSignature, classifyBaselineSurfaceEvidence, classifyInteractionSurfaceChange, + discriminatingSurfaceChangedWithinRect, markPendingInteractionOutcome, stripInternalInteractionFlags, } from '../interaction-outcome-policy.ts'; @@ -411,3 +412,58 @@ function makeSnapshotWithExtraText(label: string, y = 100): SnapshotState { ], }; } + +// --------------------------------------------------------------------------- +// discriminatingSurfaceChangedWithinRect (#2714 review): a whole-surface +// difference that lives outside the region a command acted on is not that +// command's doing. Measured on the Android tester, the status bar changed on +// its own over a list that never moved. The strict key-matched view stays +// owned here; this only narrows the region it is asked about. +// --------------------------------------------------------------------------- + +const LIST_RECT = { x: 0, y: 150, width: 390, height: 600 }; + +function rowAt(index: number, y: number) { + return { + type: 'StaticText', + identifier: `row-${index}`, + label: `Row ${index}`, + rect: { x: 12, y, width: 300, height: 24 }, + } as SnapshotState['nodes'][number]; +} + +function surfaceWithClock(clock: string, secondRowY: number) { + return [ + { + type: 'Image', + identifier: 'status-clock', + label: clock, + rect: { x: 12, y: 12, width: 40, height: 18 }, + } as SnapshotState['nodes'][number], + rowAt(1, 200), + rowAt(2, secondRowY), + ]; +} + +test('discriminatingSurfaceChangedWithinRect reads a movement inside the region', () => { + const before = buildInteractionSurfaceSignature(surfaceWithClock('2:40', 300)); + const after = buildInteractionSurfaceSignature(surfaceWithClock('2:40', 420)); + + assert.equal(discriminatingSurfaceChangedWithinRect(before, after, LIST_RECT), true); +}); + +test('discriminatingSurfaceChangedWithinRect does not read a movement outside the region', () => { + const before = buildInteractionSurfaceSignature(surfaceWithClock('2:40', 300)); + const after = buildInteractionSurfaceSignature(surfaceWithClock('2:41', 300)); + + // The whole surface did change — that is the trap this reader exists to refuse. + assert.equal(classifyBaselineSurfaceEvidence(before, after), 'changed'); + assert.equal(discriminatingSurfaceChangedWithinRect(before, after, LIST_RECT), false); +}); + +test('discriminatingSurfaceChangedWithinRect counts content appearing inside the region', () => { + const before = buildInteractionSurfaceSignature(surfaceWithClock('2:40', 300)); + const after = buildInteractionSurfaceSignature([...surfaceWithClock('2:40', 300), rowAt(3, 640)]); + + assert.equal(discriminatingSurfaceChangedWithinRect(before, after, LIST_RECT), true); +}); diff --git a/src/daemon/__tests__/scroll-movement.test.ts b/src/daemon/__tests__/scroll-movement.test.ts new file mode 100644 index 0000000000..bf46f3cbdc --- /dev/null +++ b/src/daemon/__tests__/scroll-movement.test.ts @@ -0,0 +1,501 @@ +import assert from 'node:assert/strict'; +import { test, vi } from 'vitest'; +import type { SnapshotResult } from '@agent-device/contracts/interactor-types'; +import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; +import { AppError } from '@agent-device/kernel/errors'; +import type { CommandFlags } from '@agent-device/contracts/command'; +import type { Rect, SnapshotNode } from '@agent-device/kernel/snapshot'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { IOS_SIMULATOR, MACOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; +import { makeSession } from '../../__tests__/test-utils/session-factories.ts'; +import { expireRefFrame } from '../ref-frame.ts'; +import { + observeScrollMovement, + planScrollMovement, + readScrollSurfaceBaseline, + reportScrollMovementNotApplicable, + reportScrollMovementUnobserved, + type ScrollMovementPlan, + type ScrollSurfaceBaseline, +} from '../scroll-movement.ts'; + +// The reason a scroll withholds its claim lives in the daemon log, so the cases below pin it there +// rather than trusting a bare `unobserved`. +vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, emitDiagnostic: vi.fn() }; +}); + +const loggedDiagnostics = vi.mocked(emitDiagnostic); + +function movementDiagnostic(): { phase: string; level: string; data: Record } { + const call = [...loggedDiagnostics.mock.calls] + .reverse() + .find(([event]) => String(event.phase).startsWith('scroll_movement_')); + if (!call) throw new Error('the movement module logged no diagnostic'); + return { + phase: call[0].phase, + level: call[0].level ?? 'info', + data: (call[0].data ?? {}) as Record, + }; +} + +function assertWithheld(reason: string): void { + const entry = movementDiagnostic(); + assert.equal(entry.phase, 'scroll_movement_unobserved'); + assert.equal(entry.data.reason, reason); +} + +function assertPlanKind( + plan: ScrollMovementPlan, + kind: K, +): asserts plan is Extract { + if (plan.kind !== kind) throw new Error(`expected a "${kind}" plan, got "${plan.kind}"`); +} + +/** + * The observation that gates a directional scroll's distance claim (#2714). Node shapes follow the + * live pair `interaction-surface-baseline-evidence.test.ts` transcribed: a scroller holding labelled + * rows, plus fixed chrome that never moves. + * + * Every verdict here is paired with the closest input that must NOT produce it, because the whole + * risk of this rule is failing a scroll that was fine: a no-op and a scroll at the end of its list + * differ by one hidden-content hint, a no-op and a scroll over a busy screen differ by whether the + * surface will hold still, and a no-op and a scroll whose content was replaced differ by the strict + * no-effect bar rather than by the distance it names. + */ + +const CONTAINER: Rect = { x: 18, y: 178, width: 366, height: 662 }; +const SWIPE_MIDPOINT = { x: 201, y: 437 }; +const REQUESTED_PIXELS = 656; + +/** The trees one case feeds the observer: a fixed list of them, or one that never repeats. */ +type SnapshotFrames = SnapshotNode[][] | ((attempt: number) => SnapshotNode[] | Error); + +function screen(rowOffset: number, hiddenBelow = true): SnapshotNode[] { + return [ + { + type: 'ScrollView', + identifier: 'lab-list', + rect: CONTAINER, + ...(hiddenBelow ? { hiddenContentBelow: true } : {}), + }, + { + type: 'StaticText', + label: 'Row one', + rect: { x: 24, y: 200 + rowOffset, width: 300, height: 20 }, + }, + { + type: 'StaticText', + label: 'Row two', + rect: { x: 24, y: 260 + rowOffset, width: 300, height: 20 }, + }, + { + type: 'Button', + identifier: 'automation-press', + label: 'Press canary', + rect: { x: 24, y: 780, width: 200, height: 44 }, + }, + ] as SnapshotNode[]; +} + +function state(nodes: SnapshotNode[], flags?: Partial) { + return buildSnapshotState({ nodes, backend: 'xctest', producer: 'apple-runner' }, flags); +} + +function baselineOf(nodes: SnapshotNode[], flags?: Partial): ScrollSurfaceBaseline { + const baseline = readScrollSurfaceBaseline(state(nodes, flags)); + if (!baseline) throw new Error('the fixture produced no comparable surface'); + return baseline; +} + +/** + * The captures the observation is allowed to take. A case that under-provides frames fails loudly: + * reusing the last frame would let a quiet pair form by accident, turning a budget case into a + * no-op case that passes for the wrong reason. + */ +function captures(screens: SnapshotFrames): { + calls: () => number; + capture: () => Promise; +} { + const next: (attempt: number) => SnapshotNode[] | Error = + typeof screens === 'function' ? screens : queueReader(screens); + let calls = 0; + return { + calls: () => calls, + capture: async (): Promise => { + const frame = next(calls); + calls += 1; + if (frame instanceof Error) throw frame; + return { nodes: frame, backend: 'xctest', producer: 'apple-runner' }; + }, + }; +} + +function queueReader(frames: SnapshotNode[][]) { + return (attempt: number): SnapshotNode[] => { + const frame = frames[attempt]; + if (frame === undefined) { + throw new Error('the observation captured more times than the case supplied frames for'); + } + return frame; + }; +} + +function observe(params: { + direction?: 'up' | 'down' | 'left' | 'right'; + baseline: ScrollSurfaceBaseline; + screens: SnapshotFrames; + midpoint?: { x: number; y: number }; + pixels?: number; + budgetMs?: number; +}) { + const spy = captures(params.screens); + loggedDiagnostics.mockClear(); + const observation = observeScrollMovement({ + direction: params.direction ?? 'down', + baseline: params.baseline, + swipe: { + midpoint: params.midpoint ?? SWIPE_MIDPOINT, + pixels: params.pixels ?? REQUESTED_PIXELS, + }, + capture: spy.capture, + pollMs: 1, + budgetMs: params.budgetMs ?? 5_000, + }); + return { observation, spy }; +} + +test('a surface that no longer holds the pre-gesture content answers moved on the first capture', async () => { + const { observation, spy } = observe({ + baseline: baselineOf(screen(0)), + screens: [screen(-300)], + }); + + assert.equal(await observation, 'moved'); + // The cost claim of the whole feature: a scroll that worked is confirmed by one capture. + assert.equal(spy.calls(), 1); +}); + +test('a surface that never shifted while the container still hides content refuses with a typed reason', async () => { + const { observation, spy } = observe({ + baseline: baselineOf(screen(0)), + screens: [screen(0), screen(0)], + }); + + await assert.rejects( + () => observation, + (error: unknown) => + error instanceof AppError && + error.code === 'COMMAND_FAILED' && + error.details?.reason === 'scroll_no_progress' && + error.details.direction === 'down' && + error.details.hiddenContentAt === 'bottom' && + error.details.requestedPixels === REQUESTED_PIXELS && + typeof error.details.hint === 'string' && + /swipe x1 y1 x2 y2/.test(error.details.hint), + ); + // Refusing needs the surface at rest, which needs the second read. + assert.equal(spy.calls(), 2); +}); + +/** + * The refusal above names a raw drag because it knows where the swipe ran. An owner that reports no + * coordinates (a tvOS scroll is a remote keypress) must not be told to swipe where it cannot. + */ +test('a refusal without gesture coordinates does not recommend a swipe', async () => { + const spy = captures([screen(0), screen(0)]); + + await assert.rejects( + () => + observeScrollMovement({ + direction: 'down', + baseline: baselineOf(screen(0)), + swipe: {}, + capture: spy.capture, + pollMs: 1, + budgetMs: 5_000, + }), + (error: unknown) => + error instanceof AppError && !/swipe x1 y1 x2 y2/.test(String(error.details?.hint)), + ); +}); + +test('a surface that never shifted with nothing left to reveal answers at-edge, not a refusal', async () => { + const { observation } = observe({ + baseline: baselineOf(screen(0)), + screens: [screen(0, false), screen(0, false)], + }); + + assert.equal(await observation, 'at-edge'); +}); + +test('a tree that names no scroll container is not read as the end of the content', async () => { + const rowsOnly = screen(0).filter((node) => node.type !== 'ScrollView'); + const { observation } = observe({ + baseline: baselineOf(rowsOnly), + screens: [rowsOnly, rowsOnly], + }); + + assert.equal(await observation, 'unobserved'); + assertWithheld('no-scroll-container'); +}); + +test('a container the gesture never ran inside is not blamed for the no-op', async () => { + const { observation } = observe({ + baseline: baselineOf(screen(0)), + screens: [screen(0), screen(0)], + midpoint: { x: 201, y: 40 }, + }); + + assert.equal(await observation, 'unobserved'); + assertWithheld('container-outside-swipe'); +}); + +test('a horizontal scroll that moved nothing reports what it measured instead of refusing', async () => { + const pager = [ + { type: 'ScrollView', identifier: 'pager', rect: CONTAINER, hiddenContentBelow: true }, + { type: 'StaticText', label: 'Page', rect: { x: 24, y: 400, width: 200, height: 20 } }, + ] as SnapshotNode[]; + const { observation } = observe({ + direction: 'left', + baseline: baselineOf(pager), + screens: [pager, pager], + }); + + assert.equal(await observation, 'unchanged'); +}); + +/** + * A pair from different capture lineages is not two views of one screen — the XCTest-channel fallback + * swaps the producer mid-request (#1569) — so its difference is neither movement nor a no-op. The + * frames here genuinely differ: the gate has to hold on the path that would otherwise answer `moved`, + * not only on the quiet one. + */ +test('a capture lineage that changed mid-request withholds the claim, even as the frames differ', async () => { + const baseline = readScrollSurfaceBaseline({ + ...state(screen(0)), + comparisonKey: 'lineage-before', + }); + if (!baseline) throw new Error('the fixture produced no comparable surface'); + const { observation, spy } = observe({ + baseline, + screens: [screen(-300)], + }); + + assert.equal(await observation, 'unobserved'); + assertWithheld('capture-lineage-drift'); + assert.equal(spy.calls(), 1); +}); + +/** The same gate on the other lineage axis: `snapshot -i` stored the baseline, and the broad tree this + * command reads shares too little with it to refuse a scroll on — or to credit one. */ +test('a baseline captured interactively-only withholds the claim, even as the frames differ', async () => { + const { observation } = observe({ + baseline: baselineOf(screen(0), { snapshotInteractiveOnly: true }), + screens: [screen(-300)], + }); + + assert.equal(await observation, 'unobserved'); + assertWithheld('baseline-presentation-drift'); +}); + +test('content appearing under unchanged chrome is not judged a no-op either', async () => { + // A toast over an untouched list: nothing the baseline named disappeared, so the tolerant + // classifier says 'unchanged' — and the strict no-effect bar refuses to call it a scroll that did + // nothing, in either direction. + const toast = { + type: 'StaticText', + label: 'Saved', + rect: { x: 24, y: 60, width: 120, height: 20 }, + } as SnapshotNode; + const { observation } = observe({ + baseline: baselineOf(screen(0)), + screens: [ + [...screen(0), toast], + [...screen(0), toast], + ], + }); + + assert.equal(await observation, 'unobserved'); + assertWithheld('surface-divergence'); +}); + +test('a surface still in motion on the first read is a scroll that worked, not a no-op', async () => { + const { observation, spy } = observe({ + baseline: baselineOf(screen(0)), + screens: [screen(0), screen(-320)], + }); + + assert.equal(await observation, 'moved'); + assert.equal(spy.calls(), 2); +}); + +test('a surface that never holds still spends the budget and answers unobserved', async () => { + // A spinner that comes and goes keeps every consecutive pair different while every element that + // can be identified sits exactly where it was: `unchanged` against the baseline, never at rest. + // Refusing here would fail a scroll over a busy screen, so the budget expires instead. + const flicker = { + type: 'ActivityIndicator', + label: 'Loading', + rect: { x: 340, y: 20, width: 20, height: 20 }, + } as SnapshotNode; + const { observation, spy } = observe({ + baseline: baselineOf(screen(0)), + screens: (attempt) => (attempt % 2 === 0 ? screen(0) : [...screen(0), flicker]), + budgetMs: 40, + }); + + assert.equal(await observation, 'unobserved'); + assertWithheld('surface-unsettled'); + // The budget, not an accidental quiet pair: more than the two reads a verdict needs were taken. + assert.ok(spy.calls() > 2); +}); + +test('a capture this command cannot read withholds the claim instead of failing the scroll', async () => { + const unreadable = new Error('snapshot source unsupported'); + const { observation } = observe({ + baseline: baselineOf(screen(0)), + screens: () => unreadable, + budgetMs: 40, + }); + + assert.equal(await observation, 'unobserved'); + assertWithheld('capture-unreadable'); +}); + +/** + * The tree a scroll reads carries system chrome along with the app's content, and on Android that + * chrome changes by itself: measured on the tester's `/catalog`, a status-bar icon alone turned the + * pair into "changed" over a list that had not moved. Only a difference inside the scroller counts as + * the gesture's doing — and the absence of one is not evidence that the gesture failed either. + */ +test('a difference outside the scrolled container does not buy the movement claim', async () => { + const chrome = { + type: 'Image', + identifier: 'status-clock', + label: '2:40', + rect: { x: 20, y: 20, width: 60, height: 20 }, + } as SnapshotNode; + const { observation, spy } = observe({ + baseline: baselineOf([...screen(0), chrome]), + screens: [[...screen(0), { ...chrome, label: '2:41' } as SnapshotNode]], + }); + + assert.equal(await observation, 'unobserved'); + assertWithheld('change-outside-container'); + // The claim is confined by the tree already read: no second capture is spent to ask. + assert.equal(spy.calls(), 1); +}); + +test('a surface with no container to confine the claim to keeps the whole-surface answer', async () => { + const rowsOnly = (offset: number) => screen(offset).filter((node) => node.type !== 'ScrollView'); + const { observation } = observe({ + baseline: baselineOf(rowsOnly(0)), + screens: [rowsOnly(-300)], + }); + + assert.equal(await observation, 'moved'); +}); + +/** + * What a scroll owes is decided once, before the gesture dispatches, because every fact the plan reads + * — the device family, the caller's flags, the tree the session holds — is about to change under it. + */ +test('a platform whose scroll dispatches no swipe is declined the observation', () => { + const plan = planScrollMovement({ + device: MACOS_DEVICE, + flags: undefined, + session: makeSession('movement-plan-desktop'), + }); + assertPlanKind(plan, 'declined'); + + assert.equal(plan.reason, 'non-swipe-platform'); +}); + +test('a caller that pays for its own observation is declined before the gesture', () => { + const plan = planScrollMovement({ + device: IOS_SIMULATOR, + flags: { postGestureStabilization: false }, + session: makeSession('movement-plan-replay', { snapshot: state(screen(0)) }), + }); + assertPlanKind(plan, 'declined'); + + assert.equal(plan.reason, 'caller-declined-observation'); +}); + +test('a settle observation owns the outcome, so the scroll does not answer twice', () => { + const plan = planScrollMovement({ + device: IOS_SIMULATOR, + flags: { settle: true }, + session: makeSession('movement-plan-settle', { snapshot: state(screen(0)) }), + }); + assertPlanKind(plan, 'declined'); + + assert.equal(plan.reason, 'settle-observer-owns-outcome'); +}); + +test('a session holding no tree cannot back a movement claim, and names why', () => { + const plan = planScrollMovement({ + device: IOS_SIMULATOR, + flags: undefined, + session: makeSession('movement-plan-empty'), + }); + assertPlanKind(plan, 'unobservable'); + + assert.equal(plan.reason, 'stored-surface-unusable'); +}); + +test('a tree the session no longer stands behind is refused as a baseline', () => { + const session = makeSession('movement-plan-stale', { snapshot: state(screen(0)) }); + expireRefFrame(session); + const plan = planScrollMovement({ device: IOS_SIMULATOR, flags: undefined, session }); + assertPlanKind(plan, 'unobservable'); + + assert.equal(plan.reason, 'stored-surface-not-current'); +}); + +test('a gesture nobody has read yet may still be moving the stored tree', () => { + const session = makeSession('movement-plan-pending', { snapshot: state(screen(0)) }); + session.postGestureStabilization = { action: 'tap', positionals: [], markedAt: Date.now() }; + const plan = planScrollMovement({ device: IOS_SIMULATOR, flags: undefined, session }); + assertPlanKind(plan, 'unobservable'); + + assert.equal(plan.reason, 'prior-gesture-unsettled'); +}); + +test('a session standing behind its tree gets the read, at no extra capture cost', () => { + const snapshot = state(screen(0)); + const plan = planScrollMovement({ + device: IOS_SIMULATOR, + flags: undefined, + session: makeSession('movement-plan-observe', { snapshot }), + }); + assertPlanKind(plan, 'observe'); + + assert.deepEqual(plan.baseline, readScrollSurfaceBaseline(snapshot)); +}); + +test('the withheld answer carries its reason to the daemon log, not only to the response', () => { + loggedDiagnostics.mockClear(); + + const answer = reportScrollMovementUnobserved('down', 'stored-surface-not-current', { + swipe: { midpoint: SWIPE_MIDPOINT, pixels: REQUESTED_PIXELS }, + }); + + assert.equal(answer, 'unobserved'); + const entry = movementDiagnostic(); + assert.equal(entry.phase, 'scroll_movement_unobserved'); + assert.equal(entry.data.reason, 'stored-surface-not-current'); + assert.equal(entry.data.requestedPixels, REQUESTED_PIXELS); +}); + +test('the command that owes no observation logs which owner owes the read', () => { + loggedDiagnostics.mockClear(); + + reportScrollMovementNotApplicable('down', 'owner-without-capture'); + + const entry = movementDiagnostic(); + assert.equal(entry.phase, 'scroll_movement_not_applicable'); + assert.equal(entry.data.reason, 'owner-without-capture'); +}); diff --git a/src/daemon/deferred-interaction-outcome.ts b/src/daemon/deferred-interaction-outcome.ts index 043c2fe463..79e6cc4d87 100644 --- a/src/daemon/deferred-interaction-outcome.ts +++ b/src/daemon/deferred-interaction-outcome.ts @@ -27,6 +27,7 @@ import { summarizeDiscriminatingSurfaceDivergence, markPendingInteractionOutcome, retryPendingInteractionOutcome, + snapshotSurfaceComparisonKey, type InteractionRetryTap, } from './interaction-outcome-policy.ts'; import { runPostGestureStabilityLoop } from '@agent-device/capture-kit/post-gesture-stability'; @@ -124,7 +125,7 @@ function markPostGestureStabilization( baselineSignature, // Recorded so the loop can tell a comparable quiet capture from one // served by a different backend, which is not comparable at all. - baselineBackend: snapshotComparisonKey(session.snapshot), + baselineBackend: snapshotSurfaceComparisonKey(session.snapshot), } : {}), }; @@ -350,7 +351,7 @@ export async function capturePostGestureStabilizedResult(params: { const snapshot = readSnapshot(value); return { signature: buildInteractionSurfaceSignature(snapshot.nodes), - backend: snapshotComparisonKey(snapshot), + backend: snapshotSurfaceComparisonKey(snapshot), }; }, signaturesStable: areInteractionSurfaceSignaturesStable, @@ -363,16 +364,6 @@ export async function capturePostGestureStabilizedResult(params: { return outcome; } -/** - * What makes two captures comparable at all. The iOS comparison key already carries the whole - * presentation identity, including the surface the capture described — an in-place system surface (a - * web sign-in sheet) is captured under its own host lineage (#2438) — so a sheet appearing or - * dismissing mid-poll reads as incomparable rather than as a stable surface. - */ -function snapshotComparisonKey(snapshot: SnapshotState | undefined): string | undefined { - return snapshot?.comparisonKey ?? snapshot?.snapshotQuality?.backend; -} - function isPostGestureStabilizingAction( action: string, positionals: string[], diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-outcome-policy.ts index 3de9938efa..3519a01e83 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-outcome-policy.ts @@ -1,6 +1,6 @@ import type { CommandFlags } from '@agent-device/contracts/command'; import { isMobilePlatform } from '@agent-device/kernel/device'; -import type { SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; +import type { Rect, SnapshotNode, SnapshotState } from '@agent-device/kernel/snapshot'; import { collectKeyboardChromeRefs } from '@agent-device/capture-kit/snapshot-chrome'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { isViewportRootNode } from '@agent-device/contracts/snapshot'; @@ -256,6 +256,17 @@ export function buildInteractionSurfaceSignature( return entries; } +/** + * What makes two captures comparable at all: the iOS comparison key when the capture carries one, + * and the capturing backend otherwise. Two trees from different producers are not two views of one + * screen — the XCTest-channel fallback swapping mid-request (#1569) is the case this exists for. + */ +export function snapshotSurfaceComparisonKey( + snapshot: SnapshotState | undefined, +): string | undefined { + return snapshot?.comparisonKey ?? snapshot?.snapshotQuality?.backend; +} + export function classifyInteractionSurfaceChange( before: InteractionSurfaceSignature, after: InteractionSurfaceSignature, @@ -453,6 +464,47 @@ export function summarizeDiscriminatingSurfaceDivergence( return { onlyInBaseline, onlyInCurrent: currentByKey.size, rectMismatched, shared }; } +/** + * Whether the DISCRIMINATING entries inside `rect` differ across a gesture, on the same key-matched, + * rect-tolerant view `haveIdenticalDiscriminatingSurfaces` uses — restricted to one region. + * + * A whole-surface difference is not automatically the gesture's doing. A captured tree carries system + * chrome with it, and on Android the status bar clocks and icons change on their own while the app's + * list sits frozen underneath. A difference that lives entirely outside the region a command acted on + * therefore proves nothing in either direction: it cannot credit the gesture, and it cannot convict it. + */ +export function discriminatingSurfaceChangedWithinRect( + before: InteractionSurfaceSignature, + after: InteractionSurfaceSignature, + rect: Rect, +): boolean { + const beforeInRect = discriminatingEntriesWithinRect(before, rect); + const afterByKey = new Map( + discriminatingEntriesWithinRect(after, rect).map((entry) => [entry.key, entry]), + ); + for (const entry of beforeInRect) { + const other = afterByKey.get(entry.key); + if (!other) return true; + if (!rectsWithinTolerance(entry, other)) return true; + afterByKey.delete(entry.key); + } + return afterByKey.size > 0; +} + +function discriminatingEntriesWithinRect( + signature: InteractionSurfaceSignature, + rect: Rect, +): InteractionSurfaceSignature { + return signature.filter( + (entry) => + entry.discriminating && + entry.x < rect.x + rect.width && + rect.x < entry.x + entry.width && + entry.y < rect.y + rect.height && + rect.y < entry.y + entry.height, + ); +} + function supportsInteractionOutcomePolicy(session: SessionState): boolean { return isMobilePlatform(session.device); } diff --git a/src/daemon/scroll-movement.ts b/src/daemon/scroll-movement.ts new file mode 100644 index 0000000000..e0c878d7bb --- /dev/null +++ b/src/daemon/scroll-movement.ts @@ -0,0 +1,591 @@ +import type { ScrollMovementObservation } from '@agent-device/contracts/scroll-command'; +import type { ScrollDirection } from '@agent-device/contracts/scroll-gesture'; +import type { SnapshotResult } from '@agent-device/contracts/interactor-types'; +import type { CommandFlags } from '@agent-device/contracts/command'; +import { isMobilePlatform, type DeviceInfo } from '@agent-device/kernel/device'; +import { buildSnapshotState } from '@agent-device/capture-kit/snapshot-state'; +import { + readScrollEdgeState, + scrollNoProgressHint, + verticalEdgeFor, + type ScrollEdge, +} from '@agent-device/capture-kit/scroll-edge-state'; +import { containsPoint } from '@agent-device/kernel/rect'; +import { AppError } from '@agent-device/kernel/errors'; +import type { Point, Rect, SnapshotState } from '@agent-device/kernel/snapshot'; +import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { sleep } from '@agent-device/host-kit/retry'; +import { + areInteractionSurfaceSignaturesStable, + buildInteractionSurfaceSignature, + classifyBaselineSurfaceEvidence, + discriminatingSurfaceChangedWithinRect, + haveIdenticalDiscriminatingSurfaces, + snapshotSurfaceComparisonKey, + summarizeDiscriminatingSurfaceDivergence, + type InteractionSurfaceChange, + type InteractionSurfaceSignature, +} from './interaction-outcome-policy.ts'; +import { refFrameState } from './ref-frame.ts'; +import { isPostGestureStabilizationPending } from './deferred-interaction-outcome.ts'; +import type { SessionState } from './session-state.ts'; + +/** + * What one directional scroll saw after its gesture, and the only thing that can back the distance + * the same response reports (#2714). + * + * `scroll ` used to answer with the travel its gesture plan had computed. That number + * describes the swipe that was dispatched, not the content that moved, so a scroll whose gesture + * never reached the container reported success with a distance — repeatedly, on every retry, until a + * later assertion failed for a reason no log explained. This module reads the difference the command + * can actually observe: the tree the session already held before the gesture, and one tree after it. + * + * The evidence rules are not a second opinion on that pair. The signature, the subset-tolerant + * baseline classifier, and the strict no-effect bar all belong to + * `interaction-outcome-policy.ts`, the same comparators the deferred post-gesture stabilization + * applies to every other gesture, so "that scroll did nothing" means one thing in this daemon. + * What is new here is only WHEN the answer is owed: a directional scroll pays one capture to gate its + * own reply instead of leaving the proof to the next command's snapshot. + * + * The shape of the loop is the mirror image of that stabilization: a surface that differs from the + * baseline is answered on the first capture, so a scroll that worked pays nothing extra; only a + * surface that looks untouched keeps polling (a mid-flight or stale read is indistinguishable from a + * no-op until it either moves or goes quiet), and only an untouched surface at rest is ever reported. + * + * Nothing is classified before the two trees are established to be two views of one screen, and that + * gate runs on every capture rather than only on the quiet path. The deferred loop can adopt a new + * producer when a capture lineage changes under it and keep going, because its question is whether the + * surface ever settles; this one compares against a pre-gesture tree, so a pair from different + * lineages can never answer either way and the claim is withheld rather than re-based. + */ + +/** How long a scroll keeps asking whether an untouched surface is really untouched (#1542's window). */ +const MOVEMENT_VERDICT_BUDGET_MS = 1_500; +const MOVEMENT_POLL_MS = 200; + +/** The pre-gesture surface, already in hand: no capture is spent to produce it. */ +export type ScrollSurfaceBaseline = Readonly<{ + signature: InteractionSurfaceSignature; + presentationKey: string | undefined; + comparisonKey: string | undefined; +}>; + +/** Why there is nothing honest to compare this scroll's effect against. */ +export type ScrollSurfaceBaselineAbsence = + | 'stored-surface-unusable' + | 'stored-surface-not-current' + | 'prior-gesture-unsettled'; + +/** Why this command owes no observation of its own effect. A reason, never text to match. */ +export type ScrollMovementInapplicability = + | 'owner-without-capture' + | 'non-swipe-platform' + | 'caller-declined-observation' + | 'settle-observer-owns-outcome'; + +/** + * Everything about this scroll's observation that is knowable before it dispatches, decided once, in + * one place: whether the command owes the read at all, and what it gets to compare against. + * + * The declines are the device's and the caller's — a platform that moves content with a wheel or a JS + * step instead of a synthesized swipe, a caller that pays for its own observation (Maestro's replay + * loop), and `--settle`, which freezes its own baseline and already answers whether the surface + * moved. Whether the bound runtime can read a screen at all is the one fact not knowable here, since + * binding happens after this returns; that owner reports its own `owner-without-capture`. + */ +export type ScrollMovementPlan = + | Readonly<{ + kind: 'declined'; + reason: Exclude; + }> + | Readonly<{ kind: 'unobservable'; reason: ScrollSurfaceBaselineAbsence }> + | Readonly<{ kind: 'observe'; baseline: ScrollSurfaceBaseline }>; + +export function planScrollMovement(params: { + device: DeviceInfo; + flags: CommandFlags | undefined; + session: SessionState; +}): ScrollMovementPlan { + if (!isMobilePlatform(params.device)) return { kind: 'declined', reason: 'non-swipe-platform' }; + if (params.flags?.postGestureStabilization === false) { + return { kind: 'declined', reason: 'caller-declined-observation' }; + } + if (params.flags?.settle === true) { + return { kind: 'declined', reason: 'settle-observer-owns-outcome' }; + } + return freezeScrollSurfaceBaseline(params.session); +} + +/** Where the swipe ran, so a container elsewhere on the screen cannot be blamed for the no-op. */ +export type ScrollSwipeEvidence = Readonly<{ + /** Midpoint of the dispatched gesture, in the same space as a snapshot rect. */ + midpoint?: Point; + /** The travel the gesture plan produced, reported on the refusal. */ + pixels?: number; +}>; + +export function readScrollSurfaceBaseline( + snapshot: SnapshotState | undefined, +): ScrollSurfaceBaseline | undefined { + if (!snapshot || snapshot.nodes.length === 0) return undefined; + const signature = buildInteractionSurfaceSignature(snapshot.nodes); + if (signature.length === 0) return undefined; + return { + signature, + presentationKey: snapshot.presentationKey, + comparisonKey: snapshotSurfaceComparisonKey(snapshot), + }; +} + +/** + * Freezes what a directional scroll can compare its own effect against, at the one moment that + * answer is available: before the command dispatches. + * + * The tree the session stores is not automatically the screen as it stands — a mutation may have + * happened since it was captured, and its own gesture is about to be another one. ADR 0014 already + * encodes exactly that question: the ref frame stays `active` while no device side effect has + * crossed since the last publication, which is while the stored tree still is the newest + * observation. Anything else and there is nothing honest to compare against, so this names the reason + * instead of letting a stale tree back a movement claim (#2714). + */ +function freezeScrollSurfaceBaseline(session: SessionState): ScrollMovementPlan { + const baseline = readScrollSurfaceBaseline(session.snapshot); + if (!baseline) return { kind: 'unobservable', reason: 'stored-surface-unusable' }; + if (refFrameState(session) !== 'active') { + return { kind: 'unobservable', reason: 'stored-surface-not-current' }; + } + if (isPostGestureStabilizationPending(session)) { + // A gesture nobody has read yet may still be moving this tree, which would bill its motion to + // the scroll being dispatched now. + return { kind: 'unobservable', reason: 'prior-gesture-unsettled' }; + } + return { kind: 'observe', baseline }; +} + +/** + * Observes one directional scroll and returns the movement its response may claim. + * + * Throws `scroll_no_progress` when it proves the gesture did not land: the surface is byte-for-byte + * what it was, the tree still names hidden content in the direction that was scrolled, and the swipe + * ran inside that container. Every other untouched surface answers honestly instead — at the edge of + * the content, in a direction with no end-of-content signal, or as `unobserved` when the evidence + * would not support the claim either way. + * + * A capture that fails does not fail the scroll: the gesture already happened, so an unreadable tree + * answers `unobserved` and leaves the caller's scroll succeeded. + */ +export async function observeScrollMovement(params: { + direction: ScrollDirection; + baseline: ScrollSurfaceBaseline; + swipe: ScrollSwipeEvidence; + capture: () => Promise; + /** The same two overrides `pollForScrollRest` takes: how long to ask, and how often. */ + budgetMs?: number; + pollMs?: number; +}): Promise { + const { direction, baseline, swipe } = params; + const verdict = await pollForSurfaceVerdict(baseline, params); + switch (verdict.kind) { + case 'moved': + return await claimMoved({ ...verdict, direction, baseline, swipe }); + case 'blind': + return reportScrollMovementUnobserved(direction, verdict.reason, { swipe }); + case 'settled': + return await decideUnchanged({ ...verdict, direction, baseline, swipe }); + } +} + +/** What the polling could say about the surface, before any of it is interpreted. */ +type SurfaceVerdict = + | Readonly<{ + kind: 'moved'; + observed: ObservedSurface; + attempts: number; + startedAt: number; + }> + | Readonly<{ kind: 'blind'; reason: SurfaceBlindReason }> + | Readonly<{ + kind: 'settled'; + observed: ObservedSurface; + evidence: InteractionSurfaceChange; + attempts: number; + startedAt: number; + }>; + +/** Why this capture cannot be compared against the pre-gesture tree at all, movement included. */ +type SurfaceBlindReason = 'capture-unreadable' | 'surface-unsettled' | ScrollSurfacePairDrift; + +/** + * Whether two captures describe one screen. Capture backends do not agree on which nodes exist, so a + * pair across a lineage change — the XCTest-channel fallback swapping the producer mid-request + * (#1569) — or across the interactive-only difference `snapshot -i` stores says nothing about + * movement in either direction, however different the two trees look. + */ +type ScrollSurfacePairDrift = 'baseline-presentation-drift' | 'capture-lineage-drift'; + +function surfacePairDrift( + baseline: ScrollSurfaceBaseline, + observed: ObservedSurface, +): ScrollSurfacePairDrift | undefined { + if (baseline.presentationKey !== observed.presentationKey) return 'baseline-presentation-drift'; + if (baseline.comparisonKey !== observed.comparisonKey) return 'capture-lineage-drift'; + return undefined; +} + +/** + * Polls until an untouched surface proves itself. A surface that differs from the baseline is a + * verdict on the first capture, so a scroll that worked pays for one read. One that looks untouched + * needs a quiet pair, because a gesture still in flight and a gesture that did nothing answer a + * single read identically. A surface that never holds still expires as `surface-unsettled` rather + * than being called a no-op — and that answer is worth a warning the others are not, since it spent + * the whole budget confirming nothing. + */ +async function pollForSurfaceVerdict( + baseline: ScrollSurfaceBaseline, + params: { + direction: ScrollDirection; + capture: () => Promise; + budgetMs?: number; + pollMs?: number; + swipe: ScrollSwipeEvidence; + }, +): Promise { + const startedAt = Date.now(); + const deadline = startedAt + (params.budgetMs ?? MOVEMENT_VERDICT_BUDGET_MS); + let previous: InteractionSurfaceSignature | undefined; + let attempts = 0; + + while (true) { + const reading = await readOneCapture(baseline, params.capture); + attempts += 1; + if (reading.kind === 'blind') return { kind: 'blind', reason: reading.reason }; + if (reading.kind === 'changed') + return { kind: 'moved', observed: reading.observed, attempts, startedAt }; + if (surfaceIsAtRest(previous, reading.observed.signature)) { + return { + kind: 'settled', + observed: reading.observed, + evidence: reading.evidence, + attempts, + startedAt, + }; + } + if (Date.now() >= deadline) return budgetExpiredVerdict(params, attempts, startedAt); + previous = reading.observed.signature; + await sleep(params.pollMs ?? MOVEMENT_POLL_MS); + } +} + +/** + * What a single capture says, on its own: nothing at all when the tree could not be read or comes from + * another lineage, movement when the surface differs from the baseline, and otherwise the untouched + * pair the loop has to prove is at rest before it can be reported. + */ +type CaptureReading = + | Readonly<{ kind: 'blind'; reason: SurfaceBlindReason }> + | Readonly<{ kind: 'changed'; observed: ObservedSurface }> + | Readonly<{ kind: 'unchanged'; observed: ObservedSurface; evidence: InteractionSurfaceChange }>; + +async function readOneCapture( + baseline: ScrollSurfaceBaseline, + capture: () => Promise, +): Promise { + const observed = await captureSurface(capture); + if (!observed) return { kind: 'blind', reason: 'capture-unreadable' }; + const drift = surfacePairDrift(baseline, observed); + if (drift) return { kind: 'blind', reason: drift }; + const evidence = classifyBaselineSurfaceEvidence(baseline.signature, observed.signature); + if (evidence === 'changed') return { kind: 'changed', observed }; + return { kind: 'unchanged', observed, evidence }; +} + +/** + * The surface never held still, so this command spent its whole budget confirming nothing. That answer + * is worth a warning the others are not: it is also the one that cost the caller the most. + */ +function budgetExpiredVerdict( + params: { + direction: ScrollDirection; + swipe: ScrollSwipeEvidence; + }, + attempts: number, + startedAt: number, +): SurfaceVerdict { + emitDiagnostic({ + level: 'warn', + phase: 'scroll_movement_budget_expired', + data: { + direction: params.direction, + attempts, + durationMs: Date.now() - startedAt, + ...(params.swipe.pixels === undefined ? {} : { requestedPixels: params.swipe.pixels }), + }, + }); + return { kind: 'blind', reason: 'surface-unsettled' }; +} + +function surfaceIsAtRest( + previous: InteractionSurfaceSignature | undefined, + next: InteractionSurfaceSignature, +): boolean { + return previous !== undefined && areInteractionSurfaceSignaturesStable(previous, next); +} + +type ObservedSurface = Readonly<{ + signature: InteractionSurfaceSignature; + presentationKey: string | undefined; + comparisonKey: string | undefined; + /** The tree itself: the edge question is asked of nodes, not of the identity signature. */ + nodes: SnapshotState['nodes']; +}>; + +async function captureSurface( + capture: () => Promise, +): Promise { + let result: SnapshotResult; + try { + result = await capture(); + } catch { + // The gesture is already done; a tree this command cannot read withholds the claim, it does not + // undo the scroll. + return undefined; + } + const state = buildSnapshotState(result, undefined); + return { + signature: buildInteractionSurfaceSignature(state.nodes), + presentationKey: state.presentationKey, + comparisonKey: snapshotSurfaceComparisonKey(state), + nodes: state.nodes, + }; +} + +/** + * A changed surface credits the gesture only when the change sits inside the scroller the swipe ran + * in. The captured tree carries system chrome along with the app's content, and on Android the status + * bar clocks and icons change on their own while a frozen list sits underneath them — measured on the + * tester's `/catalog`, a battery icon alone turned the pair into "changed" over a list that never + * moved. Without a container to confine the claim to (no scroller resolved, or the horizontal axis the + * analyzer does not read) the whole-surface difference is all there is, and stays the answer. + */ +async function claimMoved(params: { + direction: ScrollDirection; + baseline: ScrollSurfaceBaseline; + swipe: ScrollSwipeEvidence; + observed: ObservedSurface; + attempts: number; + startedAt: number; +}): Promise { + const { direction, baseline, swipe, observed } = params; + const edge = verticalEdgeFor(direction); + const containerRect = edge + ? (await readScrollEdgeState(observed.nodes, edge)).containerRect + : undefined; + if ( + containerRect && + !discriminatingSurfaceChangedWithinRect(baseline.signature, observed.signature, containerRect) + ) { + return reportScrollMovementUnobserved(direction, 'change-outside-container', { + swipe, + containerRect, + }); + } + return reportObserved(direction, 'moved', swipe, params.attempts, params.startedAt); +} + +async function decideUnchanged(params: { + direction: ScrollDirection; + baseline: ScrollSurfaceBaseline; + swipe: ScrollSwipeEvidence; + observed: ObservedSurface; + evidence: InteractionSurfaceChange; + attempts: number; + startedAt: number; +}): Promise { + const { direction, baseline, observed, evidence, swipe } = params; + const comparison = settledPairBlindness(baseline, observed, evidence); + if (comparison) + return reportScrollMovementUnobserved(direction, comparison.reason, { + swipe, + divergence: comparison.divergence, + }); + return await decideEdgeVerdict(params); +} + +type SurfaceComparison = Readonly<{ + reason: 'no-comparable-content' | 'surface-divergence'; + divergence?: Record; +}>; + +/** + * What a pair already known comparable still cannot say about being untouched. The subset-tolerant + * classifier is 'unchanged' for a narrower capture of the same screen, so the no-effect claim needs + * the strict both-directions bar (#1601 review P1): a scroll that replaced every list cell under + * fixed chrome must not be readable as a gesture that did nothing. + */ +function settledPairBlindness( + baseline: ScrollSurfaceBaseline, + observed: ObservedSurface, + evidence: InteractionSurfaceChange, +): SurfaceComparison | undefined { + if (evidence === 'ambiguous') return { reason: 'no-comparable-content' }; + if (!haveIdenticalDiscriminatingSurfaces(baseline.signature, observed.signature)) { + return { + reason: 'surface-divergence', + divergence: summarizeDiscriminatingSurfaceDivergence(baseline.signature, observed.signature), + }; + } + return undefined; +} + +/** What an untouched surface is: the end of the content, a direction with no edge to read, or a gesture that never landed. */ +async function decideEdgeVerdict(params: { + direction: ScrollDirection; + swipe: ScrollSwipeEvidence; + observed: ObservedSurface; + attempts: number; + startedAt: number; +}): Promise { + const { direction, observed, swipe } = params; + const edge = verticalEdgeFor(direction); + if (!edge) { + // The hidden-content analyzer reads the vertical axis only, so a horizontal scroll that moved + // nothing cannot tell a pager at its end from a list that ignored the swipe. It reports what it + // measured and leaves the two apart, rather than guessing. + return reportObserved(direction, 'unchanged', swipe, params.attempts, params.startedAt); + } + + const edgeState = await readScrollEdgeState(observed.nodes, edge); + const containerRect = edgeState.containerRect; + if (!containerRect) { + // `canScroll: false` here means the tree named no scroll container at all, which says nothing + // about where the content ends. + return reportScrollMovementUnobserved(direction, 'no-scroll-container', { swipe }); + } + if (swipe.midpoint && !containerHoldsSwipe(containerRect, swipe.midpoint)) { + return reportScrollMovementUnobserved(direction, 'container-outside-swipe', { + swipe, + containerRect, + }); + } + if (edgeState.canScroll) { + throw scrollNoProgressError(direction, edge, swipe, containerRect); + } + return reportObserved(direction, 'at-edge', swipe, params.attempts, params.startedAt); +} + +function containerHoldsSwipe(containerRect: Rect, midpoint: Point): boolean { + return containsPoint(containerRect, midpoint.x, midpoint.y); +} + +/** + * The gesture did not reach the container: hidden content is still there, and the surface never + * moved. Same typed family as `scroll_edge_no_progress` and `scroll_until_no_progress`, and the same + * hint, because the caller's next move is the same whichever loop noticed. + */ +function scrollNoProgressError( + direction: ScrollDirection, + edge: ScrollEdge, + swipe: ScrollSwipeEvidence, + containerRect: Rect, +): AppError { + return new AppError( + 'COMMAND_FAILED', + `scroll ${direction} moved nothing: the container still reports hidden content ${ + edge === 'bottom' ? 'below' : 'above' + } and its contents never shifted`, + { + reason: 'scroll_no_progress', + direction, + hiddenContentAt: edge, + containerRect, + ...(swipe.pixels === undefined ? {} : { requestedPixels: swipe.pixels }), + hint: scrollNoProgressHint({ + targetDirectly: `with scroll ${direction} --until `, + rawDrag: swipe.midpoint !== undefined, + }), + }, + ); +} + +/** + * Why a scroll that owed an observation could not make one: no usable baseline before the gesture, no + * readable or settled tree after it, or a pair that turned out not to be comparable. A reason, never + * text a caller has to match. + */ +export type ScrollMovementUnobservedReason = + | ScrollSurfaceBaselineAbsence + | SurfaceBlindReason + | 'no-comparable-content' + | 'surface-divergence' + | 'no-scroll-container' + | 'container-outside-swipe' + | 'change-outside-container'; + +/** What accompanies the reason in the daemon log, so one line explains the whole verdict. */ +export type ScrollMovementUnobservedEvidence = Readonly<{ + swipe: ScrollSwipeEvidence; + /** What the two surfaces disagreed on, for a `surface-divergence`. */ + divergence?: Record; + /** The scroll container the edge analyzer resolved, when it resolved one. */ + containerRect?: Rect; +}>; + +/** + * Tells the daemon log that this command owed no observation at all — the owner cannot read a + * screen, the device has no swipe to verify, or the caller (or another observer) already declined + * or owns that read. The response carries no `movement` field, so the reason has to live somewhere. + */ +export function reportScrollMovementNotApplicable( + direction: ScrollDirection, + reason: ScrollMovementInapplicability, +): void { + emitDiagnostic({ + level: 'debug', + phase: 'scroll_movement_not_applicable', + data: { direction, reason }, + }); +} + +/** + * Records why a scroll that DID owe an observation could not make one, and answers `unobserved`: the + * distance it reports then rests on the gesture plan alone. + */ +export function reportScrollMovementUnobserved( + direction: ScrollDirection, + reason: ScrollMovementUnobservedReason, + evidence: ScrollMovementUnobservedEvidence, +): ScrollMovementObservation { + emitDiagnostic({ + level: 'info', + phase: 'scroll_movement_unobserved', + data: { + direction, + reason, + ...(evidence.swipe.pixels === undefined ? {} : { requestedPixels: evidence.swipe.pixels }), + ...(evidence.divergence ?? {}), + ...(evidence.containerRect === undefined ? {} : { containerRect: evidence.containerRect }), + }, + }); + return 'unobserved'; +} + +function reportObserved( + direction: ScrollDirection, + movement: Exclude, + swipe: ScrollSwipeEvidence, + attempts: number, + startedAt: number, +): ScrollMovementObservation { + emitDiagnostic({ + level: movement === 'moved' ? 'debug' : 'info', + phase: 'scroll_movement_observed', + data: { + direction, + movement, + attempts, + durationMs: Date.now() - startedAt, + ...(swipe.pixels === undefined ? {} : { requestedPixels: swipe.pixels }), + }, + }); + return movement; +}