Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dcbbd4e
feat: add point inspection command
csark0812 Sep 27, 2026
1d9ecda
refactor: keep new commands within eager closure budgets
csark0812 Sep 27, 2026
471df4a
docs: keep workflow help compact
csark0812 Sep 27, 2026
562d471
fix(apple): bound point inspection on system UI
csark0812 Sep 28, 2026
a55892e
fix(apple): ignore owning app in point inspection
csark0812 Sep 28, 2026
008bd12
fix: preserve point inspection contracts
csark0812 Sep 28, 2026
c29d2c4
fix: update inspect-point owner attribution
csark0812 Sep 28, 2026
9818f10
style: format command registry owner path
csark0812 Sep 28, 2026
f92d819
fix: retain public type suppression notes
csark0812 Sep 28, 2026
abccb58
docs: clarify public type re-exports
csark0812 Sep 28, 2026
ae66184
fix: share point positional parsing
csark0812 Sep 28, 2026
d661e22
fix: contain point inspection AX requests
csark0812 Sep 28, 2026
5eae246
fix: reject incomplete point inspection captures
csark0812 Sep 28, 2026
aaf82e1
fix: distinguish blocked private AX snapshot reads
csark0812 Sep 28, 2026
f258c2a
test: harden point inspection coverage
csark0812 Sep 28, 2026
eb2bef0
fix(apple): preserve point inspection text priority
csark0812 Sep 28, 2026
2d12842
test: fix point inspection ordering assertion
csark0812 Sep 28, 2026
d5ab5cc
fix: pass inspect-point validation gates
csark0812 Sep 29, 2026
0560c82
Merge remote-tracking branch 'upstream/main' into chris/agent/inspect…
csark0812 Sep 29, 2026
c5f0dce
fix(ios): use contained AX reads for text values
csark0812 Sep 29, 2026
27d95de
fix(apple): keep bounded point text semantics
csark0812 Sep 29, 2026
612f054
fix(apple): preserve legacy get-text behavior
csark0812 Sep 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionCallsKey;
FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionNodesAddedKey;
FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionPendingKey;
FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionMissedKey;
FOUNDATION_EXPORT NSString *const RunnerAXSnapshotDeepExtensionBlockedKey;

/// Keys of the `customActions` dictionary in the snapshot response: how many
/// merged elements were eligible for an action read, and how many the bounded
Expand Down Expand Up @@ -112,6 +113,12 @@ FOUNDATION_EXPORT NSString *const RunnerAXSnapshotCustomActionsBlockedKey;
/// Total reads refused by single-flight admission.
+ (NSInteger)customActionReadBlockedCount;

/// Contained snapshot-request counters, exposed so the unit bundle can pin the
/// no-queued-work invariant for a wedged private AX snapshot request.
+ (NSInteger)snapshotReadsInFlight;
+ (NSInteger)snapshotReadDispatchCount;
+ (NSInteger)snapshotReadBlockedCount;

/// The shared AX client (`XCUIDevice.accessibilityInterface`), or nil when the
/// private interface is unavailable.
+ (nullable id)accessibilityClient;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
NSString *const RunnerAXSnapshotDeepExtensionNodesAddedKey = @"nodesAdded";
NSString *const RunnerAXSnapshotDeepExtensionPendingKey = @"pendingFrontiers";
NSString *const RunnerAXSnapshotDeepExtensionMissedKey = @"missedFrontiers";
NSString *const RunnerAXSnapshotDeepExtensionBlockedKey = @"blockedFrontiers";

NSString *const RunnerAXSnapshotCustomActionsKey = @"customActions";
NSString *const RunnerAXSnapshotCustomActionsReadKey = @"read";
Expand All @@ -30,6 +31,25 @@
/// capture's budget and starve every remaining candidate, turning a bounded
/// enrichment into a capture-length stall.
static const NSTimeInterval RunnerAXCustomActionReadTimeout = 1.0;
static const NSTimeInterval RunnerAXSnapshotReadTimeout = 2.0;

