Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
93 changes: 89 additions & 4 deletions src/tools/app-wait-for.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,54 @@ const DEFAULT_INTERVAL_MS = 500;

type WaitCondition = 'exists' | 'not_exists' | 'visible' | 'enabled';

const DEFAULT_SAMPLE_LIMIT = 5;

export interface WaitEvaluation {
met: boolean;
matchingCount: number;
sample: WaitCandidateSample[];
}

export interface WaitCandidateSample {
role: string;
label?: string;
identifier?: string;
visible?: boolean;
enabled?: boolean;
frame?: AXNode['frame'];
path?: string;
}

export function evaluateWaitCondition(
matches: AXNode[],
condition: WaitCondition,
sampleLimit = DEFAULT_SAMPLE_LIMIT,
): WaitEvaluation {
return {
met: checkCondition(matches, condition),
matchingCount: matches.length,
sample: sampleMatches(matches, sampleLimit),
};
}

export function hasHeldStableSince(
conditionMet: boolean,
firstMetAtMs: number | null,
nowMs: number,
stableMs: number,
): { stable: boolean; firstMetAtMs: number | null; stableForMs: number } {
if (!conditionMet) {
return { stable: false, firstMetAtMs: null, stableForMs: 0 };
}
const nextFirstMetAtMs = firstMetAtMs ?? nowMs;
const stableForMs = nowMs - nextFirstMetAtMs;
return {
stable: stableForMs >= Math.max(0, stableMs),
firstMetAtMs: nextFirstMetAtMs,
stableForMs,
};
}

