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
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,11 @@ struct DataPayload: Codable {
var snapshotQuality: SnapshotQuality?
/// Set when the capture describes an in-place system surface, not the app itself (#2438).
var systemSurface: SystemSurfaceProvenancePayload?
/// Whether this capture read a modal presentation over the app's own content (#3565). The runner's
/// accessibility tree reports no UIKit class names, so a runner capture states `presented` from
/// positive evidence and `unknown` otherwise; `absent` belongs to a producer that can read window
/// structure, and a tier that classified nothing publishes no key at all.
var presentedSurface: PresentedSurface?
/// The keyboard band this capture measured, when the tier that answered reads keyboards at all
/// (#2660). Absent means the query-sweep or private-AX tier answered, and the daemon's tap guard
/// keeps deriving the band from the tree.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,17 @@ extension RunnerTests {
nodes.append(contentNode)
}

return DataPayload(nodes: nodes, truncated: false)
// The probe resolved a blocking modal over the app, so this payload states its own subject rather
// than leaving a reader to infer it from a tree that holds only the modal (#3565).
return Self.blockingSystemModalPayload(nodes: nodes)
}

/// The payload a resolved blocking system modal publishes: the modal's own tree, and the fact that
/// the modal is what it describes. The probe is the one capture that states `presented` without
/// reading a window structure — its tree holds only the modal, so nothing downstream could read the
/// presentation out of it (#3565). Split from the probe body because that body needs a device.
static func blockingSystemModalPayload(nodes: [PresentedNode]) -> DataPayload {
DataPayload(nodes: nodes, truncated: false, presentedSurface: .presented)
}

