diff --git a/src/daemon/__tests__/selector-runtime-ambiguity-issuance.test.ts b/src/daemon/__tests__/selector-runtime-ambiguity-issuance.test.ts index 147d7585ec..55c1c62f11 100644 --- a/src/daemon/__tests__/selector-runtime-ambiguity-issuance.test.ts +++ b/src/daemon/__tests__/selector-runtime-ambiguity-issuance.test.ts @@ -26,7 +26,10 @@ import { handleInteractionCommands } from '../interaction/index.ts'; // resolving against the earlier tree, where the same body names a different // node. The candidate either acts on the listed node or is refused. -const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn() })); +const { mockRunAppleRunnerCommand, mockCaptureSnapshotWithInteractor } = vi.hoisted(() => ({ + mockRunAppleRunnerCommand: vi.fn(), + mockCaptureSnapshotWithInteractor: vi.fn(), +})); vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); @@ -38,9 +41,10 @@ vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { }; }); -vi.mock('../snapshot-interactor-capture.ts', () => ({ - captureSnapshotWithInteractor: vi.fn(), -})); +vi.mock('../snapshot-interactor-capture.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, captureSnapshotWithInteractor: mockCaptureSnapshotWithInteractor }; +}); vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { const actual = @@ -52,6 +56,7 @@ beforeEach(() => { resetGetRuntimeFixture(); mockRunAppleRunnerCommand.mockReset(); mockRunAppleRunnerCommand.mockResolvedValue({}); + mockCaptureSnapshotWithInteractor.mockReset(); }); function isRequest(session: string, positionals: readonly string[]): DaemonRequest { @@ -233,3 +238,78 @@ test('an ambiguous is on a sparse capture prints no candidates and issues nothin expect(session.snapshot!.nodes).toBe(storedNodes); expect(refFrameScope(session)).toBe('all'); }); + +/** + * The acting route's own coverage of the issuance rule: an ambiguous + * `press` (never an `is`-minted ref) must ITSELF issue the candidates it + * prints. Deleting the touch runtime's consumed-capture slot would silently + * degrade every press/fill ambiguity to count-only if nothing pinned this; + * the `is` tests above travel the selector-route seam, not this one. + */ +test('an ambiguous press issues its printed candidates from its own capture', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'press-ambiguity-issuance'; + const session = makeStaleRefSession(sessionName); + session.device = IOS_SIMULATOR; + sessionStore.publish(sessionName, session); + + // The press's own capture: two same-label buttons in DISTINCT branches, so + // the acting pipeline's candidate classification refuses to collapse them + // and throws AMBIGUOUS_MATCH with candidate lines. Raw nodes: the capture + // path attaches refs itself (e1 root, e2/e3 the buttons). + mockCaptureSnapshotWithInteractor.mockResolvedValue({ + nodes: [ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, + { + index: 1, + parentIndex: 0, + type: 'XCUIElementTypeButton', + label: 'Deploy', + rect: { x: 300, y: 300, width: 20, height: 20 }, + enabled: true, + hittable: true, + }, + { + index: 2, + parentIndex: 0, + type: 'XCUIElementTypeButton', + label: 'Deploy', + rect: { x: 500, y: 500, width: 20, height: 20 }, + enabled: true, + hittable: true, + }, + ], + backend: 'xctest', + producer: 'apple-runner', + }); + + const response = await handleInteractionCommands({ + req: { + token: 't', + session: sessionName, + command: 'press', + positionals: ['label="Deploy"'], + flags: {}, + }, + sessionName, + sessionStore, + contextFromFlags, + ...getRuntimeBindings(), + }); + + expect(response?.ok).toBe(false); + if (!response || response.ok) throw new Error('expected the acting ambiguity refusal'); + expect(response.error.code).toBe('AMBIGUOUS_MATCH'); + // The acting refusal owes the same contract the read refusal gives: the + // printed candidates are ISSUED refs pinned to the generation of the + // capture the press itself consumed, not a count-only degradation. + const refsGeneration = response.error.details?.refsGeneration as number; + expect(typeof refsGeneration).toBe('number'); + const candidates = response.error.details?.candidates as string[]; + expect(candidates.length).toBeGreaterThan(0); + expect([...refFrameScope(session)].sort()).toEqual( + candidates.map((line) => /^@(e\d+)/.exec(line)![1]), + ); + // Nothing was tapped: the refusal preceded dispatch. + expect(mockTapPoint).not.toHaveBeenCalled(); +}); diff --git a/src/daemon/session-snapshot.ts b/src/daemon/session-snapshot.ts index c8b6d6379c..503d35c840 100644 --- a/src/daemon/session-snapshot.ts +++ b/src/daemon/session-snapshot.ts @@ -140,12 +140,16 @@ export function issueSettleRefs( /** * ADR 0014's issuance rule for ambiguity refusals: a response that prints * candidate `@ref`s must be able to ISSUE them, and issuance is only ever - * valid against the tree the session's generation describes. Every route - * that can answer `AMBIGUOUS_MATCH` with candidates runs its error through - * here — the acting touch runtime (`press`/`click`/`fill`) and the strict-read - * dispatches (`is`, `get attrs`) — so the rule has - * one implementation beside the partial-frame primitive it wraps, exactly - * like {@link issueSettleRefs}. + * valid against the tree the session's generation describes. The routes that + * owe that contract run their error through here — the acting touch runtime + * (`press`/`click`/`fill`) and the strict-read dispatches (`is`, `get attrs`) + * — so the rule has one implementation beside the partial-frame primitive it + * wraps, exactly like {@link issueSettleRefs}. The `find` refusal + * (`buildAmbiguousMatchError`) deliberately does NOT consume this rule: it + * predates it (#1597), prints no `refsGeneration`, and its hint routes the + * caller to narrow the locator; unlike this rule, it does not pin the printed + * candidate refs to a generation. Making those refs issuable is a separate + * contract decision. * * The two branches are one decision, not two features: * - The request's consumed capture IS the session's stored tree (node