/// Snapshot requests are synchronous private-XPC calls too. Keep them off the
/// XCTest main thread and refuse a second request while a timed-out call is
/// still outstanding; the client call itself cannot be cancelled.
static dispatch_queue_t RunnerAXSnapshotReadQueue(void)
{
static dispatch_queue_t queue;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
queue = dispatch_queue_create("com.callstack.agentdevice.runner.ax-snapshot",
DISPATCH_QUEUE_SERIAL);
});
return queue;
}

static atomic_int RunnerAXSnapshotReadsInFlight = 0;
static atomic_long RunnerAXSnapshotReadDispatches = 0;
static atomic_long RunnerAXSnapshotReadBlocked = 0;

/// The AX call is a synchronous XPC round trip that cannot be cancelled once
/// issued, so the deadline above only frees the CALLER — the call itself keeps
Expand Down Expand Up @@ -85,6 +105,16 @@ static dispatch_queue_t RunnerAXCustomActionReadQueue(void)
@implementation RunnerAXSnapshotFrontier
@end

@interface RunnerAXSnapshotBridge ()
+ (nullable id)requestSnapshotContainedFromClient:(id)axClient
target:(id)target
attributes:(NSArray *)attributes
maxDepth:(NSInteger)maxDepth
maxNodes:(NSInteger)maxNodes
deadline:(nullable NSDate *)deadline
error:(NSError **)error;
@end

@implementation RunnerAXSnapshotBridge

+ (NSDictionary<NSString *, id> *)snapshotTreeForApplication:(XCUIApplication *)application
Expand Down Expand Up @@ -127,12 +157,13 @@ @implementation RunnerAXSnapshotBridge
@try {
NSArray *attributes = [self snapshotAttributes];
NSError *error = nil;
id root = [self requestSnapshotFromClient:axClient
target:target
attributes:attributes
maxDepth:maxDepth
maxNodes:maxNodes
error:&error];
id root = [self requestSnapshotContainedFromClient:axClient
target:target
attributes:attributes
maxDepth:maxDepth
maxNodes:maxNodes
deadline:deadline
error:&error];
if (nil == root) {
return [self failure:error.localizedDescription ?: @"AX snapshot request returned nil"];
}
Expand Down Expand Up @@ -222,10 +253,11 @@ + (nullable NSDictionary *)extendSnapshotFrontiers:(NSMutableArray<RunnerAXSnaps
NSInteger callsUsed = 0;
NSInteger nodesAdded = 0;
// A frontier whose live element vanished (list churn between serialization
// and extension) or whose re-rooted request failed leaves its subtree
// unresolved — it must count as missed, never as drained, or an all-miss
// extension would present a capped capture as complete.
// and extension) leaves an unresolved subtree and counts as missed. A
// contained request failure is tracked separately as blocked so it cannot be
// misreported as a benign depth limit.
NSInteger missedFrontiers = 0;
NSInteger blockedFrontiers = 0;
while (frontiers.count > 0) {
if (callsUsed >= callsAllowed || *nodeCount >= maxNodes
|| (nil != deadline && deadline.timeIntervalSinceNow <= 0)) {
Expand All @@ -242,13 +274,22 @@ + (nullable NSDictionary *)extendSnapshotFrontiers:(NSMutableArray<RunnerAXSnaps
}
callsUsed += 1;
NSError *error = nil;
id subRoot = [self requestSnapshotFromClient:axClient
target:element
attributes:attributes
maxDepth:maxDepth
maxNodes:maxNodes - *nodeCount
error:&error];
id subRoot = [self requestSnapshotContainedFromClient:axClient
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
target:element
attributes:attributes
maxDepth:maxDepth
maxNodes:maxNodes - *nodeCount
deadline:deadline
error:&error];
if (nil == subRoot) {
if (nil != error) {
blockedFrontiers += 1;
[frontiers insertObject:frontier atIndex:0];
*truncated = YES;
NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_DEEP_EXTENSION_BLOCKED=%@",
error.localizedDescription ?: @"contained snapshot request failed");
break;
}
missedFrontiers += 1;
NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_DEEP_EXTENSION_MISS=%@",
error.localizedDescription ?: @"nil subtree");
Expand Down Expand Up @@ -281,13 +322,15 @@ + (nullable NSDictionary *)extendSnapshotFrontiers:(NSMutableArray<RunnerAXSnaps
[frontiers addObjectsFromArray:[self cappedFrontiersFromCandidates:subCandidates
maxDepth:maxDepth]];
}
NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_DEEP_EXTENSION calls=%ld nodes=%ld pending=%ld missed=%ld",
(long)callsUsed, (long)nodesAdded, (long)frontiers.count, (long)missedFrontiers);
NSLog(@"AGENT_DEVICE_RUNNER_PRIVATE_AX_DEEP_EXTENSION calls=%ld nodes=%ld pending=%ld missed=%ld blocked=%ld",
(long)callsUsed, (long)nodesAdded, (long)frontiers.count, (long)missedFrontiers,
(long)blockedFrontiers);
return @{
RunnerAXSnapshotDeepExtensionCallsKey: @(callsUsed),
RunnerAXSnapshotDeepExtensionNodesAddedKey: @(nodesAdded),
RunnerAXSnapshotDeepExtensionPendingKey: @(frontiers.count),
RunnerAXSnapshotDeepExtensionMissedKey: @(missedFrontiers),
RunnerAXSnapshotDeepExtensionBlockedKey: @(blockedFrontiers),
};
}

Expand Down Expand Up @@ -358,6 +401,75 @@ + (nullable id)requestSnapshotFromClient:(id)axClient
return root ?: result;
}