func firstBlockingSystemModal(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import XCTest
import AgentDeviceSnapshotPresentation

#if AGENT_DEVICE_RUNNER_UNIT_TESTS
// #3565: a capture has to say whether it read a modal presentation over the app. The blocking
// system-modal probe is the one capture that can state `presented` without reading any window
// structure: the tree it publishes holds only the modal, so nothing downstream could read a
// presentation out of it. Only the payload factory is pinned here, because the probe body that calls it
// enumerates XCUIElements and needs a device.
extension RunnerTests {
func testResolvedBlockingSystemModalPayloadStatesAPresentedSurface() {
XCTAssertEqual(Self.blockingSystemModalPayload(nodes: []).presentedSurface, .presented)
}

// Silence is the answer a consumer must not be able to confuse with absence: a capture the runner
// never classified (an older runner, a tier that published no reading) encodes no key at all.
func testUnclassifiedSnapshotPayloadEncodesNoPresentedSurfaceKey() throws {
let encoded = try JSONEncoder().encode(DataPayload(nodes: [], truncated: false))
let object = try XCTUnwrap(
JSONSerialization.jsonObject(with: encoded) as? [String: Any],
"the snapshot payload encodes as an object"
)
XCTAssertNil(object["presentedSurface"])
}
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

/// Whether this capture read a modal presentation over the app's own content (#3565). The producer owns
/// the answer, and `absent` is a reading rather than a default, because a consumer arms a dismissal
/// confirmation on it: a capture whose evidence cannot answer says `unknown` instead of staying silent,
/// since silence is also what a producer that never classified publishes.
///
/// Mirrors the TypeScript `PresentedSurface` in `@agent-device/kernel/snapshot`.
public enum PresentedSurface: String, Codable, Equatable {
/// A presentation covers the content this capture describes.
case presented
/// Every window this capture read showed only its own content container.
case absent
/// The capture supports neither answer.
case unknown
}
4 changes: 4 additions & 0 deletions packages/capture-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,10 @@
"types": "./src/snapshot/scroll-edge-state.ts",
"default": "./src/snapshot/scroll-edge-state.ts"
},
"./snapshot-baseline": {
"types": "./src/snapshot-baseline.ts",
"default": "./src/snapshot-baseline.ts"
},
"./snapshot-chrome": {
"types": "./src/snapshot-chrome.ts",
"default": "./src/snapshot-chrome.ts"
Expand Down
38 changes: 38 additions & 0 deletions packages/capture-kit/src/snapshot-baseline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { expect, test } from 'vitest';
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import { surfaceScopedNodes } from './snapshot-baseline.ts';

const nodes = [{ index: 0, type: 'Application', ref: 'e1' }];

/**
* #3565 and #2438: this is the one mint both settle routes take their baseline from, so every fact a
* capture established about what it read has to leave here with the nodes. A baseline that lost the
* presented-surface fact would read to #1831's dismissal confirmation as "no producer spoke" instead of
* the reading the capture actually took.
*/
test('the mint carries the presented-surface fact beside the nodes', () => {
for (const presentedSurface of ['presented', 'absent', 'unknown'] as const) {
expect(surfaceScopedNodes(snapshot({ presentedSurface }))).toEqual({
nodes,
presentedSurface,
});
}
});

test('the mint carries no presented surface a capture never read', () => {
expect(surfaceScopedNodes(snapshot({}))).toEqual({ nodes });
});

test('the mint carries the system surface the nodes describe', () => {
expect(
surfaceScopedNodes(snapshot({ iosSystemSurfaceBundleId: 'com.apple.SafariViewService' })),
).toEqual({ nodes, surfaceBundleId: 'com.apple.SafariViewService' });
});

test('a session with no stored snapshot still mints an empty baseline', () => {
expect(surfaceScopedNodes(undefined)).toEqual({ nodes: [] });
});

function snapshot(fields: Partial<SnapshotState>): SnapshotState {
return { nodes, createdAt: 0, presentationKey: '', ...fields } as SnapshotState;
}
29 changes: 29 additions & 0 deletions packages/capture-kit/src/snapshot-baseline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import type { SurfaceScopedNodes } from '@agent-device/contracts/interaction';

/**
* Mints the one baseline value a post-action comparison carries: the captured nodes together with the
* surface they describe and the presentation the capture read (#2438, #3565).
*
* Both settle routes take their baseline from here — the element-targeted interaction route and the
* generic daemon route (`scroll`/`back --settle`) — so a fact a capture established cannot be carried
* by one route and dropped by the other. The pair sits beside {@link buildSnapshotState} because that
* is the layer both routes can reach: the interaction route lives in `commands/`, the generic route in
* `daemon/`, and the layering forbids the second from importing the first.
*
* One value, never two channels: every boundary that carries a baseline carries the type this returns,
* so nodes cannot arrive without the surface they describe.
*/
export function surfaceScopedNodes(
snapshot:
| Pick<SnapshotState, 'nodes' | 'iosSystemSurfaceBundleId' | 'presentedSurface'>
| undefined,
): SurfaceScopedNodes {
return {
nodes: snapshot?.nodes ?? [],
...(snapshot?.iosSystemSurfaceBundleId
? { surfaceBundleId: snapshot.iosSystemSurfaceBundleId }
: {}),
...(snapshot?.presentedSurface ? { presentedSurface: snapshot.presentedSurface } : {}),
};
}
7 changes: 7 additions & 0 deletions packages/capture-kit/src/snapshot-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
snapshotPresentationOptionsFromFlags,
snapshotRefMinting,
type IosTargetActivation,
type PresentedSurface,
type RawSnapshotNode,
type SnapshotBackend,
type SnapshotCaptureProvenance,
Expand Down Expand Up @@ -55,6 +56,11 @@ export function buildSnapshotState(
keyboard?: SnapshotKeyboardBandFact;
/** The box the producer measured the rects in (#3182), carried to the response that reads this state. */
viewport?: SnapshotViewportSize;
/**
* iOS: the producer's reading of a modal presentation over the app (#3565), decided on the
* acquired tree so every projection of one screen carries the same value.
*/
presentedSurface?: PresentedSurface;
} & SnapshotCaptureProvenance,
flags:
| (Pick<CommandFlags, 'snapshotDepth' | 'snapshotInteractiveOnly' | 'snapshotRaw'> &
Expand Down Expand Up @@ -92,6 +98,7 @@ export function buildSnapshotState(
? { comparisonKey: iosSnapshotComparisonIdentityKey(data.comparisonIdentity) }
: {}),
...(data.systemSurface ? { iosSystemSurfaceBundleId: data.systemSurface.bundleId } : {}),
...(data.presentedSurface ? { presentedSurface: data.presentedSurface } : {}),
...(data.targetActivation ? { targetActivation: data.targetActivation } : {}),
...(data.keyboard ? { keyboard: data.keyboard } : {}),
...(data.viewport ? { viewport: data.viewport } : {}),
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/src/client-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SessionSurface } from './session-surface.ts';
import type { PublicSnapshotCaptureAnnotations } from './snapshot-capture-annotations.ts';
import type { SnapshotDiagnosticsSummary } from './snapshot-diagnostics.ts';
import type {
PresentedSurface,
SnapshotCommandOptionFields,
SnapshotKeyboardBandFact,
SnapshotNode,
Expand Down Expand Up @@ -78,6 +79,13 @@ export type CaptureSnapshotResult = {
* screen size only; content-safe gesture bounds stay with #1821.
*/
viewport?: SnapshotViewportSize;
/**
* iOS: whether the capture read a modal presentation over the app (#3565), decided on the acquired
* tree before interactive filtering, compaction, depth, or scope. `'absent'` is a positive reading;
* a capture whose producer could not decide — or did not speak — reports `'unknown'`, and absence
* of the field means a non-iOS producer or an older runner published nothing.
*/
presentedSurface?: PresentedSurface;
/**
* Screenshot captured automatically when the semantic snapshot was sparse.
* Remote clients receive a materialized local path through the daemon artifact channel.
Expand Down
26 changes: 18 additions & 8 deletions packages/contracts/src/interaction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { Point, SnapshotKeyboardBandFact, SnapshotNode } from '@agent-device/kernel/snapshot';
import type {
PresentedSurface,
Point,
SnapshotKeyboardBandFact,
SnapshotNode,
} from '@agent-device/kernel/snapshot';
import type { ResponseCost } from '@agent-device/kernel/contracts';
import type { ClickButton } from './click-button.ts';
import type { FillUnconfirmedVerification } from './fill-evidence.ts';
Expand Down Expand Up @@ -119,16 +124,14 @@ export type PreresolvedInteractionTarget = {
ref: string;
/** The node the caller resolved, after the caller's own promotion. */
node: SnapshotNode;
/** The tree `node` came from — the guards read its siblings for occlusion/viewport. */
nodes: SnapshotNode[];
/**
* The in-place iOS system surface `nodes` describes (#2438), absent for ordinary app content.
* Travels with the tree so the adopting consumer's post-action comparison knows which surface
* its baseline came from.
* The tree `node` came from, minted by `surfaceScopedNodes` from the caller's capture. The guards
* read its siblings for occlusion/viewport, and the adopting leaf's post-action baseline is this
* value as-is, so the surface (#2438) and presentation (#3565) facts travel with the nodes.
*/
iosSystemSurfaceBundleId?: string;
tree: SurfaceScopedNodes;
/**
* The keyboard band the capture of `nodes` measured, carried so the adopting consumer's keyboard
* The keyboard band the capture of `tree` measured, carried so the adopting consumer's keyboard
* guard reads the same band the tree was captured with instead of re-deriving one (#2660).
*/
keyboard?: SnapshotKeyboardBandFact;
Expand All @@ -148,6 +151,13 @@ export type SurfaceScopedNodes = {
nodes: SnapshotNode[];
/** Bundle id of the in-place iOS system surface; absent for ordinary app content. */
surfaceBundleId?: string;
/**
* Whether the producer that captured these nodes read a modal presentation over the app (#3565).
* The settle baseline is carried in this type, so the fact has to travel with the nodes: a consumer
* that arms #1831's dismissal confirmation reads it here instead of guessing from the tree.
* Absent means the capture's producer published no fact.
*/
presentedSurface?: PresentedSurface;
};

/** Which tree a resolved `@ref`'s node was read from (ADR 0014 step 5). */
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
IosSnapshotComparisonIdentity,
} from './ios-snapshot.ts';
import type {
PresentedSurface,
RawSnapshotNode,
Point,
Rect,
Expand Down Expand Up @@ -265,6 +266,12 @@ export type SnapshotResult = Omit<BackendSnapshotResult, 'backend' | 'nodes'> &
* over the session app rather than the app itself (#2438).
*/
systemSurface?: IosSystemSurfaceProvenance;
/**
* Whether this capture read a modal presentation over the app (#3565), computed by the producer on
* the acquired tree before any projection. Absent means the producer published no fact at all,
* which a consumer must treat as `'unknown'`.
*/
presentedSurface?: PresentedSurface;
/**
* Keyboard evidence acquired with this tree: measured `visible`, policy-derived `derived`,
* additive `visibility-only`, rejected `tree-rejected`, proven `absent`, or unknown `unmeasurable` facts. Measured and
Expand Down
24 changes: 24 additions & 0 deletions packages/daemon/src/core/__tests__/moving-target-baseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,30 @@ test('a settle that stored its settled tree records that tree, not the tree the
assert.equal(movingTargetBaseline(session)?.tree.nodes, settled.nodes);
});

test('a stored settled tree keeps the surface and presentation its capture read', () => {
// #3565: the rest-proof baseline is minted like every other one, so a settle that came to rest on
// a sheet over SpringBoard hands the gate the same facts the capture published.
const session = makeSession('ios');
session.snapshot = {
...appTree('Settled'),
iosSystemSurfaceBundleId: 'com.apple.springboard',
presentedSurface: 'presented',
};

markDeferredInteractionOutcome({
session,
command: 'press',
positionals: ['label="Slide target"'],
flags: undefined,
preActionTree: { nodes: appTree('Pre-action').nodes },
settle: settleMark({ stored: true }),
});

const tree = movingTargetBaseline(session)?.tree;
assert.equal(tree?.surfaceBundleId, 'com.apple.springboard');
assert.equal(tree?.presentedSurface, 'presented');
});

test('a converged settle that stored nothing keeps the acted-through tree', () => {
// `settled: true` is not the whole claim, and neither is a settle that merely ran: a sparse
// capture is refused by the store and a cross-surface transition (#2438) withholds the diff,
Expand Down
11 changes: 8 additions & 3 deletions packages/daemon/src/core/deferred-interaction-outcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,19 @@ export function markDeferredInteractionOutcome(
markMovingTargetBaseline(session, preActionTree, settle?.diff !== undefined);
}

/** Narrows a published capture to the shape a baseline keeps, so a stored tree never smuggles in more. */
function movingTargetRestTree(snapshot: SnapshotState | undefined): SurfaceScopedNodes | undefined {
/**
* The rest-proof baseline, carrying the same facts `surfaceScopedNodes` mints. Built here because this
* module is on the eager path of most daemon entries, and importing `capture-kit/snapshot-baseline`
* adds a module to each of their closures (ADR 0027).
*/
function restTree(snapshot: SnapshotState | undefined): SurfaceScopedNodes | undefined {
if (!snapshot) return undefined;
return {
nodes: snapshot.nodes,
...(snapshot.iosSystemSurfaceBundleId
? { surfaceBundleId: snapshot.iosSystemSurfaceBundleId }
: {}),
...(snapshot.presentedSurface ? { presentedSurface: snapshot.presentedSurface } : {}),
};
}

Expand Down Expand Up @@ -173,7 +178,7 @@ function markMovingTargetBaseline(
preActionTree: SurfaceScopedNodes | undefined,
restProved: boolean,
): void {
const tree = restProved ? movingTargetRestTree(session.snapshot) : preActionTree;
const tree = restProved ? restTree(session.snapshot) : preActionTree;
if (!tree || tree.nodes.length === 0) {
retireMovingTargetBaseline(session);
return;
Expand Down
6 changes: 6 additions & 0 deletions packages/daemon/src/core/snapshot-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
normalizeRef,
SNAPSHOT_CAPTURE_OPTION_KEYS,
snapshotOptionsFromFlags,
type PresentedSurface,
type RawSnapshotNode,
type SnapshotCaptureProvenance,
type SnapshotKeyboardBandFact,
Expand Down Expand Up @@ -54,6 +55,11 @@ type SnapshotData = {
keyboard?: SnapshotKeyboardBandFact;
/** The box the capture's rects are measured in (#3182), carried to the state the response reads. */
viewport?: SnapshotViewportSize;
/**
* iOS: whether the capture read a modal presentation over the app (#3565), carried to the state
* settle's presented-surface confirmation arms on. Absent means the producer published no fact.
*/
presentedSurface?: PresentedSurface;
} & Omit<SnapshotCaptureAnnotations, 'quality'> &
SnapshotCaptureProvenance;

Expand Down
8 changes: 2 additions & 6 deletions packages/daemon/src/execution/generic-settle.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CommandFlags } from '@agent-device/contracts/command';
import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface';
import { surfaceScopedNodes } from '@agent-device/capture-kit/snapshot-baseline';
import type {
SettleObservation,
SettleParams,
Expand Down Expand Up @@ -84,12 +85,7 @@ export function planGenericSettleObservation(
const settle = readSettleRequest(params.flags);
if (!settle) return {};
const snapshot = params.session.snapshot;
const baseline: SurfaceScopedNodes = {
nodes: snapshot?.nodes ?? [],
...(snapshot?.iosSystemSurfaceBundleId
? { surfaceBundleId: snapshot.iosSystemSurfaceBundleId }
: {}),
};
const baseline = surfaceScopedNodes(snapshot);
return { observe: async () => await observeSettled(params, settle, baseline) };
}

Expand Down
Loading
Loading