Skip to content

Commit 1d5c754

Browse files
committed
fix: address resolution disclosure review findings
1 parent 74ee1ef commit 1d5c754

11 files changed

Lines changed: 204 additions & 45 deletions

File tree

src/commands/interaction/runtime/resolution.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ async function resolveRefInteractionTarget(
167167
node,
168168
capture.snapshot.nodes,
169169
params.action,
170-
EXACT_REF_RESOLUTION,
170+
resolved.resolution,
171171
),
172172
};
173173
}
@@ -251,16 +251,22 @@ const RESOLUTION_DIAGNOSTIC_STRING_BYTE_CAP = 256;
251251
const MAX_RESOLUTION_ALTERNATIVES = 5;
252252

253253
/**
254-
* ADR 0012 decision 2: an `@ref` names exactly one node by construction — the
255-
* runtime-ref path (via `describeResolvedInteractionNode`) and the native-ref
256-
* fast path (`interactions.ts`) both attach this same constant.
254+
* ADR 0012 decision 2: an `@ref` names exactly one node by construction when
255+
* the ref lookup succeeds. The native-ref fast path (`interactions.ts`) always
256+
* attaches this constant; runtime-ref can instead disclose label fallback.
257257
*/
258258
export const EXACT_REF_RESOLUTION: ResolutionDisclosure = {
259259
source: 'ref',
260260
phase: 'pre-action',
261261
kind: 'exact',
262262
};
263263