+ (nullable id)requestSnapshotContainedFromClient:(id)axClient
target:(id)target
attributes:(NSArray *)attributes
maxDepth:(NSInteger)maxDepth
maxNodes:(NSInteger)maxNodes
deadline:(nullable NSDate *)deadline
error:(NSError **)error
{
if (nil != deadline && deadline.timeIntervalSinceNow <= 0) {
if (NULL != error) {
*error = [NSError errorWithDomain:@"agent-device.runner" code:4 userInfo:@{
NSLocalizedDescriptionKey: @"AX snapshot deadline expired before request dispatch"
}];
}
return nil;
}
int expected = 0;
if (!atomic_compare_exchange_strong(&RunnerAXSnapshotReadsInFlight, &expected, 1)) {
atomic_fetch_add(&RunnerAXSnapshotReadBlocked, 1);
if (NULL != error) {
*error = [NSError errorWithDomain:@"agent-device.runner" code:2 userInfo:@{
NSLocalizedDescriptionKey: @"A previous AX snapshot request is still outstanding"
}];
}
return nil;
}

atomic_fetch_add(&RunnerAXSnapshotReadDispatches, 1);
NSMutableArray *resultBox = [NSMutableArray array];
NSMutableArray *errorBox = [NSMutableArray array];
dispatch_semaphore_t finished = dispatch_semaphore_create(0);
dispatch_async(RunnerAXSnapshotReadQueue(), ^{
NSError *requestError = nil;
@try {
id result = [self requestSnapshotFromClient:axClient
target:target
attributes:attributes
maxDepth:maxDepth
maxNodes:maxNodes
error:&requestError];
if (nil != result) [resultBox addObject:result];
} @catch (NSException *exception) {
requestError = [NSError errorWithDomain:@"agent-device.runner" code:5 userInfo:@{
NSLocalizedDescriptionKey: exception.reason ?: exception.name ?: @"AX snapshot request failed"
}];
} @finally {
if (nil != requestError) [errorBox addObject:requestError];
atomic_store(&RunnerAXSnapshotReadsInFlight, 0);
dispatch_semaphore_signal(finished);
}
});

NSTimeInterval remaining = deadline ? [deadline timeIntervalSinceNow] : RunnerAXSnapshotReadTimeout;
remaining = MIN(RunnerAXSnapshotReadTimeout, MAX(0, remaining));
long waited = dispatch_semaphore_wait(
finished,
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_SEC)));
if (waited != 0) {
if (NULL != error) {
*error = [NSError errorWithDomain:@"agent-device.runner" code:3 userInfo:@{
NSLocalizedDescriptionKey: @"AX snapshot request exceeded its contained deadline"
}];
}
return nil;
}
if (NULL != error) *error = errorBox.firstObject;
return resultBox.firstObject;
}

