Skip to content

Commit 36fdde3

Browse files
committed
fix(ios): keep surface identity in verify and settle comparisons
`--verify` compared node digests and `--settle` diffed node-only baselines, so an app baseline and an in-place system-surface capture (a web sign-in sheet) were treated as one presentation: a meaningless changed verdict, and a whole-surface replacement presented as an in-surface diff with refs. The pre-action baseline now travels with the surface its capture described, from the resolution and the session frame through to the settled capture, and one module owns the comparison for both routes. Across a surface change no same-surface claim is made: evidence reports the transition instead of a digest comparison, the settled diff and its refs are withheld, and both payloads disclose the transition.
1 parent 2200150 commit 36fdde3

12 files changed

Lines changed: 628 additions & 84 deletions

File tree

packages/contracts/src/interaction.ts

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,15 +121,35 @@ export type PreresolvedInteractionTarget = {
121121
node: SnapshotNode;
122122
/** The tree `node` came from — the guards read its siblings for occlusion/viewport. */
123123
nodes: SnapshotNode[];
124+
/**
125+
* The in-place iOS system surface `nodes` describes (#2438), absent for ordinary app content.
126+
* Travels with the tree so the adopting consumer's post-action comparison knows which surface
127+
* its baseline came from.
128+
*/
129+
iosSystemSurfaceBundleId?: string;
130+
};
131+
132+
/**
133+
* The pre-action tree a post-action observation compares against, and the SURFACE the capture it
134+
* came from described (#2438: the bundle id of an in-place iOS system surface such as a web
135+
* sign-in sheet, absent for ordinary app content).
136+
*
137+
* One value, never two channels: a capture of the sheet and a capture of the app describe
138+
* different surfaces, so a `--verify` digest comparison or a `--settle` diff across that boundary
139+
* is not about one presentation. The identity is attached where the nodes are taken, so every
140+
* comparison site can ask the question.
141+
*/
142+
type PreActionBaselineFields = {
143+
preActionNodes?: SnapshotNode[];
144+
preActionSurfaceBundleId?: string;
124145
};
125146

126147
export type ResolvedInteractionTarget =
127-
| {
148+
| ({
128149
kind: 'point';
129150
point: Point;
130-
preActionNodes?: SnapshotNode[];
131-
}
132-
| {
151+
} & PreActionBaselineFields)
152+
| ({
133153
kind: 'ref';
134154
point?: Point;
135155
target: Extract<ResolvedTarget, { kind: 'ref' }>;
@@ -138,11 +158,10 @@ export type ResolvedInteractionTarget =
138158
refLabel?: string;
139159
targetHittable?: boolean;
140160
hint?: string;
141-
preActionNodes?: SnapshotNode[];
142161
resolution?: ResolutionDisclosure;
143162
recordingTarget?: RecordingTargetOverride;
144-
}
145-
| {
163+
} & PreActionBaselineFields)
164+
| ({
146165
kind: 'selector';
147166
point: Point;
148167
target: Extract<ResolvedTarget, { kind: 'selector' }>;
@@ -151,10 +170,26 @@ export type ResolvedInteractionTarget =
151170
refLabel?: string;
152171
targetHittable?: boolean;
153172
hint?: string;
154-
preActionNodes?: SnapshotNode[];
155173
resolution?: ResolutionDisclosure;
156174
recordingTarget?: RecordingTargetOverride;
157-
};
175+
} & PreActionBaselineFields);
176+
177+
/**
178+
* A post-action capture that describes a DIFFERENT surface than the pre-action baseline (#2438): an
179+
* in-place iOS system surface (a web sign-in sheet, hosted out of the app's process) was presented
180+
* over the app, or left it. `from`/`to` name the two surfaces — a host bundle id, or `app` for
181+
* ordinary app content.
182+
*
183+
* Its presence IS the refusal of a same-surface claim: the two captures are not one presentation,
184+
* so `--verify` reports `changedFromBefore` from this transition instead of from a digest
185+
* comparison across it, and `--settle` attaches no settled diff (and therefore no refs) across it.
186+
*/
187+
export type PostActionSurfaceChange = {
188+
from: string;
189+
to: string;
190+
/** The one agent-facing sentence for this transition (`@agent-device/contracts/ios-system-surface`). */
191+
disclosure: string;
192+
};
158193