264+
const LABEL_FALLBACK_REF_RESOLUTION: ResolutionDisclosure = {
265+
source: 'ref',
266+
phase: 'pre-action',
267+
kind: 'label-fallback',
268+
};
269+
264270
const UNIQUE_RUNTIME_RESOLUTION: ResolutionDisclosure = {
265271
source: 'runtime',
266272
phase: 'pre-action',
@@ -467,7 +473,7 @@ async function resolveSnapshotForRef(
467473
runtime: AgentDeviceRuntime,
468474
options: CommandContext,
469475
target: Extract<InteractionTarget, { kind: 'ref' }>,
470-
): Promise<CapturedSnapshot & { resolved: { ref: string; node: SnapshotNode } }> {
476+
): Promise<CapturedSnapshot & { resolved: ResolvedRefNode }> {
471477
const sessionName = options.session ?? 'default';
472478
const session = await runtime.sessions.get(sessionName);
473479
if (!session) throw new AppError('SESSION_NOT_FOUND', 'No active session. Run open first.');
@@ -501,19 +507,27 @@ function tryResolveRefNode(
501507
options: {
502508
fallbackLabel: string;
503509
},
504-
): { ref: string; node: SnapshotNode } | null {
510+
): ResolvedRefNode | null {
505511
const ref = normalizeRef(refInput);
506512
if (!ref) throw new AppError('INVALID_ARGS', `Invalid ref: ${refInput}`);
507513
const refNode = findNodeByRef(nodes, ref);
508-
if (isUsableResolvedNode(refNode)) return { ref, node: refNode };
514+
if (isUsableResolvedNode(refNode)) {
515+
return { ref, node: refNode, resolution: EXACT_REF_RESOLUTION };
516+
}
509517
const fallbackNode =
510518
options.fallbackLabel.length > 0 ? findNodeByLabel(nodes, options.fallbackLabel) : null;
511519
if (isUsableResolvedNode(fallbackNode)) {
512-
return { ref, node: fallbackNode };
520+
return { ref, node: fallbackNode, resolution: LABEL_FALLBACK_REF_RESOLUTION };
513521
}
514522
return null;
515523
}
516524

525+
type ResolvedRefNode = {
526+
ref: string;
527+
node: SnapshotNode;
528+
resolution: ResolutionDisclosure;
529+
};
530+
517531
function resolveNodeCenter(node: SnapshotNode, message: string): Point {
518532
const point = resolveRectCenter(node.rect);
519533
if (!point) throw new AppError('COMMAND_FAILED', message);

src/contracts/interaction-guarantees.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,9 @@ export const INTERACTION_DISPATCH_PATHS: Record<InteractionPathId, InteractionPa
211211
kind: 'runtime',
212212
via: 'src/daemon/selectors-resolve.ts#STALE_REF_HINT',
213213
},
214-
// ADR 0012 decision 2: an @ref names exactly one node by construction.
214+
// ADR 0012 decision 2: a successful @ref lookup is exact. The replay
215+
// trailing-label recovery is explicitly disclosed as label-fallback,
216+
// rather than claiming exact ref provenance.
215217
resolutionDisclosure: {
216218
kind: 'runtime',
217219
via: 'src/commands/interaction/runtime/resolution.ts#EXACT_REF_RESOLUTION',

src/contracts/interaction.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@ export type ResolutionDiagnosticEntry = {
6565
* `matchCount` matches via the existing visible/deepest/smallest-area
6666
* heuristic; `alternatives` lists at most 5 losing candidates (the winner is
6767
* never included).
68-
* - `ref`/`exact`: an `@ref` names exactly one node by construction
69-
* (runtime-ref and native-ref paths).
68+
* - `ref`/`exact`: an `@ref` names exactly one node by construction.
69+
* - `ref`/`label-fallback`: a stale or unusable `@ref` was recovered with its
70+
* trailing replay label. This is first-match label lookup, never exact ref
71+
* provenance.
7072
* - `direct-ios`/`not-observed`: the direct iOS XCTest fast path has no
7173
* daemon tree and cannot truthfully report a match count or candidates.
7274
*/
@@ -82,6 +84,7 @@ export type ResolutionDisclosure =
8284
alternatives: ResolutionDiagnosticEntry[];
8385
}
8486
| { source: 'ref'; phase: 'pre-action'; kind: 'exact' }
87+
| { source: 'ref'; phase: 'pre-action'; kind: 'label-fallback' }
8588
| { source: 'direct-ios'; kind: 'not-observed' };
8689

8790
export type ResolvedInteractionTarget =

src/core/command-descriptor/__tests__/command-result.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type Equal<A, B> =
3333

3434
test('seeded CommandResult entries resolve to their existing contract result types', () => {
3535
const press: Equal<CommandResult<'press'>, PressCommandResult> = true;
36+
const click: Equal<CommandResult<'click'>, PressCommandResult> = true;
3637
const fill: Equal<CommandResult<'fill'>, FillCommandResult> = true;
3738
const longPress: Equal<CommandResult<'longpress'>, LongPressCommandResult> = true;
3839
const boot: Equal<CommandResult<'boot'>, BootCommandResult> = true;
@@ -55,6 +56,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ
5556
> = true;
5657
expect([
5758
press,
59+
click,
5860
fill,
5961
longPress,
6062
boot,
@@ -91,6 +93,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ
9193
true,
9294
true,
9395
true,
96+
true,
9497
]);
9598
});
9699

@@ -105,6 +108,7 @@ test('CommandResultMap is seeded only from already-existing contract result type
105108
const keys: Equal<
106109
keyof CommandResultMap,
107110
| 'press'
111+
| 'click'
108112
| 'fill'
109113
| 'longpress'
110114
| 'boot'

src/core/command-descriptor/command-result.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import type { TriggerAppEventCommandResult } from '../../contracts/app-events.ts
3232
*
3333
* Batches 1-2 wired `boot` / `shutdown` / `viewport` and the navigation/action
3434
* commands `home` / `back` / `rotate` / `app-switcher` alongside the seed
35-
* interaction trio. Batch 3 adds `clipboard` (a closed `read`/`write` union) and
35+
* interaction quartet. Batch 3 adds `clipboard` (a closed `read`/`write` union) and
3636
* `appstate` (a closed `platform` union — Apple session state with the iOS-only
3737
* device locators, or Android package/activity). Batch 4 adds `keyboard` (a
3838
* closed flat shape). Batch 5 adds the compact daemon projections for `wait`,
@@ -41,6 +41,7 @@ import type { TriggerAppEventCommandResult } from '../../contracts/app-events.ts
4141
*/
4242
export interface CommandResultMap {
4343
press: PressCommandResult;
44+
click: PressCommandResult;
4445
fill: FillCommandResult;
4546
longpress: LongPressCommandResult;
4647
boot: BootCommandResult;

src/daemon/__tests__/response-views.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ test('settle default and full return today’s shape unchanged (same reference)'
358358

359359
// --- ADR 0012 decision 2: resolution digest view ---
360360

361-
test('resolution digest drops alternatives but keeps matchCount/tiebreak/winnerDiagnostic', () => {
361+
test('resolution digest drops default-level alternatives but keeps schema-required disambiguation fields', () => {
362362
const data: DaemonResponseData = {
363363
ref: 'e2',
364364
resolution: {
@@ -382,7 +382,7 @@ test('resolution digest drops alternatives but keeps matchCount/tiebreak/winnerD
382382
});
383383
});
384384

385-
test('resolution digest leaves unique/exact/not-observed shapes unchanged (no alternatives to drop)', () => {
385+
test('resolution digest leaves unique/ref/not-observed shapes unchanged (no alternatives to drop)', () => {
386386
const unique: DaemonResponseData = {
387387
resolution: { source: 'runtime', phase: 'pre-action', kind: 'unique' },
388388
};
@@ -393,6 +393,11 @@ test('resolution digest leaves unique/exact/not-observed shapes unchanged (no al
393393
};
394394
expect(RESPONSE_VIEWS.press!(exact, 'digest')).toBe(exact);
395395

396+
const labelFallback: DaemonResponseData = {
397+
resolution: { source: 'ref', phase: 'pre-action', kind: 'label-fallback' },
398+
};
399+
expect(RESPONSE_VIEWS.press!(labelFallback, 'digest')).toBe(labelFallback);
400+
396401
const notObserved: DaemonResponseData = {
397402
resolution: { source: 'direct-ios', kind: 'not-observed' },
398403
};

src/daemon/__tests__/selectors.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,67 @@ test('resolveSelectorChain disambiguation prefers on-screen candidates over off-
222222
);
223223
});
224224

225+
test('resolveSelectorChain discloses the winner versus its closest challenger, not the first comparison', () => {
226+
// e3 is the first losing candidate, so the previous side channel recorded
227+
// `visible`. e4 is the strongest loser, though: it is also visible, making
228+
// e2's extra depth the actual deciding criterion regardless of document order.
229+
const nodes: SnapshotState['nodes'] = [
230+
{
231+
ref: 'e1',
232+
index: 0,
233+
type: 'Application',
234+
rect: { x: 0, y: 0, width: 400, height: 800 },
235+
depth: 0,
236+
enabled: true,
237+
hittable: true,
238+
},
239+
{
240+
ref: 'e2',
241+
index: 1,
242+
parentIndex: 0,
243+
type: 'Button',
244+
label: 'Profile',
245+
rect: { x: 20, y: 700, width: 200, height: 50 },
246+
depth: 3,
247+
enabled: true,
248+
hittable: true,
249+
},
250+
{
251+
ref: 'e3',
252+
index: 2,
253+
parentIndex: 0,
254+
type: 'Button',
255+
label: 'Profile',
256+
rect: { x: -320, y: 240, width: 100, height: 20 },
257+
depth: 8,
258+
enabled: true,
259+
hittable: false,
260+
},
261+
{
262+
ref: 'e4',
263+
index: 3,
264+
parentIndex: 0,
265+
type: 'Button',
266+
label: 'Profile',
267+
rect: { x: 20, y: 620, width: 200, height: 50 },
268+
depth: 2,
269+
enabled: true,
270+
hittable: true,
271+
},
272+
];
273+
274+
const resolved = resolveSelectorChain(nodes, parseSelectorChain('label="Profile"'), {
275+
platform: 'ios',
276+
requireRect: true,
277+
requireUnique: true,
278+
disambiguateAmbiguous: true,
279+
});
280+
281+
assert.ok(resolved);
282+
assert.equal(resolved.node.ref, 'e2');
283+
assert.equal(resolved.disambiguation?.tiebreak, 'deepest');
284+
});
285+
225286
test('resolveSelectorChain disambiguation treats items inside an off-screen scroll container as off-screen', () => {
226287
// The closed drawer carries its own ScrollView at negative x. Visibility
227288
// relative to that (off-screen) container is not enough — the drawer item

src/daemon/handlers/__tests__/interaction.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2637,6 +2637,11 @@ test('press @ref fallback label is used after refresh when ref bounds remain inv
26372637
if (response?.ok) {
26382638
expect(response.data?.x).toBe(140);
26392639
expect(response.data?.y).toBe(220);
2640+
expect(response.data?.resolution).toEqual({
2641+
source: 'ref',
2642+
phase: 'pre-action',
2643+
kind: 'label-fallback',
2644+
});
26402645
}
26412646
});
26422647

src/daemon/selectors-resolve.ts

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,6 @@ type DisambiguationState = {
135135
best: SnapshotNode | null;
136136
bestVisible: boolean;
137137
tie: boolean;
138-
// ADR 0012 decision 2: the criterion that most recently distinguished the
139-
// current best from a challenger, win or lose — a side channel only. It
140-
// never feeds back into `best`/`tie`, so the winner selection this state
141-
// machine already made is unchanged.
142-
tiebreak: DisambiguationTiebreak | null;
143138
};
144139

145140
function analyzeSelectorMatches(
@@ -159,7 +154,7 @@ function analyzeSelectorMatches(
159154
let count = 0;
160155
let firstNode: SnapshotNode | null = null;
161156
const candidates: SnapshotNode[] = [];
162-
const state: DisambiguationState = { best: null, bestVisible: false, tie: false, tiebreak: null };
157+
const state: DisambiguationState = { best: null, bestVisible: false, tie: false };
163158
// Lazily built: only ambiguous matches pay for viewport inference.
164159
let byIndex: Map<number, SnapshotNode> | undefined;
165160
const isVisible = (node: SnapshotNode): boolean => {
@@ -178,7 +173,7 @@ function analyzeSelectorMatches(
178173
count,
179174
firstNode,
180175
disambiguated: state.tie ? null : state.best,
181-
tiebreak: state.tie ? null : state.tiebreak,
176+
tiebreak: state.tie ? null : findDecidingTiebreak(candidates, state.best, isVisible),
182177
candidates,
183178
};
184179
}
@@ -204,27 +199,53 @@ function accumulateDisambiguationCandidate(
204199
state.best = node;
205200
state.bestVisible = true;
206201
state.tie = false;
207-
state.tiebreak = 'visible';
208-
} else {
209-
// The current best already wins on visibility; record that fact only if
210-
// no criterion has decided yet — a later, closer comparison (equal
211-
// visibility, decided by depth/area) is the more specific explanation.
212-
state.tiebreak ??= 'visible';
213202
}
214203
return;
215204
}
216205
const comparison = compareDisambiguationCandidates(node, state.best);
217206
if (comparison.result > 0) {
218207
state.best = node;
219208
state.tie = false;
220-
state.tiebreak = comparison.criterion;
221209
} else if (comparison.result === 0) {
222210
state.tie = true;
223-
} else {
224-
state.tiebreak ??= comparison.criterion;
225211
}
226212
}
227213

214+
/**
215+
* The resolution winner is deliberately still picked by the existing running
216+
* comparator above. For disclosure, compare that winner with its best losing
217+
* challenger rather than leaking the first document-order comparison that
218+
* happened to set state. This is a side channel only.
219+
*/
220+
function findDecidingTiebreak(
221+
candidates: readonly SnapshotNode[],
222+
winner: SnapshotNode | null,
223+
isVisible: (node: SnapshotNode) => boolean,
224+
): DisambiguationTiebreak | null {
225+
if (!winner) return null;
226+
let runnerUp: SnapshotNode | null = null;
227+
for (const candidate of candidates) {
228+
if (candidate === winner) continue;
229+
if (!runnerUp || compareCandidatesWithVisibility(candidate, runnerUp, isVisible).result > 0) {
230+
runnerUp = candidate;
231+
}
232+
}
233+
return runnerUp ? compareCandidatesWithVisibility(winner, runnerUp, isVisible).criterion : null;
234+
}
235+
236+
function compareCandidatesWithVisibility(
237+
a: SnapshotNode,
238+
b: SnapshotNode,
239+
isVisible: (node: SnapshotNode) => boolean,
240+
): DisambiguationComparison {
241+
const visibleA = isVisible(a);
242+
const visibleB = isVisible(b);
243+
if (visibleA !== visibleB) {
244+
return { result: visibleA ? 1 : -1, criterion: 'visible' };
245+
}
246+
return compareDisambiguationCandidates(a, b);
247+
}
248+
228249
function countSelectorMatchesOnly(
229250
nodes: SnapshotState['nodes'],
230251
selector: Selector,

src/mcp/__tests__/command-tools.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,36 @@ test('MCP newly typed outputSchemas advertise public contract keys', () => {
293293
);
294294
});
295295

296+
test('MCP click outputSchema validates default and digest resolution disclosures', () => {
297+
const click = listCommandTools().find((tool) => tool.name === 'click');
298+
assert.ok(click?.outputSchema);
299+
assert.equal(click.outputSchema, COMMAND_OUTPUT_SCHEMAS.click);
300+
301+
const resolutionSchema = click.outputSchema.properties?.resolution;
302+
const disambiguated = resolutionSchema?.oneOf?.find(
303+
(branch) =>
304+
(branch.properties?.kind as { const?: unknown } | undefined)?.const === 'disambiguated',
305+
);
306+
assert.ok(disambiguated);
307+
308+
const digestResolution = {
309+
source: 'runtime',
310+
phase: 'pre-action',
311+
kind: 'disambiguated',
312+
matchCount: 2,
313+
winnerDiagnostic: { diagnosticRef: 'diag-e2' },
314+
tiebreak: 'deepest',
315+
};
316+
for (const required of disambiguated.required ?? []) {
317+
assert.ok(
318+
required in digestResolution,
319+
`digest resolution is missing required key: ${required}`,
320+
);
321+
}
322+
// The verbose default/full response still exposes the bounded candidate list.
323+
assert.ok('alternatives' in (disambiguated.properties ?? {}));
324+
});
325+
296326
test('MCP prepare outputSchema stays complete for the typed non-exposed command', () => {
297327
const prepareSchema = COMMAND_OUTPUT_SCHEMAS.prepare;
298328
assert.ok(prepareSchema.required?.includes('runner'));

0 commit comments

Comments
 (0)