export function registerAppWaitForNativeTool(server: MCPServer): void {
server.registerTool(
{
Expand Down Expand Up @@ -61,6 +109,14 @@ export function registerAppWaitForNativeTool(server: MCPServer): void {
type: 'number',
description: 'Poll interval in ms (default: 500)',
},
stable_ms: {
type: 'number',
description: 'Require the condition to hold continuously for this many ms before success (default: 0).',
},
sample_limit: {
type: 'number',
description: 'Maximum matching AX nodes to include in timeout diagnostics (default: 5, max: 20).',
},
Comment on lines +116 to +119
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return sampled matches when wait times out

The new sample_limit option is wired into polling, but on the timeout path the response still only returns { error, condition, query, timeout, elapsed, polls }, so callers never receive last_matches/stability diagnostics for the failure case this feature targets. In practice, timed-out waits get no extra debugging signal and sample_limit becomes effectively a no-op unless the condition succeeds; please include the tracked lastEvaluation/stability fields in the timeout payload.

Useful? React with 👍 / 👎.

device_id: {
type: 'string',
description: 'Simulator UDID (uses active device if omitted)',
Expand Down Expand Up @@ -104,6 +160,8 @@ export function registerAppWaitForNativeTool(server: MCPServer): void {
const condition = (params.condition as WaitCondition | undefined) ?? 'exists';
const timeout = (params.timeout as number | undefined) ?? DEFAULT_TIMEOUT_MS;
const interval = (params.interval as number | undefined) ?? DEFAULT_INTERVAL_MS;
const stableMs = Math.max(0, Math.floor((params.stable_ms as number | undefined) ?? 0));
const sampleLimit = Math.min(20, Math.max(0, Math.floor((params.sample_limit as number | undefined) ?? DEFAULT_SAMPLE_LIMIT)));
const bundleId = params.bundle_id as string | undefined;
const query = { identifier, label, text, role };

Expand Down Expand Up @@ -133,15 +191,22 @@ export function registerAppWaitForNativeTool(server: MCPServer): void {
const startTime = Date.now();
const deadline = startTime + timeout;
let pollCount = 0;
let firstMetAtMs: number | null = null;
let lastEvaluation: WaitEvaluation = { met: false, matchingCount: 0, sample: [] };
let lastStableForMs = 0;

while (Date.now() < deadline) {
pollCount++;
try {
const result = await bridge.query(query, { deviceId });
const met = checkCondition(result.matches, condition);
lastEvaluation = evaluateWaitCondition(result.matches, condition, sampleLimit);
const now = Date.now();
const stable = hasHeldStableSince(lastEvaluation.met, firstMetAtMs, now, stableMs);
firstMetAtMs = stable.firstMetAtMs;
lastStableForMs = stable.stableForMs;

if (met) {
const elapsed = Date.now() - startTime;
if (stable.stable) {
const elapsed = now - startTime;
return {
content: [{
type: 'text' as const,
Expand All @@ -150,6 +215,9 @@ export function registerAppWaitForNativeTool(server: MCPServer): void {
condition,
elapsed,
polls: pollCount,
stable_ms: stableMs,
stable_for_ms: lastStableForMs,
matching_count: lastEvaluation.matchingCount,
element: condition !== 'not_exists' && result.matches.length > 0
? {
role: result.matches[0].role,
Expand All @@ -165,7 +233,12 @@ export function registerAppWaitForNativeTool(server: MCPServer): void {
};
}
} catch {
// Query error during polling — continue to next attempt
// Query error during polling breaks any requested stability window;
// a condition cannot be considered continuously held across an
// interval where it was not observed.
firstMetAtMs = null;
lastStableForMs = 0;
lastEvaluation = { met: false, matchingCount: 0, sample: [] };
}

// Don't sleep past deadline
Expand Down Expand Up @@ -202,6 +275,18 @@ export function registerAppWaitForNativeTool(server: MCPServer): void {
);
}

function sampleMatches(matches: AXNode[], limit: number): WaitCandidateSample[] {
return matches.slice(0, limit).map((m) => ({
role: m.role,
...(m.label ? { label: m.label } : {}),
...(m.identifier ? { identifier: m.identifier } : {}),
visible: m.visible,
enabled: m.enabled,
frame: m.frame,
path: m.path,
}));
}

function checkCondition(matches: AXNode[], condition: WaitCondition): boolean {
switch (condition) {
case 'exists':
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/app-wait-for-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { evaluateWaitCondition, hasHeldStableSince } from '../../src/tools/app-wait-for';
import type { AXNode } from '../../src/native';

const baseNode: AXNode = {
role: 'AXButton',
label: 'Continue',
identifier: 'continue_button',
value: undefined,
frame: { x: 0, y: 0, width: 44, height: 44 },
visible: true,
enabled: true,
focused: false,
traits: [],
path: '/0/1',
children: [],
};

describe('app_wait_for diagnostic helpers', () => {
it('evaluates visible and enabled conditions', () => {
expect(evaluateWaitCondition([{ ...baseNode, visible: false }], 'visible').met).toBe(false);
expect(evaluateWaitCondition([{ ...baseNode, enabled: true }], 'enabled').met).toBe(true);
});

it('samples matches with bounded AX metadata', () => {
const result = evaluateWaitCondition([baseNode, { ...baseNode, label: 'Cancel' }], 'exists', 1);
expect(result.matchingCount).toBe(2);
expect(result.sample).toEqual([{ role: 'AXButton', label: 'Continue', identifier: 'continue_button', visible: true, enabled: true, frame: baseNode.frame, path: '/0/1' }]);
});

it('supports not_exists conditions', () => {
expect(evaluateWaitCondition([], 'not_exists').met).toBe(true);
expect(evaluateWaitCondition([baseNode], 'not_exists').met).toBe(false);
});

it('requires a condition to hold for the requested stability window', () => {
let state = hasHeldStableSince(true, null, 100, 250);
expect(state.stable).toBe(false);
state = hasHeldStableSince(true, state.firstMetAtMs, 349, 250);
expect(state.stable).toBe(false);
state = hasHeldStableSince(true, state.firstMetAtMs, 350, 250);
expect(state.stable).toBe(true);
expect(hasHeldStableSince(false, state.firstMetAtMs, 400, 250)).toEqual({ stable: false, firstMetAtMs: null, stableForMs: 0 });
});
});

describe('app_wait_for stability reset contract', () => {
it('treats an unobserved poll as a broken stability window', () => {
const first = hasHeldStableSince(true, null, 100, 250);
expect(first.firstMetAtMs).toBe(100);
const reset = hasHeldStableSince(false, first.firstMetAtMs, 200, 250);
expect(reset).toEqual({ stable: false, firstMetAtMs: null, stableForMs: 0 });
const afterError = hasHeldStableSince(true, reset.firstMetAtMs, 300, 250);
expect(afterError.stable).toBe(false);
expect(afterError.firstMetAtMs).toBe(300);
});
});
Loading