159194
/**
160195
* Opt-in (`--verify`) cheap post-condition evidence for mutating interaction
@@ -164,13 +199,18 @@ export type ResolvedInteractionTarget =
164199
* held, so no extra device round trip is spent beyond the one verify capture.
165200
* `changedFromBefore: false` is evidence, not failure — the command still
166201
* succeeded.
202+
*
203+
* When `surfaceChange` is present the two captures describe different surfaces, so the digest
204+
* comparison is not made at all: `changedFromBefore` then reports that transition, which replaced
205+
* the whole observed surface.
167206
*/
168207
export type InteractionEvidence = {
169208
foregroundApp?: string;
170209
nodeCount: number;
171210
interactiveNodeCount: number;
172211
digest: string;
173212
changedFromBefore: boolean;
213+
surfaceChange?: PostActionSurfaceChange;
174214
};
175215

176216
export type SettleDiffLine = {
@@ -249,7 +289,17 @@ export type SettleObservation = {
249289
* intentionally omitted.
250290
*/
251291
refs?: Array<{ ref: string }>;
252-
/** Present only for `settled: true` observations that stored the settled tree. */
292+
/**
293+
* Present when the settled capture describes a different surface than the pre-action baseline
294+
* (#2438). The settled tree then replaced the whole surface rather than changing within one, so
295+
* `diff` is omitted: its lines (and their refs) would present a surface replacement as an
296+
* in-surface change. `hint` says what to do instead.
297+
*/
298+
surfaceChange?: PostActionSurfaceChange;
299+
/**
300+
* Present only for `settled: true` observations that stored the settled tree, and never across a
301+
* `surfaceChange` — a diff describes change WITHIN one surface.
302+
*/
253303
diff?: {
254304
summary: { additions: number; removals: number; unchanged: number };
255305
lines: SettleDiffLine[];

packages/contracts/src/ios-system-surface.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,16 @@ export function iosSystemSurfaceDisclosure(
7979
): string | undefined {
8080
return provenance ? IOS_SYSTEM_SURFACE_DISCLOSURE : undefined;
8181
}
82+
83+
/**
84+
* The agent-facing sentence for a surface TRANSITION between two captures — the post-action
85+
* observation's case, where the pre-action baseline and the capture taken after the action describe
86+
* different surfaces. `to` is the surface the AFTER capture describes: a host bundle id when the
87+
* sheet is now on screen (the standing disclosure applies verbatim), or `undefined` when the sheet
88+
* has left and the capture shows app content again, which the standing sentence cannot say.
89+
*/
90+
export function iosSystemSurfaceTransitionDisclosure(to: string | undefined): string {
91+
return to === undefined
92+
? 'A system web sign-in sheet was presented over the app before this action and is gone now, so this observation describes app content while the pre-action tree described that sheet.'
93+
: IOS_SYSTEM_SURFACE_DISCLOSURE;
94+
}

src/commands/interaction/runtime/interactions.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import {
88
createMemorySessionStore,
99
localCommandPolicy,
1010
} from '../../../runtime.ts';
11-
import type { Point } from '@agent-device/kernel/snapshot';
11+
import type { Point, SnapshotState } from '@agent-device/kernel/snapshot';
12+
import { summarizeAxEvidence } from '@agent-device/capture-kit/snapshot-evidence';
13+
import { IOS_SYSTEM_SURFACE_DISCLOSURE } from '@agent-device/contracts/ios-system-surface';
1214
import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures';
1315
import {
1416
coveredByTabBarSnapshot,
@@ -491,6 +493,92 @@ test('runtime press with verify reports changedFromBefore true when the post-act
491493
assert.equal(result.evidence?.nodeCount, 2);
492494
});
493495

496+
// #2438: iOS serves a web sign-in sheet (com.apple.SafariViewService) IN PLACE over a
497+
// still-foreground app, so a capture of the sheet and a capture of the app describe DIFFERENT
498+
// surfaces. `--verify` must not answer "did this change?" by comparing their node digests.
499+
const WEB_SIGN_IN_SHEET_BUNDLE_ID = 'com.apple.SafariViewService';
500+
501+
function webSignInSheetSnapshot(labels: string[]): SnapshotState {
502+
return {
503+
...makeSnapshotState(
504+
labels.map((label, index) => ({
505+
index,
506+
depth: 0,
507+
type: 'Button',
508+
label,
509+
rect: { x: 10, y: 20 + index * 50, width: 200, height: 40 },
510+
hittable: true,
511+
})),
512+
),
513+
iosSystemSurfaceBundleId: WEB_SIGN_IN_SHEET_BUNDLE_ID,
514+
};
515+
}
516+
517+
test('runtime press with verify discloses the surface change when a sign-in sheet replaces the app', async () => {
518+
let captureCount = 0;
519+
const device = createInteractionDevice(selectorSnapshot(), {
520+
captureSnapshot: async () => {
521+
captureCount += 1;
522+
// Capture 1 resolves the target against app content; capture 2 is the verify capture, taken
523+
// after the sheet was presented in place over that app.
524+
return {
525+
snapshot:
526+
captureCount === 1
527+
? selectorSnapshot()
528+
: webSignInSheetSnapshot(['Sign in with Example', 'Cancel']),
529+
};
530+
},
531+
tap: async () => ({ ok: true }),
532+
});
533+
534+
const result = await device.interactions.press(selector('label=Continue'), {
535+
session: 'default',
536+
verify: true,
537+
});
538+
539+
assert.equal(result.kind, 'selector');
540+
assert.deepEqual(result.evidence?.surfaceChange, {
541+
from: 'app',
542+
to: WEB_SIGN_IN_SHEET_BUNDLE_ID,
543+
disclosure: IOS_SYSTEM_SURFACE_DISCLOSURE,
544+
});
545+
// The transition is what changed, not a digest comparison between two different surfaces.
546+
assert.equal(result.evidence?.changedFromBefore, true);
547+
});
548+
549+
test('runtime press with verify reports the sheet leaving even when the two digests coincide', async () => {
550+
const sheet: SnapshotState = {
551+
...selectorSnapshot(),
552+
iosSystemSurfaceBundleId: WEB_SIGN_IN_SHEET_BUNDLE_ID,
553+
};
554+
const app = selectorSnapshot();
555+
// The premise this test exists for: the sheet tree and the app tree that replaces it digest
556+
// identically, so a digest comparison would report "nothing changed" across a whole-surface
557+
// replacement. The surfaces, not the digests, answer the question.
558+
assert.equal(summarizeAxEvidence(sheet.nodes).digest, summarizeAxEvidence(app.nodes).digest);
559+
let captureCount = 0;
560+
const device = createInteractionDevice(sheet, {
561+
captureSnapshot: async () => {
562+
captureCount += 1;
563+
return { snapshot: captureCount === 1 ? sheet : app };
564+
},
565+
tap: async () => ({ ok: true }),
566+
});
567+
568+
const result = await device.interactions.press(selector('label=Continue'), {
569+
session: 'default',
570+
verify: true,
571+
});
572+
573+
assert.equal(result.kind, 'selector');
574+
assert.equal(result.evidence?.surfaceChange?.from, WEB_SIGN_IN_SHEET_BUNDLE_ID);
575+
assert.equal(result.evidence?.surfaceChange?.to, 'app');
576+
assert.match(result.evidence?.surfaceChange?.disclosure ?? '', /sign-in sheet/);
577+
// The sheet is gone, so the standing "is presented over the app" sentence cannot be the one used.
578+
assert.notEqual(result.evidence?.surfaceChange?.disclosure, IOS_SYSTEM_SURFACE_DISCLOSURE);
579+
assert.equal(result.evidence?.changedFromBefore, true);
580+
});
581+
494582
test('runtime fill without verify omits evidence entirely', async () => {
495583
const device = createInteractionDevice(fillableSnapshot(), {
496584
fill: async () => ({ ok: true }),

src/commands/interaction/runtime/post-action-observation.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
21
import type { AgentDeviceRuntime, CommandContext } from '../../../runtime-contract.ts';
3-
import { summarizeAxEvidence } from '@agent-device/capture-kit/snapshot-evidence';
42
import type {
53
InteractionEvidence,
64
ResolvedInteractionTarget,
75
SettleObservation,
86
SettleParams,
97
} from '@agent-device/contracts/interaction';
108
import { captureInteractionSnapshot } from './resolution.ts';
9+
import {
10+
preActionBaseline,
11+
summarizePostActionEvidence,
12+
surfaceScopedNodes,
13+
} from './post-action-surface.ts';
1114
import { settleAfterInteraction, settleEvidence } from './settle.ts';
1215

1316
type ObservedResult<T extends object> = T & {
@@ -82,10 +85,7 @@ async function observeAfterInteraction(
8285
resolved,
8386
});
8487
const evidence = params.verify
85-
? settleEvidence(
86-
outcome.settledNodes,
87-
'preActionNodes' in resolved ? resolved.preActionNodes : undefined,
88-
)
88+
? settleEvidence(outcome.settledCapture, preActionBaseline(resolved))
8989
: undefined;
9090
return { settle: outcome.observation, ...(evidence ? { evidence } : {}) };
9191
}
@@ -99,22 +99,24 @@ async function observeAfterInteraction(
9999
* the same capture helper the resolution path already uses, digested and then
100100
* discarded. The node tree itself is never attached to the result, only the
101101
* cheap summary.
102+
*
103+
* Both sides of the comparison carry the surface they describe (#2438), so a
104+
* capture of an in-place system surface is never digest-compared against an app
105+
* baseline: `summarizePostActionEvidence` owns that rule for this route and the
106+
* `--settle --verify` route alike. A missing baseline still yields
107+
* `changedFromBefore: false` — no baseline, no claim.
102108
*/
103109
async function captureVerifyEvidence(
104110
runtime: AgentDeviceRuntime,
105111
options: CommandContext,
106112
resolved: ResolvedInteractionTarget,
107113
): Promise<InteractionEvidence | undefined> {
108-
const preActionNodes: SnapshotNode[] | undefined =
109-
'preActionNodes' in resolved ? resolved.preActionNodes : undefined;
110114
try {
111115
const capture = await captureInteractionSnapshot(runtime, options, true);
112-
const after = summarizeAxEvidence(capture.snapshot.nodes);
113-
// No pre-action baseline means we cannot claim a change happened; default
114-
// to false rather than asserting a change we did not actually observe.
115-
const changedFromBefore =
116-
preActionNodes !== undefined && after.digest !== summarizeAxEvidence(preActionNodes).digest;
117-
return { ...after, changedFromBefore };
116+
return summarizePostActionEvidence(
117+
surfaceScopedNodes(capture.snapshot),
118+
preActionBaseline(resolved),
119+
);
118120
} catch {
119121
return undefined;
120122
}
@@ -150,6 +152,9 @@ function hasMaterialPostActionChange(view: {
150152
settle?: SettleObservation;
151153
}): boolean {
152154
if (view.evidence?.changedFromBefore === true) return true;
155+
// A surface replacement (#2438) carries no settled diff by design, and it is the most material
156+
// post-action change there is: the screen is now a different surface.
157+
if (view.settle?.surfaceChange !== undefined) return true;
153158
const summary = view.settle?.diff?.summary;
154159
return !!summary && (summary.additions > 0 || summary.removals > 0);
155160
}

0 commit comments

Comments
 (0)