From 10d511c2831c449b93310fe0a8d8d137d10b953e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 6 Aug 2026 16:11:58 +0200 Subject: [PATCH 1/6] refactor: declare selector resolution policy as data (#1630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five native consumers of "resolve a selector against the screen" each hand-declared their ambiguity contract as inline requireUnique/ disambiguateAmbiguous literals, so the repo's real policy matrix was only discoverable by reading four files. SELECTOR_RESOLUTION_POLICIES (packages/selectors) now declares one row per caller — ambiguity kind plus the structural columns (rect, occlusion, off-screen guard, promotion, poll) — and selectorResolutionKnobs turns a row into the engine knobs it stands for. Callers consume rows; zero ambiguity literals remain in src. Semantics are unchanged by construction: each row was read off its call site. The matrix names what was previously implicit — act and get text disambiguate, is/get attrs fail closed, exists/find-reads and wait take the first match, mutating find rejects candidates unless narrowed (#1625). `reject-candidates` is declaration-only and rejected by selectorResolutionKnobs at the type level, because find enforces it through its own narrowing rather than engine knobs. resolution-policy-parity.test.ts gate-tests the matrix against the callers (ADR 0011's declared-plus-gate-tested pattern): knobs must match the named ambiguity contract, every claimed structural column must appear in the caller's source, the read/wait pipelines must genuinely lack the machinery they disclaim, and no caller may reintroduce an inline literal. Verified revert-sensitive: flipping readUnique to disambiguate and faking wait's occlusion column each fail it. Out of scope, unchanged, per the issue: the Maestro engine (ADR 0015) and the open click-implicit-wait product decision. --- packages/selectors/src/index.ts | 8 ++ .../src/internal/resolution-policy.ts | 130 +++++++++++++++++ .../resolution-policy-parity.test.ts | 135 ++++++++++++++++++ .../interaction/runtime/resolution.ts | 13 +- .../interaction/runtime/selector-read.ts | 26 ++-- src/daemon/handlers/find.ts | 6 +- 6 files changed, 298 insertions(+), 20 deletions(-) create mode 100644 packages/selectors/src/internal/resolution-policy.ts create mode 100644 src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts diff --git a/packages/selectors/src/index.ts b/packages/selectors/src/index.ts index ffabbe117..e33b94fb0 100644 --- a/packages/selectors/src/index.ts +++ b/packages/selectors/src/index.ts @@ -259,3 +259,11 @@ function resolveSelectorChain( const result = resolveSelectorChainAst(nodes, parseSelectorChain(expression), options); return result ? { ...result, selector: result.selector.raw } : null; } +export { + SELECTOR_RESOLUTION_POLICIES, + selectorResolutionKnobs, +} from './internal/resolution-policy.ts'; +export type { + KnobBackedSelectorAmbiguity, + SelectorResolutionPolicy, +} from './internal/resolution-policy.ts'; diff --git a/packages/selectors/src/internal/resolution-policy.ts b/packages/selectors/src/internal/resolution-policy.ts new file mode 100644 index 000000000..b40619218 --- /dev/null +++ b/packages/selectors/src/internal/resolution-policy.ts @@ -0,0 +1,130 @@ +import type { SelectorResolutionOptions } from './public-resolution-types.ts'; + +/** + * The per-caller selector-resolution policy matrix (#1630): every native + * consumer of "resolve a selector against the screen" declares its ambiguity + * contract here instead of passing `requireUnique`/`disambiguateAmbiguous` + * literals at the call site. The engine stays policy-neutral; which row a + * caller consumes IS the caller's documented contract, and changing a row is + * a reviewable one-line policy change instead of a multi-file literal hunt. + * + * Ambiguity kinds: + * - `disambiguate` — unique match required, but the engine's visible→deepest→ + * smallest-area tiebreak may pick a winner from an ambiguous set (acting + * commands, `get text`). + * - `fail-closed` — unique match required, ties reject (by design: `is` + * predicates and `get attrs` must never guess). + * - `first-match` — any match count accepted, first wins (existence reads and + * the wait loop, where presence is the question). + * - `reject-candidates` — multiple matches reject with the candidate list + * unless the caller explicitly narrows (#1625's mutating-find contract). + * Declaration-only: enforced by find's own narrowing logic, not by engine + * knobs, so `selectorResolutionKnobs` rejects it at the type level. + * + * The structural columns (`occlusion`, `offscreenGuard`, `promotion`, `poll`) + * document which pipeline hosts each policy; the pipelines live in the + * callers, and resolution-policy-parity.test.ts gate-tests these claims + * against the callers' actual imports so the matrix cannot drift into + * fiction (the ADR 0011 declared-plus-gate-tested pattern). + */ + +export type KnobBackedSelectorAmbiguity = 'disambiguate' | 'fail-closed' | 'first-match'; +export type SelectorAmbiguityPolicy = KnobBackedSelectorAmbiguity | 'reject-candidates'; + +export type SelectorResolutionPolicy = { + ambiguity: SelectorAmbiguityPolicy; + /** Only nodes carrying a rect participate (acting paths need a tap point). */ + requireRect: boolean; + /** Occlusion filtering / covered-target rejection runs in this pipeline. */ + occlusion: boolean; + /** The winner is checked against the viewport (with the iOS rescue probe). */ + offscreenGuard: boolean; + /** Hittable-ancestor promotion may move the dispatch point. */ + promotion: boolean; + /** Single capture (`none`) or the wait loop's poll budget. */ + poll: 'none' | 'wait-budget'; +}; + +export const SELECTOR_RESOLUTION_POLICIES = { + /** click/press/fill/focus/longPress/drag/scroll targets (resolution.ts). */ + act: { + ambiguity: 'disambiguate', + requireRect: true, + occlusion: true, + offscreenGuard: true, + promotion: true, + poll: 'none', + }, + /** The post-miss diagnosis probe deciding "no match" vs "matched but covered". */ + actCoveredDiagnosis: { + ambiguity: 'first-match', + requireRect: true, + occlusion: true, + offscreenGuard: false, + promotion: false, + poll: 'none', + }, + /** `get text` — reads through the same tiebreak acting uses. */ + readText: { + ambiguity: 'disambiguate', + requireRect: false, + occlusion: false, + offscreenGuard: false, + promotion: false, + poll: 'none', + }, + /** `is` non-exists predicates and `get attrs` — ties reject, never guess. */ + readUnique: { + ambiguity: 'fail-closed', + requireRect: false, + occlusion: false, + offscreenGuard: false, + promotion: false, + poll: 'none', + }, + /** `exists` and find's read-only actions — presence is the question. */ + readAny: { + ambiguity: 'first-match', + requireRect: false, + occlusion: false, + offscreenGuard: false, + promotion: false, + poll: 'none', + }, + /** `wait` — first match per poll, under the wait budget. */ + wait: { + ambiguity: 'first-match', + requireRect: false, + occlusion: false, + offscreenGuard: false, + promotion: false, + poll: 'wait-budget', + }, + /** Mutating `find` (#1625): candidates reject unless explicitly narrowed. */ + findAct: { + ambiguity: 'reject-candidates', + requireRect: true, + occlusion: true, + offscreenGuard: false, + promotion: true, + poll: 'none', + }, +} as const satisfies Record; + +/** + * The engine knobs a knob-backed policy row stands for. `reject-candidates` + * rows are rejected at the type level — that contract is enforced by the + * caller's narrowing logic, not by these knobs. + */ +export function selectorResolutionKnobs( + policy: SelectorResolutionPolicy & { ambiguity: KnobBackedSelectorAmbiguity }, +): Pick { + if (policy.ambiguity === 'first-match') { + return { requireRect: policy.requireRect, requireUnique: false }; + } + return { + requireRect: policy.requireRect, + requireUnique: true, + disambiguateAmbiguous: policy.ambiguity === 'disambiguate', + }; +} diff --git a/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts b/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts new file mode 100644 index 000000000..8841c01e7 --- /dev/null +++ b/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { test } from 'vitest'; +import { + SELECTOR_RESOLUTION_POLICIES, + selectorResolutionKnobs, + type SelectorResolutionPolicy, +} from '@agent-device/selectors'; + +/** + * The matrix in resolution-policy.ts is a claim about the callers, so it is + * gate-tested against them rather than trusted (ADR 0011's declared-plus- + * gate-tested pattern). Two failure modes this catches: a policy row whose + * knobs stop matching the ambiguity contract it names, and a structural + * column (occlusion / off-screen / promotion / poll) drifting into fiction + * because a caller stopped importing the machinery the row advertises. + */ + +const REPO_SRC = path.resolve(import.meta.dirname, '../../../..'); + +function readSource(relative: string): string { + return readFileSync(path.join(REPO_SRC, relative), 'utf8'); +} + +const CALLERS = { + act: 'commands/interaction/runtime/resolution.ts', + actCoveredDiagnosis: 'commands/interaction/runtime/resolution.ts', + readText: 'commands/interaction/runtime/selector-read.ts', + readUnique: 'commands/interaction/runtime/selector-read.ts', + readAny: 'commands/interaction/runtime/selector-read.ts', + wait: 'commands/interaction/runtime/selector-wait.ts', + findAct: 'daemon/handlers/find.ts', +} as const satisfies Record; + +// What each structural column means in caller source, so the claim is +// checkable rather than decorative. +const OCCLUSION_MARKERS = ['isSnapshotNodeInteractionBlocked', 'interactableSelectorNodes']; +const OFFSCREEN_MARKERS = ['throwIfOffscreenInteractionTarget', 'assertVisibleSelectorTarget']; +const PROMOTION_MARKERS = ['resolveActionableTouchNode', 'resolveActionableNodeOrThrow']; +const POLL_MARKERS = ['createWaitPolling']; + +function mentionsAny(source: string, markers: readonly string[]): boolean { + return markers.some((marker) => source.includes(marker)); +} + +test('every policy row names a real caller', () => { + assert.deepEqual(Object.keys(SELECTOR_RESOLUTION_POLICIES).sort(), Object.keys(CALLERS).sort()); +}); + +test('knobs match the ambiguity contract each row names', () => { + const expected: Record> = { + disambiguate: { requireRect: false, requireUnique: true, disambiguateAmbiguous: true }, + 'fail-closed': { requireRect: false, requireUnique: true, disambiguateAmbiguous: false }, + 'first-match': { requireRect: false, requireUnique: false }, + }; + for (const [name, policy] of Object.entries(SELECTOR_RESOLUTION_POLICIES)) { + if (policy.ambiguity === 'reject-candidates') continue; + const knobs = selectorResolutionKnobs(policy); + const want = { ...expected[policy.ambiguity], requireRect: policy.requireRect }; + assert.deepEqual(knobs, want, name); + } +}); + +test('acting policies require a rect; read and wait policies do not', () => { + assert.equal(SELECTOR_RESOLUTION_POLICIES.act.requireRect, true); + assert.equal(SELECTOR_RESOLUTION_POLICIES.findAct.requireRect, true); + assert.equal(SELECTOR_RESOLUTION_POLICIES.readUnique.requireRect, false); + assert.equal(SELECTOR_RESOLUTION_POLICIES.readAny.requireRect, false); + assert.equal(SELECTOR_RESOLUTION_POLICIES.wait.requireRect, false); +}); + +test('is/get-attrs fail closed and wait never disambiguates (the by-design asymmetry)', () => { + assert.equal(SELECTOR_RESOLUTION_POLICIES.readUnique.ambiguity, 'fail-closed'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.readAny.ambiguity, 'first-match'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.wait.ambiguity, 'first-match'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.act.ambiguity, 'disambiguate'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.readText.ambiguity, 'disambiguate'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.findAct.ambiguity, 'reject-candidates'); +}); + +test('structural columns match what each caller actually imports', () => { + const sources = new Map(); + for (const relative of Object.values(CALLERS)) { + if (!sources.has(relative)) sources.set(relative, readSource(relative)); + } + for (const [name, relative] of Object.entries(CALLERS)) { + const policy: SelectorResolutionPolicy = + SELECTOR_RESOLUTION_POLICIES[name as keyof typeof SELECTOR_RESOLUTION_POLICIES]; + const source = sources.get(relative)!; + if (policy.occlusion) { + assert.ok(mentionsAny(source, OCCLUSION_MARKERS), `${name} claims occlusion`); + } + if (policy.offscreenGuard) { + assert.ok(mentionsAny(source, OFFSCREEN_MARKERS), `${name} claims an off-screen guard`); + } + if (policy.promotion) { + assert.ok(mentionsAny(source, PROMOTION_MARKERS), `${name} claims promotion`); + } + if (policy.poll === 'wait-budget') { + assert.ok(mentionsAny(source, POLL_MARKERS), `${name} claims a poll budget`); + } + } +}); + +test('read and wait pipelines really do skip occlusion, off-screen, and promotion', () => { + // The inverse direction: a row claiming NO occlusion must not sit in a file + // that performs it, or the matrix would under-report real behavior. + const readSourceText = readSource(CALLERS.readUnique); + const waitSource = readSource(CALLERS.wait); + for (const [name, source] of [ + ['selector-read', readSourceText], + ['selector-wait', waitSource], + ] as const) { + assert.equal(mentionsAny(source, OCCLUSION_MARKERS), false, `${name} occlusion`); + assert.equal(mentionsAny(source, OFFSCREEN_MARKERS), false, `${name} off-screen`); + assert.equal(mentionsAny(source, PROMOTION_MARKERS), false, `${name} promotion`); + } +}); + +test('no caller re-declares ambiguity knobs as inline literals', () => { + for (const relative of new Set(Object.values(CALLERS))) { + const source = readSource(relative); + assert.equal( + /disambiguateAmbiguous:\s*(true|false)/.test(source), + false, + `${relative} declares disambiguateAmbiguous inline`, + ); + assert.equal( + /requireUnique:\s*(true|false)/.test(source), + false, + `${relative} declares requireUnique inline`, + ); + } +}); diff --git a/src/commands/interaction/runtime/resolution.ts b/src/commands/interaction/runtime/resolution.ts index 1b4356c5f..b75f6f849 100644 --- a/src/commands/interaction/runtime/resolution.ts +++ b/src/commands/interaction/runtime/resolution.ts @@ -14,6 +14,8 @@ import { STALE_REF_HINT, type SelectorResolution, buildSelectorChainForNode, + SELECTOR_RESOLUTION_POLICIES, + selectorResolutionKnobs, } from '@agent-device/selectors'; import { resolvePressRecordingTarget } from '../../../core/press-retarget.ts'; import { requireSnapshotSession } from './selector-read-shared.ts'; @@ -277,9 +279,7 @@ async function resolveSelectorInteractionTarget( selectorExpression, { platform: runtime.backend.platform, - requireRect: true, - requireUnique: true, - disambiguateAmbiguous: true, + ...selectorResolutionKnobs(SELECTOR_RESOLUTION_POLICIES.act), }, ); if ((!resolved || !resolved.node.rect) && params.requireInteractive) { @@ -289,17 +289,14 @@ async function resolveSelectorInteractionTarget( selectorExpression, { platform: runtime.backend.platform, - requireRect: true, - requireUnique: true, - disambiguateAmbiguous: true, + ...selectorResolutionKnobs(SELECTOR_RESOLUTION_POLICIES.act), }, ); } if (!resolved || !resolved.node.rect) { const covered = resolveSelectorChain(capture.snapshot.nodes, selectorExpression, { platform: runtime.backend.platform, - requireRect: true, - requireUnique: false, + ...selectorResolutionKnobs(SELECTOR_RESOLUTION_POLICIES.actCoveredDiagnosis), }); if (covered?.node && isSnapshotNodeInteractionBlocked(covered.node)) { throw buildCoveredInteractionError({ diff --git a/src/commands/interaction/runtime/selector-read.ts b/src/commands/interaction/runtime/selector-read.ts index 384499daa..5bae2a125 100644 --- a/src/commands/interaction/runtime/selector-read.ts +++ b/src/commands/interaction/runtime/selector-read.ts @@ -14,6 +14,10 @@ import { parseFindSelectorExpression, type FindAction, type FindLocator, + SELECTOR_RESOLUTION_POLICIES, + selectorResolutionKnobs, + type KnobBackedSelectorAmbiguity, + type SelectorResolutionPolicy, } from '@agent-device/selectors'; import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { isSparseSnapshotQualityVerdict } from '../../../snapshot/snapshot-quality.ts'; @@ -51,6 +55,10 @@ import { TINY_STABLE_TREE_NODE_COUNT, } from './stable-capture.ts'; +type KnobBackedResolutionPolicy = SelectorResolutionPolicy & { + ambiguity: KnobBackedSelectorAmbiguity; +}; + export type { SelectorSnapshotOptions } from './selector-read-shared.ts'; export type { WaitCommandOptions, @@ -209,7 +217,10 @@ export const getCommand: RuntimeCommand = a const resolved = await resolveSelectorNode(runtime, options, options.session ?? 'default', { selector: options.target.selector, - disambiguateAmbiguous: options.property === 'text', + policy: + options.property === 'text' + ? SELECTOR_RESOLUTION_POLICIES.readText + : SELECTOR_RESOLUTION_POLICIES.readUnique, }); assertExpectedResolvedTarget( resolved.node, @@ -317,9 +328,7 @@ export const isCommand: RuntimeCommand = asyn const resolved = resolveSelectorChain(capture.snapshot.nodes, selectorExpression, { platform: runtime.backend.platform, - requireRect: false, - requireUnique: true, - disambiguateAmbiguous: false, + ...selectorResolutionKnobs(SELECTOR_RESOLUTION_POLICIES.readUnique), }); if (!resolved) { throw new AppError( @@ -472,8 +481,7 @@ async function findFirstLocatorMatch( if (selectorExpression) { const resolved = resolveSelectorChain(capture.snapshot.nodes, selectorExpression, { platform: runtime.backend.platform, - requireRect: false, - requireUnique: false, + ...selectorResolutionKnobs(SELECTOR_RESOLUTION_POLICIES.readAny), }); return { capture, match: resolved?.node }; } @@ -487,7 +495,7 @@ async function resolveSelectorNode( runtime: AgentDeviceRuntime, options: GetCommandOptions, sessionName: string, - params: { selector: string; disambiguateAmbiguous: boolean }, + params: { selector: string; policy: KnobBackedResolutionPolicy }, ): Promise<{ capture: CapturedSnapshot; node: SnapshotNode; selector: string; ref: string }> { const capture = await captureSelectorSnapshot( runtime, @@ -499,9 +507,7 @@ async function resolveSelectorNode( ); const resolved = resolveSelectorChain(capture.snapshot.nodes, params.selector, { platform: runtime.backend.platform, - requireRect: false, - requireUnique: true, - disambiguateAmbiguous: params.disambiguateAmbiguous, + ...selectorResolutionKnobs(params.policy), }); if (!resolved) { throw new AppError( diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index 1b1064c2b..b1bd5c530 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -7,6 +7,7 @@ import { parseFindSelectorExpression, type FindLocator, listSelectorChainMatches, + SELECTOR_RESOLUTION_POLICIES, } from '@agent-device/selectors'; import { centerOfRect, @@ -252,16 +253,17 @@ function resolveFindMatch(params: { // explicitly opts into positional narrowing. Selectors used to take the // first match silently, which was exactly the mis-binding path the error's // own recovery advice ("use a selector") pointed agents at. + const policy = SELECTOR_RESOLUTION_POLICIES.findAct; let matches: SnapshotState['nodes']; if (selectorExpression) { matches = listSelectorChainMatches(searchableNodes, selectorExpression, { platform, - requireRect: true, + requireRect: policy.requireRect, })?.matchedNodes ?? []; } else { matches = findBestMatchesByLocator(searchableNodes, locator, query, { - requireRect: true, + requireRect: policy.requireRect, }).matches; } matches = preferOnscreenMatches(matches, nodes); From 09bb602536b02e7cf7e3bca54ce9b95bb5e9169e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 6 Aug 2026 16:33:17 +0200 Subject: [PATCH 2/6] refactor: route wait and mutating find through the policy interface (#1649 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1 was right: the first head declared seven rows but genuinely routed five. selector-wait.ts never imported its row (it called listSelectorChainMatches directly), findAct consumed only requireRect while its ambiguity contract stayed bespoke, and the parity test sniffed marker strings in source files — so it stayed green across exactly that gap. Asserting about the layer I had edited instead of the behavior it produces. resolveSelectorChainWithPolicy is now the one policy-driven entry: it returns a discriminated outcome (none / resolved / ambiguous) because the rows genuinely disagree about what several matches mean, which is what previously forced each caller to re-derive its contract inline. wait and find's selector branch both route through it; find additionally asserts its row still says reject-candidates rather than assuming. The parity test is rebuilt on fixture trees driven through that interface — no source sniffing. Wiring verified revert-sensitive: flipping the wait row fails the policy tests, and flipping findAct fails REAL find handler tests (ambiguous-candidate listing), which is the proof the previous version could not produce. One behavior nuance the fixture work surfaced and now pins: disambiguation declines on genuinely indistinguishable candidates (the tiebreak is evidence, not a coin flip), so an acting row surfaces ambiguity there rather than binding one silently. --- packages/selectors/src/index.ts | 15 ++ .../src/internal/resolve-with-policy.ts | 105 +++++++++ .../resolution-policy-parity.test.ts | 223 ++++++++++-------- .../interaction/runtime/selector-wait.ts | 40 +++- src/daemon/handlers/find.ts | 39 ++- 5 files changed, 311 insertions(+), 111 deletions(-) create mode 100644 packages/selectors/src/internal/resolve-with-policy.ts diff --git a/packages/selectors/src/index.ts b/packages/selectors/src/index.ts index e33b94fb0..1e9e81571 100644 --- a/packages/selectors/src/index.ts +++ b/packages/selectors/src/index.ts @@ -7,6 +7,7 @@ import type { SelectorResolution, SelectorResolutionOptions, } from './internal/public-resolution-types.ts'; +import { resolveSelectorChainWithPolicy as resolveSelectorChainWithPolicyAst } from './internal/resolve-with-policy.ts'; import { checkElementTargetArgs, checkGetFormat, @@ -267,3 +268,17 @@ export type { KnobBackedSelectorAmbiguity, SelectorResolutionPolicy, } from './internal/resolution-policy.ts'; +import type { SelectorResolutionPolicy } from './internal/resolution-policy.ts'; +import type { PolicyResolutionOutcome } from './internal/resolve-with-policy.ts'; +export type { PolicyResolutionOutcome } from './internal/resolve-with-policy.ts'; + +/** Public façade wrapper that accepts selector text, never an AST. */ +function resolveSelectorChainWithPolicy( + nodes: SnapshotState['nodes'], + expression: string, + policy: SelectorResolutionPolicy, + options: SelectorMatchOptions, +): PolicyResolutionOutcome { + return resolveSelectorChainWithPolicyAst(nodes, parseSelectorChain(expression), policy, options); +} +export { resolveSelectorChainWithPolicy }; diff --git a/packages/selectors/src/internal/resolve-with-policy.ts b/packages/selectors/src/internal/resolve-with-policy.ts new file mode 100644 index 000000000..59f23a61e --- /dev/null +++ b/packages/selectors/src/internal/resolve-with-policy.ts @@ -0,0 +1,105 @@ +import type { SnapshotState } from '@agent-device/kernel/snapshot'; +import type { SelectorChain } from './parse.ts'; +import type { SelectorMatchOptions } from './public-resolution-types.ts'; +import { + listSelectorChainMatches, + resolveSelectorChain, + type AstSelectorResolution, +} from './resolve.ts'; +import type { SelectorResolutionPolicy } from './resolution-policy.ts'; + +/** + * The one policy-driven resolution entry every native caller routes through + * (#1630). A caller passes the policy row that IS its documented contract; + * this decides what "resolved" means for that row, so ambiguity semantics + * live in the matrix rather than in each caller's local branching. + * + * The outcome is a discriminated union rather than a nullable node, because + * the rows genuinely disagree about what to do with several matches: + * `disambiguate` and `fail-closed` want one winner or nothing, `first-match` + * wants the head of the list, and `reject-candidates` needs the whole + * candidate set to refuse with (or to narrow, when the caller was given an + * explicit index). Collapsing those into "node | null" is what previously + * forced every caller to re-derive its own contract inline. + */ + +export type PolicyResolutionOutcome = + /** No selector alternative matched anything. */ + | { kind: 'none' } + /** Exactly the node this policy authorizes acting on. */ + | { kind: 'resolved'; resolution: AstSelectorResolution } + /** + * Several matches and the policy refuses to choose. `fail-closed` returns + * this instead of guessing; `reject-candidates` returns it so the caller + * can narrow explicitly or surface the candidate list. + */ + | { + kind: 'ambiguous'; + selector: string; + selectorIndex: number; + matchedNodes: SnapshotState['nodes']; + }; + +export function resolveSelectorChainWithPolicy( + nodes: SnapshotState['nodes'], + chain: SelectorChain, + policy: SelectorResolutionPolicy, + options: SelectorMatchOptions, +): PolicyResolutionOutcome { + const matchOptions = { ...options, requireRect: policy.requireRect }; + + if (policy.ambiguity === 'reject-candidates') { + const list = listSelectorChainMatches(nodes, chain, matchOptions); + if (!list || list.matchedNodes.length === 0) return { kind: 'none' }; + if (list.matchedNodes.length > 1) { + return { + kind: 'ambiguous', + selector: list.selector.raw, + selectorIndex: list.selectorIndex, + matchedNodes: list.matchedNodes, + }; + } + return resolvedFromList(list); + } + + if (policy.ambiguity === 'first-match') { + const list = listSelectorChainMatches(nodes, chain, matchOptions); + if (!list || list.matchedNodes.length === 0) return { kind: 'none' }; + return resolvedFromList(list); + } + + const resolution = resolveSelectorChain(nodes, chain, { + ...matchOptions, + requireUnique: true, + disambiguateAmbiguous: policy.ambiguity === 'disambiguate', + }); + if (resolution) return { kind: 'resolved', resolution }; + + // Distinguish "nothing matched" from "matched but this policy will not + // choose" — a fail-closed caller must report ambiguity, not absence. + const list = listSelectorChainMatches(nodes, chain, matchOptions); + if (!list || list.matchedNodes.length === 0) return { kind: 'none' }; + return { + kind: 'ambiguous', + selector: list.selector.raw, + selectorIndex: list.selectorIndex, + matchedNodes: list.matchedNodes, + }; +} + +function resolvedFromList( + list: NonNullable>, +): PolicyResolutionOutcome { + const node = list.matchedNodes[0]; + if (!node) return { kind: 'none' }; + return { + kind: 'resolved', + resolution: { + node, + selector: list.selector, + selectorIndex: list.selectorIndex, + matches: list.matchedNodes.length, + diagnostics: [{ selector: list.selector.raw, matches: list.matchedNodes.length }], + }, + }; +} diff --git a/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts b/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts index 8841c01e7..c7a6412e2 100644 --- a/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts +++ b/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts @@ -1,135 +1,156 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; import { test } from 'vitest'; +import type { SnapshotNode } from '@agent-device/kernel/snapshot'; import { SELECTOR_RESOLUTION_POLICIES, + resolveSelectorChainWithPolicy, selectorResolutionKnobs, - type SelectorResolutionPolicy, } from '@agent-device/selectors'; /** - * The matrix in resolution-policy.ts is a claim about the callers, so it is - * gate-tested against them rather than trusted (ADR 0011's declared-plus- - * gate-tested pattern). Two failure modes this catches: a policy row whose - * knobs stop matching the ambiguity contract it names, and a structural - * column (occlusion / off-screen / promotion / poll) drifting into fiction - * because a caller stopped importing the machinery the row advertises. + * The matrix is exercised through the interface callers actually use + * (`resolveSelectorChainWithPolicy`) against fixture trees, so each row's + * ambiguity contract is proven behaviorally rather than asserted about + * source text. A row that stops matching its documented semantics fails + * here even though the declaration still reads plausibly. */ -const REPO_SRC = path.resolve(import.meta.dirname, '../../../..'); - -function readSource(relative: string): string { - return readFileSync(path.join(REPO_SRC, relative), 'utf8'); +function node(index: number, label: string, overrides: Partial = {}): SnapshotNode { + return { + ref: `e${index}`, + index, + depth: 1, + type: 'Button', + label, + rect: { x: 0, y: index * 40, width: 100, height: 30 }, + ...overrides, + } as SnapshotNode; } -const CALLERS = { - act: 'commands/interaction/runtime/resolution.ts', - actCoveredDiagnosis: 'commands/interaction/runtime/resolution.ts', - readText: 'commands/interaction/runtime/selector-read.ts', - readUnique: 'commands/interaction/runtime/selector-read.ts', - readAny: 'commands/interaction/runtime/selector-read.ts', - wait: 'commands/interaction/runtime/selector-wait.ts', - findAct: 'daemon/handlers/find.ts', -} as const satisfies Record; +/** Two nodes share a label: every ambiguity contract has to say something. */ +const AMBIGUOUS_TREE: SnapshotNode[] = [node(0, 'Save'), node(1, 'Save'), node(2, 'Cancel')]; +/** + * Same ambiguity, but the candidates differ in depth/area, so the engine's + * visible→deepest→smallest-area tiebreak CAN pick a winner. Kept separate + * from AMBIGUOUS_TREE because indistinguishable candidates are exactly the + * case where disambiguation must decline (below). + */ +const TIEBREAKABLE_TREE: SnapshotNode[] = [ + node(0, 'Save', { rect: { x: 0, y: 0, width: 300, height: 200 } }), + node(1, 'Save', { depth: 3, rect: { x: 10, y: 10, width: 80, height: 24 } }), + node(2, 'Cancel'), +]; +const UNIQUE_TREE: SnapshotNode[] = [node(0, 'Save'), node(1, 'Cancel')]; +/** Rectless nodes: only rect-requiring rows should reject these. */ +const RECTLESS_TREE: SnapshotNode[] = [node(0, 'Save', { rect: undefined })]; -// What each structural column means in caller source, so the claim is -// checkable rather than decorative. -const OCCLUSION_MARKERS = ['isSnapshotNodeInteractionBlocked', 'interactableSelectorNodes']; -const OFFSCREEN_MARKERS = ['throwIfOffscreenInteractionTarget', 'assertVisibleSelectorTarget']; -const PROMOTION_MARKERS = ['resolveActionableTouchNode', 'resolveActionableNodeOrThrow']; -const POLL_MARKERS = ['createWaitPolling']; +const OPTIONS = { platform: 'ios' as const }; -function mentionsAny(source: string, markers: readonly string[]): boolean { - return markers.some((marker) => source.includes(marker)); +function outcomeFor(policyName: keyof typeof SELECTOR_RESOLUTION_POLICIES, tree: SnapshotNode[]) { + return resolveSelectorChainWithPolicy( + tree, + 'label="Save"', + SELECTOR_RESOLUTION_POLICIES[policyName], + OPTIONS, + ); } -test('every policy row names a real caller', () => { - assert.deepEqual(Object.keys(SELECTOR_RESOLUTION_POLICIES).sort(), Object.keys(CALLERS).sort()); +test('a unique match resolves under every policy', () => { + for (const name of Object.keys( + SELECTOR_RESOLUTION_POLICIES, + ) as (keyof typeof SELECTOR_RESOLUTION_POLICIES)[]) { + const outcome = outcomeFor(name, UNIQUE_TREE); + assert.equal(outcome.kind, 'resolved', name); + if (outcome.kind === 'resolved') assert.equal(outcome.resolution.node.label, 'Save'); + } }); -test('knobs match the ambiguity contract each row names', () => { - const expected: Record> = { - disambiguate: { requireRect: false, requireUnique: true, disambiguateAmbiguous: true }, - 'fail-closed': { requireRect: false, requireUnique: true, disambiguateAmbiguous: false }, - 'first-match': { requireRect: false, requireUnique: false }, - }; - for (const [name, policy] of Object.entries(SELECTOR_RESOLUTION_POLICIES)) { - if (policy.ambiguity === 'reject-candidates') continue; - const knobs = selectorResolutionKnobs(policy); - const want = { ...expected[policy.ambiguity], requireRect: policy.requireRect }; - assert.deepEqual(knobs, want, name); +test('no match resolves to none under every policy', () => { + for (const name of Object.keys( + SELECTOR_RESOLUTION_POLICIES, + ) as (keyof typeof SELECTOR_RESOLUTION_POLICIES)[]) { + const outcome = resolveSelectorChainWithPolicy( + UNIQUE_TREE, + 'label="Absent"', + SELECTOR_RESOLUTION_POLICIES[name], + OPTIONS, + ); + assert.equal(outcome.kind, 'none', name); } }); -test('acting policies require a rect; read and wait policies do not', () => { - assert.equal(SELECTOR_RESOLUTION_POLICIES.act.requireRect, true); - assert.equal(SELECTOR_RESOLUTION_POLICIES.findAct.requireRect, true); - assert.equal(SELECTOR_RESOLUTION_POLICIES.readUnique.requireRect, false); - assert.equal(SELECTOR_RESOLUTION_POLICIES.readAny.requireRect, false); - assert.equal(SELECTOR_RESOLUTION_POLICIES.wait.requireRect, false); +test('disambiguating rows pick the tiebreak winner and disclose the match count', () => { + for (const name of ['act', 'readText'] as const) { + const outcome = outcomeFor(name, TIEBREAKABLE_TREE); + assert.equal(outcome.kind, 'resolved', name); + if (outcome.kind === 'resolved') { + assert.equal(outcome.resolution.matches, 2, `${name} discloses the real match count`); + assert.equal(outcome.resolution.node.index, 1, `${name} takes the deepest/smallest`); + } + } }); -test('is/get-attrs fail closed and wait never disambiguates (the by-design asymmetry)', () => { - assert.equal(SELECTOR_RESOLUTION_POLICIES.readUnique.ambiguity, 'fail-closed'); - assert.equal(SELECTOR_RESOLUTION_POLICIES.readAny.ambiguity, 'first-match'); - assert.equal(SELECTOR_RESOLUTION_POLICIES.wait.ambiguity, 'first-match'); - assert.equal(SELECTOR_RESOLUTION_POLICIES.act.ambiguity, 'disambiguate'); - assert.equal(SELECTOR_RESOLUTION_POLICIES.readText.ambiguity, 'disambiguate'); - assert.equal(SELECTOR_RESOLUTION_POLICIES.findAct.ambiguity, 'reject-candidates'); +test('disambiguation declines when candidates are genuinely indistinguishable', () => { + // The tiebreak is evidence, not a coin flip: identical candidates must not + // silently bind one. Acting rows surface the ambiguity instead. + for (const name of ['act', 'readText'] as const) { + assert.equal(outcomeFor(name, AMBIGUOUS_TREE).kind, 'ambiguous', name); + } +}); + +test('fail-closed rows refuse an ambiguous tree instead of guessing', () => { + const outcome = outcomeFor('readUnique', AMBIGUOUS_TREE); + assert.equal(outcome.kind, 'ambiguous'); + if (outcome.kind === 'ambiguous') assert.equal(outcome.matchedNodes.length, 2); }); -test('structural columns match what each caller actually imports', () => { - const sources = new Map(); - for (const relative of Object.values(CALLERS)) { - if (!sources.has(relative)) sources.set(relative, readSource(relative)); +test('first-match rows take the head of an ambiguous tree', () => { + for (const name of ['readAny', 'wait', 'actCoveredDiagnosis'] as const) { + const outcome = outcomeFor(name, AMBIGUOUS_TREE); + assert.equal(outcome.kind, 'resolved', name); + if (outcome.kind === 'resolved') assert.equal(outcome.resolution.node.index, 0, name); } - for (const [name, relative] of Object.entries(CALLERS)) { - const policy: SelectorResolutionPolicy = - SELECTOR_RESOLUTION_POLICIES[name as keyof typeof SELECTOR_RESOLUTION_POLICIES]; - const source = sources.get(relative)!; - if (policy.occlusion) { - assert.ok(mentionsAny(source, OCCLUSION_MARKERS), `${name} claims occlusion`); - } - if (policy.offscreenGuard) { - assert.ok(mentionsAny(source, OFFSCREEN_MARKERS), `${name} claims an off-screen guard`); - } - if (policy.promotion) { - assert.ok(mentionsAny(source, PROMOTION_MARKERS), `${name} claims promotion`); - } - if (policy.poll === 'wait-budget') { - assert.ok(mentionsAny(source, POLL_MARKERS), `${name} claims a poll budget`); - } +}); + +test('reject-candidates surfaces every candidate for the caller to narrow or refuse', () => { + const outcome = outcomeFor('findAct', AMBIGUOUS_TREE); + assert.equal(outcome.kind, 'ambiguous'); + if (outcome.kind === 'ambiguous') { + assert.deepEqual( + outcome.matchedNodes.map((n) => n.index), + [0, 1], + ); } }); -test('read and wait pipelines really do skip occlusion, off-screen, and promotion', () => { - // The inverse direction: a row claiming NO occlusion must not sit in a file - // that performs it, or the matrix would under-report real behavior. - const readSourceText = readSource(CALLERS.readUnique); - const waitSource = readSource(CALLERS.wait); - for (const [name, source] of [ - ['selector-read', readSourceText], - ['selector-wait', waitSource], - ] as const) { - assert.equal(mentionsAny(source, OCCLUSION_MARKERS), false, `${name} occlusion`); - assert.equal(mentionsAny(source, OFFSCREEN_MARKERS), false, `${name} off-screen`); - assert.equal(mentionsAny(source, PROMOTION_MARKERS), false, `${name} promotion`); +test('rect-requiring rows skip rectless nodes; read and wait rows accept them', () => { + for (const name of ['act', 'findAct', 'actCoveredDiagnosis'] as const) { + assert.equal(outcomeFor(name, RECTLESS_TREE).kind, 'none', name); + } + for (const name of ['readUnique', 'readAny', 'readText', 'wait'] as const) { + assert.equal(outcomeFor(name, RECTLESS_TREE).kind, 'resolved', name); } }); -test('no caller re-declares ambiguity knobs as inline literals', () => { - for (const relative of new Set(Object.values(CALLERS))) { - const source = readSource(relative); - assert.equal( - /disambiguateAmbiguous:\s*(true|false)/.test(source), - false, - `${relative} declares disambiguateAmbiguous inline`, - ); - assert.equal( - /requireUnique:\s*(true|false)/.test(source), - false, - `${relative} declares requireUnique inline`, - ); +test('knobs stay consistent with the ambiguity each knob-backed row names', () => { + for (const [name, policy] of Object.entries(SELECTOR_RESOLUTION_POLICIES)) { + if (policy.ambiguity === 'reject-candidates') continue; + const knobs = selectorResolutionKnobs(policy); + assert.equal(knobs.requireRect, policy.requireRect, name); + if (policy.ambiguity === 'first-match') { + assert.equal(knobs.requireUnique, false, name); + } else { + assert.equal(knobs.requireUnique, true, name); + assert.equal(knobs.disambiguateAmbiguous, policy.ambiguity === 'disambiguate', name); + } } }); + +test('the documented per-caller contracts are the ones declared', () => { + assert.equal(SELECTOR_RESOLUTION_POLICIES.act.ambiguity, 'disambiguate'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.readText.ambiguity, 'disambiguate'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.readUnique.ambiguity, 'fail-closed'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.readAny.ambiguity, 'first-match'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.wait.ambiguity, 'first-match'); + assert.equal(SELECTOR_RESOLUTION_POLICIES.findAct.ambiguity, 'reject-candidates'); +}); diff --git a/src/commands/interaction/runtime/selector-wait.ts b/src/commands/interaction/runtime/selector-wait.ts index 349ba6aca..854bb5473 100644 --- a/src/commands/interaction/runtime/selector-wait.ts +++ b/src/commands/interaction/runtime/selector-wait.ts @@ -15,8 +15,10 @@ import { import type { PublicPlatform } from '@agent-device/kernel/device'; import { checkWaitText, - listSelectorChainMatches, type SelectorChainMatchList, + SELECTOR_RESOLUTION_POLICIES, + resolveSelectorChainWithPolicy, + type PolicyResolutionOutcome, } from '@agent-device/selectors'; import { deriveSelectorCapturePolicy } from './selector-capture-policy.ts'; import { findNodeByLabel, resolveRefLabel } from './selector-read-utils.ts'; @@ -27,6 +29,30 @@ import { waitTimeoutError, } from './wait-polling.ts'; +/** + * The landmark check (#1349) needs the full candidate set, which the policy + * outcome carries in either shape: a `first-match` resolution exposes the + * winner, and the ambiguous branch exposes all candidates. Wait's row never + * refuses, so this only ever adapts — it does not re-decide anything. + */ +function policyMatchList(outcome: PolicyResolutionOutcome): SelectorChainMatchList | undefined { + if (outcome.kind === 'ambiguous') { + return { + selector: outcome.selector, + selectorIndex: outcome.selectorIndex, + matchedNodes: outcome.matchedNodes, + }; + } + if (outcome.kind === 'resolved') { + return { + selector: outcome.resolution.selector.raw, + selectorIndex: outcome.resolution.selectorIndex, + matchedNodes: [outcome.resolution.node], + }; + } + return undefined; +} + type WaitCommandContext = { session?: string; requestId?: string; @@ -248,9 +274,15 @@ async function waitForSelector( const capture = poll.value; if (capture) { const nodes = capture.snapshot.nodes; - const matchList = listSelectorChainMatches(nodes, selectorExpression, { - platform: runtime.backend.platform, - }); + const outcome = resolveSelectorChainWithPolicy( + nodes, + selectorExpression, + SELECTOR_RESOLUTION_POLICIES.wait, + { platform: runtime.backend.platform }, + ); + // The wait row is `first-match`, so a multi-match screen resolves rather + // than refusing; the landmark check below is what decides satisfaction. + const matchList = policyMatchList(outcome); if (matchList) { const landmark = resolveLandmarkMatch(nodes, matchList, recordedLandmark); if (landmark.kind === 'satisfied') { diff --git a/src/daemon/handlers/find.ts b/src/daemon/handlers/find.ts index b1bd5c530..b83e93fce 100644 --- a/src/daemon/handlers/find.ts +++ b/src/daemon/handlers/find.ts @@ -6,8 +6,10 @@ import { checkFindArgs, parseFindSelectorExpression, type FindLocator, - listSelectorChainMatches, SELECTOR_RESOLUTION_POLICIES, + resolveSelectorChainWithPolicy, + type PolicyResolutionOutcome, + type SelectorResolutionPolicy, } from '@agent-device/selectors'; import { centerOfRect, @@ -33,6 +35,24 @@ import { stripInternalInteractionFlags } from '../interaction-outcome-policy.ts' import { dispatchFindReadOnlyViaRuntime } from '../selector-runtime.ts'; import { createSelectorCaptureRuntime } from '../selector-capture-runtime.ts'; import { isSparseSnapshotQualityVerdict } from '../../snapshot/snapshot-quality.ts'; + +/** + * Both branches of the `reject-candidates` contract produce a candidate set: + * a single resolved match, or the full ambiguous set find must refuse (or + * narrow) explicitly. + */ +function policyMatchedNodes(outcome: PolicyResolutionOutcome): SnapshotState['nodes'] { + if (outcome.kind === 'ambiguous') return outcome.matchedNodes; + if (outcome.kind === 'resolved') return [outcome.resolution.node]; + return []; +} + +function assertRejectsCandidates(policy: SelectorResolutionPolicy): void { + if (policy.ambiguity !== 'reject-candidates') { + throw new Error(`find's resolution policy must reject candidates, got "${policy.ambiguity}"`); + } +} + type FindContext = { req: DaemonRequest; sessionName: string; @@ -256,11 +276,14 @@ function resolveFindMatch(params: { const policy = SELECTOR_RESOLUTION_POLICIES.findAct; let matches: SnapshotState['nodes']; if (selectorExpression) { - matches = - listSelectorChainMatches(searchableNodes, selectorExpression, { - platform, - requireRect: policy.requireRect, - })?.matchedNodes ?? []; + // Selector-shaped queries resolve through the policy interface, so the + // `reject-candidates` contract is the matrix's decision rather than a + // local convention. The locator branch cannot: it matches by fuzzy text + // scoring, not by selector chains, so it produces its candidate set with + // its own matcher and joins the shared contract below. + matches = policyMatchedNodes( + resolveSelectorChainWithPolicy(searchableNodes, selectorExpression, policy, { platform }), + ); } else { matches = findBestMatchesByLocator(searchableNodes, locator, query, { requireRect: policy.requireRect, @@ -269,6 +292,10 @@ function resolveFindMatch(params: { matches = preferOnscreenMatches(matches, nodes); if (matches.length > 1) { + // The row says candidates reject unless the caller narrowed explicitly; + // assert that rather than assuming, so a future row edit cannot silently + // turn this into first-match. + assertRejectsCandidates(policy); const narrowed = narrowMultipleMatches(matches, flags); if (!narrowed) { return { ok: false, response: buildAmbiguousMatchError(matches, locator, query) }; From 633c41a203c53f2c3b0e88f520ef713f5b55686e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 6 Aug 2026 17:09:48 +0200 Subject: [PATCH 3/6] fix(test): let fallow see the host-process mock helper's real consumers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase onto main brought #1642's host-process-mock.ts into this PR's fallow scope, where its export reports as unused. It is not: three suites consume it, but only through `(await import(...)).pinOwnProcessStartTime` inside vi.mock factories — vitest hoists those above static imports, so the dynamic form is required and fallow cannot trace it statically. Documented suppression rather than a restructure that would break the hoisting contract. Latent on main rather than introduced here: the audit gate is changed-files-only, so main sees the file in scope only from a PR whose diff contains it. --- src/__tests__/test-utils/host-process-mock.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/__tests__/test-utils/host-process-mock.ts b/src/__tests__/test-utils/host-process-mock.ts index 8992d3926..795dca847 100644 --- a/src/__tests__/test-utils/host-process-mock.ts +++ b/src/__tests__/test-utils/host-process-mock.ts @@ -17,6 +17,10 @@ type HostProcessModule = typeof import('../../utils/host-process.ts'); * Usage: `vi.mock('/utils/host-process.ts', async (importOriginal) => * (await import('/test-utils/host-process-mock.ts')).pinOwnProcessStartTime(importOriginal))` */ +// Consumed by three suites, but only through `(await import(...)).pinOwnProcessStartTime` +// inside `vi.mock` factories — vitest hoists those above static imports, so the dynamic +// form is required and fallow cannot trace the consumers statically. +// fallow-ignore-next-line unused-export export async function pinOwnProcessStartTime( importOriginal: () => Promise, ): Promise { From c2afd78c1d29c7d1fb68d9a09e1bc4aae0be1ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 6 Aug 2026 17:41:40 +0200 Subject: [PATCH 4/6] fix: keep every candidate when a policy resolves one winner (#1649 review P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real regression I introduced, not a test gap: routing wait through the policy interface collapsed the candidate set to the winner, and the #1349 landmark check is satisfied when SOME match carries the recorded identity. A first same-selector impostor therefore hid a later genuine landmark and timed the wait out. The resolved outcome now carries `matchedNodes` — the full candidate set of the alternative the winner came from — so a policy that picks one node no longer throws the rest away. wait passes that straight to the landmark check, restoring the original semantics. Regression test added at the within-one-poll shape the existing suite did not cover (both candidates in the SAME capture, impostor first); verified it goes red against the singleton reconstruction it replaces. --- .../src/internal/resolve-with-policy.ts | 24 +++++++-- .../interaction/runtime/selector-wait.test.ts | 51 +++++++++++++++++++ .../interaction/runtime/selector-wait.ts | 5 +- 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/selectors/src/internal/resolve-with-policy.ts b/packages/selectors/src/internal/resolve-with-policy.ts index 59f23a61e..b5ca1257c 100644 --- a/packages/selectors/src/internal/resolve-with-policy.ts +++ b/packages/selectors/src/internal/resolve-with-policy.ts @@ -26,8 +26,18 @@ import type { SelectorResolutionPolicy } from './resolution-policy.ts'; export type PolicyResolutionOutcome = /** No selector alternative matched anything. */ | { kind: 'none' } - /** Exactly the node this policy authorizes acting on. */ - | { kind: 'resolved'; resolution: AstSelectorResolution } + /** + * The node this policy authorizes acting on, plus the full candidate set + * of the alternative it came from. Callers that verify identity across + * candidates (wait's #1349 landmark check) need the whole set — a policy + * that picks one winner must not throw the rest away, or a first impostor + * would hide a later genuine match. + */ + | { + kind: 'resolved'; + resolution: AstSelectorResolution; + matchedNodes: SnapshotState['nodes']; + } /** * Several matches and the policy refuses to choose. `fail-closed` returns * this instead of guessing; `reject-candidates` returns it so the caller @@ -73,7 +83,14 @@ export function resolveSelectorChainWithPolicy( requireUnique: true, disambiguateAmbiguous: policy.ambiguity === 'disambiguate', }); - if (resolution) return { kind: 'resolved', resolution }; + if (resolution) { + const list = listSelectorChainMatches(nodes, chain, matchOptions); + return { + kind: 'resolved', + resolution, + matchedNodes: list?.matchedNodes ?? [resolution.node], + }; + } // Distinguish "nothing matched" from "matched but this policy will not // choose" — a fail-closed caller must report ambiguity, not absence. @@ -94,6 +111,7 @@ function resolvedFromList( if (!node) return { kind: 'none' }; return { kind: 'resolved', + matchedNodes: list.matchedNodes, resolution: { node, selector: list.selector, diff --git a/src/commands/interaction/runtime/selector-wait.test.ts b/src/commands/interaction/runtime/selector-wait.test.ts index 4faa01cd4..8b1b9175d 100644 --- a/src/commands/interaction/runtime/selector-wait.test.ts +++ b/src/commands/interaction/runtime/selector-wait.test.ts @@ -123,6 +123,57 @@ function landmarkWaitDevice(captures: Array>) return device; } +/** + * The within-one-poll twin of the test below (#1649 review P1): both + * candidates are on screen in the SAME capture, impostor first. The landmark + * check is satisfied when SOME match carries the recorded identity, so + * resolution must hand it every candidate — a policy that returns only its + * first-match winner would hide the genuine landmark behind the impostor and + * make this wait time out. + */ +function twoCandidateScreen(): ReturnType { + return makeSnapshotState([ + { index: 0, depth: 0, type: 'Other', label: 'List Screen' }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'StaticText', + label: 'Screen X', + rect: { x: 0, y: 0, width: 100, height: 20 }, + }, + { index: 2, depth: 0, type: 'Other', label: 'Detail Screen' }, + { + index: 3, + depth: 1, + parentIndex: 2, + type: 'StaticText', + label: 'Screen X', + rect: { x: 0, y: 40, width: 100, height: 20 }, + }, + ]); +} + +test('runtime wait finds the recorded landmark behind a same-selector impostor in one capture', async () => { + const recorded = recordedLandmarkFor(landmarkScreen('Detail Screen')); + const device = landmarkWaitDevice([twoCandidateScreen()]); + + const result = await device.selectors.wait({ + session: 'default', + target: { + kind: 'selector', + selector: 'label="Screen X"', + timeoutMs: 2_000, + recordedLandmark: recorded, + }, + }); + + assert.equal(result.kind, 'selector'); + if (result.kind !== 'selector') throw new Error('unreachable'); + // The SECOND candidate is the one carrying the recorded ancestry. + assert.equal(result.node?.index, 3); +}); + test('runtime wait keeps polling past a same-selector impostor and succeeds on the recorded landmark', async () => { const recordTime = landmarkScreen('Detail Screen'); const recorded = recordedLandmarkFor(recordTime); diff --git a/src/commands/interaction/runtime/selector-wait.ts b/src/commands/interaction/runtime/selector-wait.ts index 854bb5473..1c9db1918 100644 --- a/src/commands/interaction/runtime/selector-wait.ts +++ b/src/commands/interaction/runtime/selector-wait.ts @@ -47,7 +47,10 @@ function policyMatchList(outcome: PolicyResolutionOutcome): SelectorChainMatchLi return { selector: outcome.resolution.selector.raw, selectorIndex: outcome.resolution.selectorIndex, - matchedNodes: [outcome.resolution.node], + // Every candidate, not just the winner: the landmark check is satisfied + // when SOME match carries the recorded identity, so a first impostor + // must not hide a later genuine landmark (#1349). + matchedNodes: outcome.matchedNodes, }; } return undefined; From 9d443110cf655a9cd462587a7788a2933cef4b57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 6 Aug 2026 18:03:46 +0200 Subject: [PATCH 5/6] refactor: declare only the policy fields the matrix enforces (#1649 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The occlusion / offscreenGuard / promotion / poll columns were never consumed by resolveSelectorChainWithPolicy or selectorResolutionKnobs: changing any of them left behavior and the suite green, so they were unverifiable claims that read as truth. (My earlier source-sniffing test "verified" them by grepping caller files for marker strings — which is why it also stayed green when a row was disconnected entirely.) The matrix now declares exactly what it enforces: the ambiguity contract and the rect requirement, both consumed by the resolution interface and pinned behaviorally. A new test asserts every row's field set, so an unenforceable column cannot reappear without coverage — verified by re-adding one and watching it fail. Routing the structural stages into typed behavior is tracked in #1656 with the constraint that each field must be consumed, not merely declared. --- .../src/internal/resolution-policy.ts | 53 +++++-------------- .../resolution-policy-parity.test.ts | 17 ++++++ 2 files changed, 29 insertions(+), 41 deletions(-) diff --git a/packages/selectors/src/internal/resolution-policy.ts b/packages/selectors/src/internal/resolution-policy.ts index b40619218..03880ad6d 100644 --- a/packages/selectors/src/internal/resolution-policy.ts +++ b/packages/selectors/src/internal/resolution-policy.ts @@ -21,11 +21,18 @@ import type { SelectorResolutionOptions } from './public-resolution-types.ts'; * Declaration-only: enforced by find's own narrowing logic, not by engine * knobs, so `selectorResolutionKnobs` rejects it at the type level. * - * The structural columns (`occlusion`, `offscreenGuard`, `promotion`, `poll`) - * document which pipeline hosts each policy; the pipelines live in the - * callers, and resolution-policy-parity.test.ts gate-tests these claims - * against the callers' actual imports so the matrix cannot drift into - * fiction (the ADR 0011 declared-plus-gate-tested pattern). + * Scope, deliberately narrow: this matrix declares the **ambiguity contract + * and the rect requirement**, and nothing else. Both are consumed by + * `resolveSelectorChainWithPolicy` and pinned behaviorally in + * resolution-policy-parity.test.ts, so a row that stops matching its + * documented semantics fails a test. + * + * The surrounding pipeline stages — occlusion, the off-screen guard, + * hittable-ancestor promotion, and the wait poll budget — still live in the + * callers and are NOT declared here. An earlier revision listed them as + * columns; nothing consumed them, so they were unverifiable claims that read + * as truth while being free to drift (#1649 review). Routing them into typed + * behavior is tracked in #1656. */ export type KnobBackedSelectorAmbiguity = 'disambiguate' | 'fail-closed' | 'first-match'; @@ -35,14 +42,6 @@ export type SelectorResolutionPolicy = { ambiguity: SelectorAmbiguityPolicy; /** Only nodes carrying a rect participate (acting paths need a tap point). */ requireRect: boolean; - /** Occlusion filtering / covered-target rejection runs in this pipeline. */ - occlusion: boolean; - /** The winner is checked against the viewport (with the iOS rescue probe). */ - offscreenGuard: boolean; - /** Hittable-ancestor promotion may move the dispatch point. */ - promotion: boolean; - /** Single capture (`none`) or the wait loop's poll budget. */ - poll: 'none' | 'wait-budget'; }; export const SELECTOR_RESOLUTION_POLICIES = { @@ -50,64 +49,36 @@ export const SELECTOR_RESOLUTION_POLICIES = { act: { ambiguity: 'disambiguate', requireRect: true, - occlusion: true, - offscreenGuard: true, - promotion: true, - poll: 'none', }, /** The post-miss diagnosis probe deciding "no match" vs "matched but covered". */ actCoveredDiagnosis: { ambiguity: 'first-match', requireRect: true, - occlusion: true, - offscreenGuard: false, - promotion: false, - poll: 'none', }, /** `get text` — reads through the same tiebreak acting uses. */ readText: { ambiguity: 'disambiguate', requireRect: false, - occlusion: false, - offscreenGuard: false, - promotion: false, - poll: 'none', }, /** `is` non-exists predicates and `get attrs` — ties reject, never guess. */ readUnique: { ambiguity: 'fail-closed', requireRect: false, - occlusion: false, - offscreenGuard: false, - promotion: false, - poll: 'none', }, /** `exists` and find's read-only actions — presence is the question. */ readAny: { ambiguity: 'first-match', requireRect: false, - occlusion: false, - offscreenGuard: false, - promotion: false, - poll: 'none', }, /** `wait` — first match per poll, under the wait budget. */ wait: { ambiguity: 'first-match', requireRect: false, - occlusion: false, - offscreenGuard: false, - promotion: false, - poll: 'wait-budget', }, /** Mutating `find` (#1625): candidates reject unless explicitly narrowed. */ findAct: { ambiguity: 'reject-candidates', requireRect: true, - occlusion: true, - offscreenGuard: false, - promotion: true, - poll: 'none', }, } as const satisfies Record; diff --git a/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts b/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts index c7a6412e2..2fbe34e88 100644 --- a/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts +++ b/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts @@ -146,6 +146,23 @@ test('knobs stay consistent with the ambiguity each knob-backed row names', () = } }); +/** + * The matrix may only declare what it can enforce (#1649 review). An earlier + * revision carried occlusion / off-screen / promotion / poll columns that no + * code consumed, so changing them left both behavior and the suite green — + * an unverifiable claim reading as truth. This fails if such a field returns + * without behavioral coverage. + */ +test('policy rows declare only the fields this matrix actually enforces', () => { + for (const [name, policy] of Object.entries(SELECTOR_RESOLUTION_POLICIES)) { + assert.deepEqual( + Object.keys(policy).sort(), + ['ambiguity', 'requireRect'], + `${name} declares a field the matrix cannot enforce`, + ); + } +}); + test('the documented per-caller contracts are the ones declared', () => { assert.equal(SELECTOR_RESOLUTION_POLICIES.act.ambiguity, 'disambiguate'); assert.equal(SELECTOR_RESOLUTION_POLICIES.readText.ambiguity, 'disambiguate'); From 3d151b5bf764561cce950c9d9c46dda1c60b78a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:56:41 +0000 Subject: [PATCH 6/6] fix(selectors): flatten the policy outcome at the package boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PolicyResolutionOutcome.resolution` was typed as `AstSelectorResolution` and the root façade returned it unchanged, so the parser AST #1589 confined to `@agent-device/selectors/ast` came back through a nested field. `selector-wait.ts` reading `outcome.resolution.selector.raw` was the runtime proof. The existing boundary gate reads exported *names*, so it could not see this. The public outcome now lives beside `SelectorResolution` in public-resolution-types.ts with its selector as text; the parser-side shape is renamed `AstPolicyResolutionOutcome` and stays package-private, and the façade wrapper flattens on the way out — the same treatment `resolveSelectorChain` already gave `AstSelectorResolution`. Two new pins, both verified red against the shape they replace: a behavioral one asserting the façade returns selector text under every policy row, and a structural one asserting resolution shapes are re-exported from public-resolution-types.ts rather than from a parser-side module — which is what distinguishes the leak from a correct re-export in a name list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017Rva4YGtSCAKJqH5PbpcCU --- packages/selectors/src/index.ts | 30 ++++++++++++++++--- .../src/internal/public-resolution-types.ts | 30 +++++++++++++++++++ .../src/internal/resolve-with-policy.ts | 6 ++-- scripts/layering/facade-exports.ts | 24 +++++++++++++++ scripts/layering/package-boundaries.test.ts | 21 ++++++++++++- .../resolution-policy-parity.test.ts | 25 ++++++++++++++++ .../interaction/runtime/selector-wait.ts | 2 +- 7 files changed, 129 insertions(+), 9 deletions(-) diff --git a/packages/selectors/src/index.ts b/packages/selectors/src/index.ts index 1e9e81571..54a9191a7 100644 --- a/packages/selectors/src/index.ts +++ b/packages/selectors/src/index.ts @@ -1,6 +1,7 @@ import type { SnapshotState } from '@agent-device/kernel/snapshot'; import type { Selector } from './internal/parse.ts'; import type { + PolicyResolutionOutcome, SelectorChainMatch, SelectorChainMatchList, SelectorMatchOptions, @@ -61,6 +62,7 @@ import { export type { FindAction, FindLocator } from './internal/find.ts'; export type { IsPredicate } from './internal/predicates.ts'; export type { + PolicyResolutionOutcome, SelectorChainMatchList, SelectorChainMatch, SelectorResolution, @@ -269,16 +271,36 @@ export type { SelectorResolutionPolicy, } from './internal/resolution-policy.ts'; import type { SelectorResolutionPolicy } from './internal/resolution-policy.ts'; -import type { PolicyResolutionOutcome } from './internal/resolve-with-policy.ts'; -export type { PolicyResolutionOutcome } from './internal/resolve-with-policy.ts'; -/** Public façade wrapper that accepts selector text, never an AST. */ +/** + * Public façade wrapper that accepts selector text and returns selector text — + * never an AST, in either direction. + * + * The return leg is the half that is easy to miss: the parser-side outcome + * carries the winning `Selector` node inside `resolution`, and returning it + * unchanged would put a package-private parser object back in every caller's + * hands through a nested field. The façade's own boundary gate reads exported + * *names*, so it cannot see that; `selector-wait.ts` reading + * `outcome.resolution.selector.raw` was the runtime proof it had happened. + * Flattening here is the same treatment `resolveSelectorChain` above gives + * `AstSelectorResolution` (#1589). + */ function resolveSelectorChainWithPolicy( nodes: SnapshotState['nodes'], expression: string, policy: SelectorResolutionPolicy, options: SelectorMatchOptions, ): PolicyResolutionOutcome { - return resolveSelectorChainWithPolicyAst(nodes, parseSelectorChain(expression), policy, options); + const outcome = resolveSelectorChainWithPolicyAst( + nodes, + parseSelectorChain(expression), + policy, + options, + ); + if (outcome.kind !== 'resolved') return outcome; + return { + ...outcome, + resolution: { ...outcome.resolution, selector: outcome.resolution.selector.raw }, + }; } export { resolveSelectorChainWithPolicy }; diff --git a/packages/selectors/src/internal/public-resolution-types.ts b/packages/selectors/src/internal/public-resolution-types.ts index f07dcafad..c1cf649dc 100644 --- a/packages/selectors/src/internal/public-resolution-types.ts +++ b/packages/selectors/src/internal/public-resolution-types.ts @@ -29,6 +29,36 @@ export type SelectorResolution = { disambiguation?: SelectorDisambiguationDisclosure; }; +/** + * The façade twin of the parser-side `AstPolicyResolutionOutcome`: identical + * except that the winning alternative is its raw selector text rather than the + * `Selector` node, the same flattening `SelectorResolution` applies to + * `AstSelectorResolution`. + * + * It exists as a separate declaration for the same reason that pair does + * (#1589): the parser representation is package-private, and a nested return + * type is a leak the façade's named-export gate cannot see — it filters export + * *names*, so an `AstSelectorResolution` reached indirectly through + * `outcome.resolution` would reopen the boundary silently. + */ +export type PolicyResolutionOutcome = + /** No selector alternative matched anything. */ + | { kind: 'none' } + /** + * The node this policy authorizes acting on, plus the full candidate set of + * the alternative it came from. Callers that verify identity across + * candidates (wait's #1349 landmark check) need the whole set — a policy + * that picks one winner must not throw the rest away, or a first impostor + * would hide a later genuine match. + */ + | { kind: 'resolved'; resolution: SelectorResolution; matchedNodes: SnapshotNode[] } + /** + * Several matches and the policy refuses to choose. `fail-closed` returns + * this instead of guessing; `reject-candidates` returns it so the caller can + * narrow explicitly or surface the candidate list. + */ + | { kind: 'ambiguous'; selector: string; selectorIndex: number; matchedNodes: SnapshotNode[] }; + /** The first matching selector alternative and its complete matched-node domain. */ export type SelectorChainMatchList = { selector: string; diff --git a/packages/selectors/src/internal/resolve-with-policy.ts b/packages/selectors/src/internal/resolve-with-policy.ts index b5ca1257c..2cccd7129 100644 --- a/packages/selectors/src/internal/resolve-with-policy.ts +++ b/packages/selectors/src/internal/resolve-with-policy.ts @@ -23,7 +23,7 @@ import type { SelectorResolutionPolicy } from './resolution-policy.ts'; * forced every caller to re-derive its own contract inline. */ -export type PolicyResolutionOutcome = +export type AstPolicyResolutionOutcome = /** No selector alternative matched anything. */ | { kind: 'none' } /** @@ -55,7 +55,7 @@ export function resolveSelectorChainWithPolicy( chain: SelectorChain, policy: SelectorResolutionPolicy, options: SelectorMatchOptions, -): PolicyResolutionOutcome { +): AstPolicyResolutionOutcome { const matchOptions = { ...options, requireRect: policy.requireRect }; if (policy.ambiguity === 'reject-candidates') { @@ -106,7 +106,7 @@ export function resolveSelectorChainWithPolicy( function resolvedFromList( list: NonNullable>, -): PolicyResolutionOutcome { +): AstPolicyResolutionOutcome { const node = list.matchedNodes[0]; if (!node) return { kind: 'none' }; return { diff --git a/scripts/layering/facade-exports.ts b/scripts/layering/facade-exports.ts index 957666390..007ac99c1 100644 --- a/scripts/layering/facade-exports.ts +++ b/scripts/layering/facade-exports.ts @@ -96,3 +96,27 @@ export function readDirectNamedExports(source: string): string[] { } return [...names].sort(); } + +/** + * Which module each name in `source` is re-exported FROM, for names that come + * from a re-export rather than a local declaration. + * + * A façade's export *names* are only half its boundary: a type re-exported + * from the right module and one re-exported from a package-private module read + * identically in the name list, while only the second leaks. #1649 shipped + * exactly that — a policy outcome re-exported from the parser-side module, so + * its nested `resolution` field handed callers the private AST — and the + * name-list gate stayed green throughout. + */ +export function readReExportSources(source: string): Map { + const parsed = parseSync('facade-reexport-source-scan.ts', source); + const sources = new Map(); + for (const staticExport of parsed.module.staticExports) { + for (const entry of staticExport.entries) { + if (entry.exportName.kind !== 'Name' || !entry.exportName.name) continue; + if (!entry.moduleRequest) continue; + sources.set(entry.exportName.name, entry.moduleRequest.value); + } + } + return sources; +} diff --git a/scripts/layering/package-boundaries.test.ts b/scripts/layering/package-boundaries.test.ts index c1bbc69e4..345cab3c1 100644 --- a/scripts/layering/package-boundaries.test.ts +++ b/scripts/layering/package-boundaries.test.ts @@ -7,7 +7,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; import { listSourceFiles } from './check.ts'; -import { readDirectNamedExports, readNamedExports } from './facade-exports.ts'; +import { readDirectNamedExports, readNamedExports, readReExportSources } from './facade-exports.ts'; import { checkPackageBoundaries, checkPackageInternalSites, @@ -398,6 +398,25 @@ test('the real tree parses, declares, and passes R11', () => { [], 'selectors façade keeps AST and grammar internals private', ); + // Named exports are not the whole boundary. A parser-side type reached + // through a NESTED field — `PolicyResolutionOutcome.resolution` typed as + // `AstSelectorResolution` — leaks the same objects while exporting none of + // their names, and the assertion above stays green on it (#1649). What + // separates the two is which module the type is re-exported FROM: + // `public-resolution-types.ts` holds the string-flattened shapes, + // `resolve-with-policy.ts` and `resolve.ts` hold the parser-side ones. A + // resolution type re-exported from either of the latter means a flattening + // step at the façade was skipped. + const selectorsReExports = readReExportSources( + fs.readFileSync(path.join(repoRoot, 'packages/selectors/src/index.ts'), 'utf8'), + ); + assert.deepEqual( + ['PolicyResolutionOutcome', 'SelectorResolution', 'SelectorChainMatchList'].filter( + (name) => selectorsReExports.get(name) !== './internal/public-resolution-types.ts', + ), + [], + 'selectors façade must publish resolution shapes from public-resolution-types.ts, not from the parser-side modules', + ); // The AST subpath's one in-repo consumer is the published SDK re-export. // Anything else importing it means the string-only façade was bypassed. assert.deepEqual( diff --git a/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts b/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts index 2fbe34e88..696d39631 100644 --- a/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts +++ b/src/commands/interaction/runtime/__tests__/resolution-policy-parity.test.ts @@ -65,6 +65,31 @@ test('a unique match resolves under every policy', () => { } }); +test('the façade returns selector TEXT under every policy, never a parser node', () => { + // #1589 made the root façade string-in/string-out and confined parser + // objects to `@agent-device/selectors/ast`. A nested return type reopens + // that boundary invisibly: the package-boundary gate filters exported + // *names*, so `PolicyResolutionOutcome.resolution` typed as the AST shape + // stayed green while production read `outcome.resolution.selector.raw`. + // Every row, and both branches that carry a selector. + for (const name of Object.keys( + SELECTOR_RESOLUTION_POLICIES, + ) as (keyof typeof SELECTOR_RESOLUTION_POLICIES)[]) { + const resolved = outcomeFor(name, UNIQUE_TREE); + assert.equal(resolved.kind, 'resolved', name); + if (resolved.kind === 'resolved') { + assert.equal(typeof resolved.resolution.selector, 'string', name); + assert.equal(resolved.resolution.selector, 'label="Save"', name); + } + const ambiguous = outcomeFor(name, AMBIGUOUS_TREE); + if (ambiguous.kind === 'ambiguous') { + assert.equal(typeof ambiguous.selector, 'string', name); + } else if (ambiguous.kind === 'resolved') { + assert.equal(typeof ambiguous.resolution.selector, 'string', name); + } + } +}); + test('no match resolves to none under every policy', () => { for (const name of Object.keys( SELECTOR_RESOLUTION_POLICIES, diff --git a/src/commands/interaction/runtime/selector-wait.ts b/src/commands/interaction/runtime/selector-wait.ts index 1c9db1918..423373d15 100644 --- a/src/commands/interaction/runtime/selector-wait.ts +++ b/src/commands/interaction/runtime/selector-wait.ts @@ -45,7 +45,7 @@ function policyMatchList(outcome: PolicyResolutionOutcome): SelectorChainMatchLi } if (outcome.kind === 'resolved') { return { - selector: outcome.resolution.selector.raw, + selector: outcome.resolution.selector, selectorIndex: outcome.resolution.selectorIndex, // Every candidate, not just the winner: the landmark check is satisfied // when SOME match carries the recorded identity, so a first impostor