+ (NSArray *)snapshotAttributes
{
NSArray<NSString *> *keyPaths = @[
Expand Down Expand Up @@ -649,6 +761,21 @@ + (NSInteger)customActionReadBlockedCount
return (NSInteger)atomic_load(&RunnerAXCustomActionReadBlocked);
}

+ (NSInteger)snapshotReadsInFlight
{
return atomic_load(&RunnerAXSnapshotReadsInFlight);
}

+ (NSInteger)snapshotReadDispatchCount
{
return (NSInteger)atomic_load(&RunnerAXSnapshotReadDispatches);
}

+ (NSInteger)snapshotReadBlockedCount
{
return (NSInteger)atomic_load(&RunnerAXSnapshotReadBlocked);
}

+ (nullable id)accessibilityClient
{
return [self objectFrom:XCUIDevice.sharedDevice selectorName:@"accessibilityInterface"];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ extension RunnerTests {
guard let pendingFrontiers, let missedFrontiers else { return true }
return pendingFrontiers > 0 || missedFrontiers > 0
}

static func privateAXContainmentFailure(blockedFrontiers: Int?) -> SnapshotCaptureFailure? {
guard let blockedFrontiers, blockedFrontiers > 0 else { return nil }
return SnapshotCaptureFailure(
code: "IOS_SNAPSHOT_AX_CONTAINMENT_FAILED",
message: "The private AX snapshot could not complete a contained accessibility read.",
hint: "Retry after the accessibility server responds; no partial tree was treated as complete."
)
}
/// Deep React Native trees make the AX server reject bulk snapshot requests outright with
/// kAXErrorIllegalArgument once the requested depth crosses a tree-size-dependent limit
/// (observed between depth 56 and 64 on the Bluesky Home feed; the limit moves with live
Expand Down Expand Up @@ -165,7 +174,7 @@ extension RunnerTests {
target: SnapshotCaptureTarget,
hint: CaptureHint,
deadline: Date = .distantFuture
) -> SnapshotAcquisition? {
) throws -> SnapshotAcquisition? {
#if os(iOS) && targetEnvironment(simulator)
let app = target.app
let requestedDepth = hint.rawTraversalDepth ?? 64
Expand Down Expand Up @@ -238,6 +247,11 @@ extension RunnerTests {
// depth-limited would send agents chasing deeper content that is not
// there. Pending or missed frontiers keep the depth-limited verdict.
let deepExtension = response[RunnerAXSnapshotDeepExtensionKey] as? [String: Any]
if let failure = Self.privateAXContainmentFailure(
blockedFrontiers: deepExtension?[RunnerAXSnapshotDeepExtensionBlockedKey] as? Int
) {
throw failure
}
let depthLimited = Self.privateAXDepthLimited(
effectiveDepth: effectiveDepth,
requestedDepth: requestedDepth,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,19 @@ extension RunnerTests {
guard let x = command.x, let y = command.y else {
return Response(ok: false, error: ErrorPayload(message: "readText requires x and y"))
}
if command.inspectPoint == true {
let inspection = readPointAt(app: activeApp, x: x, y: y)
guard inspection.complete else {
return Response(
ok: false,
error: ErrorPayload(
code: "COMMAND_FAILED",
message: "Point inspection could not prove a miss because its accessibility capture was truncated"
)
)
}
return Response(ok: true, data: DataPayload(text: inspection.text, elements: inspection.elements))
}
guard let text = readTextAt(app: activeApp, x: x, y: y) else {
return Response(ok: false, error: ErrorPayload(message: "readText did not resolve text"))
}
Expand Down
Loading