Skip to content

Commit d81ac0a

Browse files
authored
fix(ios): corroborate recorded tap outcomes (#1605)
* fix(ios): corroborate recorded tap outcomes * fix(ios): preserve corroborated tap target identity * fix(ios): suppress corroborated tap retries * fix: require comparable iOS tap evidence * fix: bound iOS tap corroboration baseline * test(ios): deterministic injection seam for recorded-tap-failure corroboration (#1605 merge gate) The field failure cannot be reproduced on this head: the tap false-failures were a downstream symptom of XCTest-channel saturation, which the #1587 capture fixes removed. The seam records a real XCTIssue AFTER the real gesture inside the per-command failure-count window, so xctestRecordedFailureResponse and target invalidation fire byte-for-byte like the field failure. Armed via a decrementing /tmp flag file (the daemon regenerates tampered xctestrun templates, so env plumbing cannot reach a daemon-spawned runner); compiled only under AGENT_DEVICE_RUNNER_UNIT_TESTS. Live evidence on a daemon-spawned runner (Bluesky, ad-bsky-repro sim): - landed case: injected failure on a real Search-tab tap -> success with the corroboration warning, screen verifiably on Search, no redispatch, runner serving next commands; flag consumed exactly once. - unchanged case: injected failure on a dead-coordinate tap -> capture unchanged -> XCTEST_RECORDED_FAILURE preserved with the new honest hint; runner still usable. - field-shape race (relaunch -> full snapshot -> immediate press, 5 attempts): no natural recorded failure occurs on this head — the hostile tree needed for channel saturation is gone, corroborating the causal story. * fix: reconcile tap corroboration with current interaction semantics
1 parent 3b1431c commit d81ac0a

15 files changed

Lines changed: 1130 additions & 27 deletions

CONTEXT.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,11 @@ the observable freshness and failure semantics below before any runtime refactor
431431
- iOS sparse and AX failures are not proof of empty UI. Regular visible snapshots can recover through
432432
the capture plan; raw and strict paths preserve failure. `runnerFatal` invalidates the cached target
433433
and must never refresh healthy mutation recency.
434+
- An `XCTEST_RECORDED_FAILURE` after an iOS tap is an ambiguous outcome, not proof that the tap missed.
435+
The daemon may take one same-presentation post-action capture against a usable retained snapshot;
436+
only a changed accessibility digest converts the result to success with a warning. Capture failure,
437+
sparse or mismatched presentation, and an unchanged digest remain failures so corroboration cannot
438+
turn an unknown tap into a false success.
434439
- Android helper reuse must not become snapshot result caching. Freshness is short lived, marked only
435440
after navigation-sensitive actions, compared against broad route-safe baselines, and not learned
436441
from scoped, depth-limited, interactive, or ref-refresh snapshots.

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,16 @@ extension RunnerTests {
439439
}
440440
#endif
441441

442+
func testInjectedTapRecordedFailureGateIsTapOnlyAndCountGated() {
443+
// The seam's recording side cannot run in-bundle (a real XCTIssue would
444+
// fail this very test run — same constraint the record(_:) suppression
445+
// tests document); the live daemon proof covers it. This pins the gate.
446+
XCTAssertFalse(RunnerTests.shouldInjectTapRecordedFailure(command: .tap, remaining: 0))
447+
XCTAssertTrue(RunnerTests.shouldInjectTapRecordedFailure(command: .tap, remaining: 1))
448+
XCTAssertFalse(RunnerTests.shouldInjectTapRecordedFailure(command: .type, remaining: 1))
449+
XCTAssertFalse(RunnerTests.shouldInjectTapRecordedFailure(command: .snapshot, remaining: 1))
450+
}
451+
442452
func testXCTestRecordedFailureResponseFailsMutatingSuccesses() throws {
443453
let command = try runnerCommandFixture(#"{"command":"tap","commandId":"tap-1"}"#)
444454
let response = Response(ok: true, data: DataPayload(message: "tapped"))
@@ -1227,6 +1237,20 @@ extension RunnerTests {
12271237
userInfo: [NSLocalizedDescriptionKey: "command returned no response"]
12281238
)
12291239
}
1240+
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
1241+
// #1605 merge gate: the REAL gesture already executed above; recording a
1242+
// production-shaped issue here makes the per-command failure-count
1243+
// conversion below fire exactly as in the field (bsky-24: activation
1244+
// lands, bookkeeping records a failure). Compiled out of production.
1245+
if consumeInjectedTapRecordedFailureForTesting(command: command.command) {
1246+
record(
1247+
XCTIssue(
1248+
type: .assertionFailure,
1249+
compactDescription: "Injected tap recorded-failure (#1605 corroboration merge gate)"
1250+
)
1251+
)
1252+
}
1253+
#endif
12301254
if didRecordXCTestFailure(since: failureCountBefore),
12311255
let failureResponse = xctestRecordedFailureResponse(command: command, response: response)
12321256
{
@@ -2481,7 +2505,7 @@ extension RunnerTests {
24812505
error: ErrorPayload(
24822506
code: "XCTEST_RECORDED_FAILURE",
24832507
message: "XCTest recorded a failure while executing \(command.command.rawValue); the action may not have been performed.",
2484-
hint: "The iOS runner session will be restarted. Retry after a fresh snapshot, or use screenshot plus coordinate commands when the accessibility tree is unavailable."
2508+
hint: "The iOS runner session was invalidated. Re-observe with a fresh snapshot before retrying; if the accessibility tree is unavailable, use screenshot plus coordinate commands instead of retrying the tap blindly."
24852509
)
24862510
)
24872511
}

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Lifecycle.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ extension RunnerTests {
103103

104104
func resetTargetAfterExternalRelaunch() -> Response {
105105
invalidateCachedTarget(reason: "external_app_relaunch")
106+
// The app process is replaced, but the retained runner survives. Clear
107+
// process-bound capture state explicitly because invalidation drops the
108+
// old PID before refreshCachedTargetIfProcessChanged can observe it.
109+
clearSnapshotXCTestChannelPenalty(reason: "external_app_relaunch")
110+
clearPrivateAXAcceptedDepth(reason: "external_app_relaunch")
106111
needsFirstInteractionDelay = true
107112
return Response(ok: true, data: DataPayload(message: "target reset"))
108113
}

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+LifecycleCacheTests.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ extension RunnerTests {
134134
currentAppProcessIdentifier = 42
135135
snapshotXCTestPenaltyWarmupExemptionPending = true
136136
needsFirstInteractionDelay = false
137+
penalizeSnapshotXCTestChannel(bundleId: "com.example.app", reason: "test")
138+
XCTAssertTrue(isSnapshotXCTestChannelPenalized(bundleId: "com.example.app"))
137139

138140
let response = resetTargetAfterExternalRelaunch()
139141

@@ -142,6 +144,7 @@ extension RunnerTests {
142144
XCTAssertNil(currentBundleId)
143145
XCTAssertNil(currentAppProcessIdentifier)
144146
XCTAssertFalse(snapshotXCTestPenaltyWarmupExemptionPending)
147+
XCTAssertFalse(isSnapshotXCTestChannelPenalized(bundleId: "com.example.app"))
145148
XCTAssertTrue(needsFirstInteractionDelay)
146149
}
147150
#endif

apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,42 @@ final class RunnerTests: XCTestCase {
120120
// seconds on remote-hosted consent dialogs and bypass the plan budget (#1244).
121121
let systemModalProbeBudget: TimeInterval = 4
122122
#if AGENT_DEVICE_RUNNER_UNIT_TESTS
123+
// #1605 merge gate: deterministic live reproduction of the field ambiguity —
124+
// a tap whose coordinate activation LANDS while XCTest bookkeeping records a
125+
// failure. Armed by writing a decrementing count to the flag file below
126+
// (the daemon regenerates tampered xctestrun templates, so env plumbing
127+
// cannot reach a daemon-spawned runner); consumed one injection per tap.
128+
// The injection records a real XCTIssue AFTER the real gesture, so
129+
// `xctestRecordedFailureResponse` and target invalidation fire byte-for-byte
130+
// like a field failure. Production builds compile none of this.
131+
static let injectedTapFailureFlagPathForTesting =
132+
"/tmp/agent-device-inject-tap-recorded-failure-for-testing"
133+
134+
static func shouldInjectTapRecordedFailure(command: CommandType, remaining: Int) -> Bool {
135+
command == .tap && remaining > 0
136+
}
137+
138+
func consumeInjectedTapRecordedFailureForTesting(command: CommandType) -> Bool {
139+
guard
140+
let raw = try? String(
141+
contentsOfFile: Self.injectedTapFailureFlagPathForTesting,
142+
encoding: .utf8
143+
),
144+
let remaining = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
145+
else {
146+
return false
147+
}
148+
guard Self.shouldInjectTapRecordedFailure(command: command, remaining: remaining) else {
149+
return false
150+
}
151+
try? String(remaining - 1).write(
152+
toFile: Self.injectedTapFailureFlagPathForTesting,
153+
atomically: true,
154+
encoding: .utf8
155+
)
156+
return true
157+
}
158+
123159
// Unit-test-only injectable override for the system-modal probe (see
124160
// `boundedBlockingSystemAlertSnapshot` in RunnerTests+Snapshot.swift): when set, a test's probe
125161
// body runs in place of `blockingSystemAlertSnapshot` so it can force a real timeout without a

docs/adr/0005-ios-runner-interaction-lifecycle.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ normal activation path.
6464

6565
An external iOS simulator relaunch also invalidates process-bound target state. After replacing the
6666
app process, the daemon sends a lifecycle reset to the retained runner so the next command reacquires
67-
`XCUIApplication`; if that reset cannot be confirmed, the daemon discards the runner session.
67+
`XCUIApplication`; the reset also clears process-bound snapshot penalty and private-AX depth state. If
68+
that reset cannot be confirmed, the daemon discards the runner session.
6869

6970
The snapshot surface intentionally has two AX-failure shapes. Interactive fast snapshots return a
7071
truncated success payload with `runnerFatal` so agents can still see that AX state is unavailable
@@ -93,8 +94,15 @@ Apps with broken accessibility trees may still be impossible for XCTest to inspe
9394
failed snapshot no longer teaches the runner to keep using a suspect cached app target or to amplify
9495
the failure by walking every interactive element query.
9596

97+
An `XCTEST_RECORDED_FAILURE` returned after a tap is treated as an ambiguous outcome at the daemon
98+
boundary. When a usable retained snapshot exists, the daemon takes one same-presentation post-action
99+
capture. A changed accessibility digest is reported as success with an explicit warning so agents do
100+
not blindly repeat a tap that may already have navigated; unchanged, sparse, mismatched, or unavailable
101+
evidence remains the original failure.
102+
96103
Simulator relaunch keeps the healthy XCTest process warm without carrying an app target across
97-
process identity. The reset adds one local runner request instead of paying for a runner restart.
104+
process identity. The reset adds one local runner request instead of paying for a runner restart and
105+
clears the old process's hostile-screen capture penalty before the replacement is reacquired.
98106

99107
Future optimization work should only reduce these preflights after the runner exposes status in a
100108
way that survives command-induced XCTest teardown and can prove the session is still serving new

src/commands/interaction/runtime/interactions.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { isFillableType } from '@agent-device/contracts/snapshot';
1111
import { successText } from '../../../utils/success-text.ts';
1212
import { findMistargetedTypeRefToken } from '../../../utils/type-target-warning.ts';
1313
import { requireIntInRange } from '../../../utils/validation.ts';
14+
import { attachResolvedInteractionTarget } from '../../../contracts/interaction-outcome.ts';
1415
import type { RepeatedInput } from '../../command-input.ts';
1516
import { toBackendContext } from '../../runtime-common.ts';
1617
import {
@@ -196,14 +197,23 @@ async function tapCommand(
196197
throw new AppError('UNSUPPORTED_OPERATION', 'tap is not supported by this backend');
197198
}
198199
const point = requireResolvedPoint(resolved);
199-
const backendResult = await runtime.backend.tap(toBackendContext(runtime, options), point, {
200-
button: options.button,
201-
count: options.count,
202-
intervalMs: options.intervalMs,
203-
holdMs: options.holdMs,
204-
jitterPx: options.jitterPx,
205-
doubleTap: options.doubleTap,
206-
});
200+
let backendResult;
201+
try {
202+
backendResult = await runtime.backend.tap(toBackendContext(runtime, options), point, {
203+
button: options.button,
204+
count: options.count,
205+
intervalMs: options.intervalMs,
206+
holdMs: options.holdMs,
207+
jitterPx: options.jitterPx,
208+
doubleTap: options.doubleTap,
209+
});
210+
} catch (error) {
211+
// Resolution is complete before the backend call. Preserve it out of
212+
// band so a daemon-level failure corroboration can still record the same
213+
// target identity if the backend reports an ambiguous tap outcome.
214+
attachResolvedInteractionTarget(error, resolved);
215+
throw error;
216+
}
207217
const formattedBackendResult = toBackendResult(backendResult);
208218
return await applyPostActionObservation(
209219
runtime,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { ResolvedInteractionTarget } from '@agent-device/contracts/interaction';
2+
3+
// The runtime can resolve an interaction before the backend reports a failure.
4+
// Keep that resolution out of serialized error details while making it
5+
// available to the daemon's failure-corroboration boundary.
6+
const resolvedInteractionTargets = new WeakMap<object, ResolvedInteractionTarget>();
7+
8+
/** Preserve pre-dispatch target identity across a backend rejection. */
9+
export function attachResolvedInteractionTarget(
10+
error: unknown,
11+
target: ResolvedInteractionTarget,
12+
): void {
13+
if (isObjectLike(error)) resolvedInteractionTargets.set(error, target);
14+
}
15+
16+
/** Read the target captured before a backend interaction rejection. */
17+
export function readResolvedInteractionTarget(
18+
error: unknown,
19+
): ResolvedInteractionTarget | undefined {
20+
return isObjectLike(error) ? resolvedInteractionTargets.get(error) : undefined;
21+
}
22+
23+
function isObjectLike(value: unknown): value is object {
24+
return (typeof value === 'object' && value !== null) || typeof value === 'function';
25+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { RawSnapshotNode } from '@agent-device/kernel/snapshot';
2+
import { buildSnapshotState } from '../snapshot-capture.ts';
3+
4+
export const profileNodes: RawSnapshotNode[] = [
5+
{
6+
index: 0,
7+
type: 'Application',
8+
label: 'Profile',
9+
rect: { x: 0, y: 0, width: 390, height: 844 },
10+
},
11+
{
12+
index: 1,
13+
parentIndex: 0,
14+
type: 'Button',
15+
identifier: 'unfollow',
16+
label: 'Unfollow',
17+
rect: { x: 24, y: 200, width: 160, height: 44 },
18+
hittable: true,
19+
},
20+
];
21+
22+
export const imageViewerNodes: RawSnapshotNode[] = [
23+
{
24+
index: 0,
25+
type: 'Application',
26+
label: 'Image viewer',
27+
rect: { x: 0, y: 0, width: 390, height: 844 },
28+
},
29+
{
30+
index: 1,
31+
parentIndex: 0,
32+
type: 'Button',
33+
identifier: 'close-image',
34+
label: 'Close image',
35+
rect: { x: 24, y: 40, width: 120, height: 44 },
36+
hittable: true,
37+
},
38+
];
39+
40+
export function snapshot(nodes: RawSnapshotNode[]) {
41+
return buildSnapshotState(
42+
{
43+
nodes,
44+
backend: 'xctest',
45+
quality: { state: 'healthy', backend: 'tree' },
46+
},
47+
{ snapshotInteractiveOnly: false },
48+
);
49+
}
50+
51+
export function snapshotPayload(
52+
nodes: RawSnapshotNode[],
53+
backend: 'tree' | 'queries' | 'private-ax' = 'tree',
54+
) {
55+
return {
56+
backend: 'xctest' as const,
57+
nodes,
58+
quality: { state: 'healthy' as const, backend },
59+
};
60+
}

0 commit comments

Comments
 (0)