Skip to content

Commit b66bea3

Browse files
thymikeeclaude
andcommitted
fix(device-selection): fail an ambiguous device request with only the tied devices
An ambiguous device selection now lists only the equally preferred devices, is typed dispatched: 'no', and its hint names the --udid/--serial of each tied device as the one retry step. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 80075b6 commit b66bea3

4 files changed

Lines changed: 46 additions & 11 deletions

File tree

‎packages/device-selection/src/__tests__/device-selection-resolver.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,37 @@ test('the booted simulator with the app installed carries its own selected-by re
7474
assert.equal(selection.source, 'local');
7575
});
7676

77+
test('a cross-platform tie fails undispatched, naming only the tied devices and their ids', async () => {
78+
const error = await resolveInventoryDeviceSelection({
79+
devices: [
80+
ANDROID_EMULATOR,
81+
IOS_SIMULATOR,
82+
{ ...IOS_SIMULATOR, id: 'sim-stopped-1', name: 'iPhone 17', booted: false },
83+
{ ...IOS_SIMULATOR, id: 'sim-stopped-2', name: 'iPhone Air', booted: false },
84+
],
85+
selector: {},
86+
source: 'local',
87+
}).catch((error: unknown) => error);
88+
89+
assert.ok(error instanceof AppError);
90+
assert.equal(error.code, 'AMBIGUOUS_MATCH');
91+
assert.equal(error.details?.dispatched, 'no');
92+
assert.deepEqual(error.details?.devices, [
93+
{ id: ANDROID_EMULATOR.id, name: ANDROID_EMULATOR.name },
94+
{ id: IOS_SIMULATOR.id, name: IOS_SIMULATOR.name },
95+
]);
96+
assert.deepEqual(error.details?.retrySelectors, [
97+
{ flag: '--serial', value: ANDROID_EMULATOR.id },
98+
{ flag: '--device', value: ANDROID_EMULATOR.name },
99+
{ flag: '--udid', value: IOS_SIMULATOR.id },
100+
{ flag: '--device', value: IOS_SIMULATOR.name },
101+
]);
102+
assert.equal(
103+
error.details?.hint,
104+
`Retry with the id of the intended device: --serial ${ANDROID_EMULATOR.id} or --udid ${IOS_SIMULATOR.id}.`,
105+
);
106+
});
107+
77108
test('successful local virtual preparation records the boot event without changing provider evidence', () => {
78109
const localSelection = markSelectionBootOccurred({
79110
device: STOPPED_ANDROID_EMULATOR,

‎packages/device-selection/src/__tests__/dispatch-resolve.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,7 @@ test('resolveTargetDevice refuses ambiguous booted simulator app matches', async
472472
assert.equal(error.code, 'AMBIGUOUS_MATCH');
473473
assert.match(error.message, /Multiple booted iOS simulators have com\.example\.demo installed/);
474474
assert.equal(error.details?.hint, 'Pass --udid to select the intended simulator explicitly.');
475+
assert.equal(error.details?.dispatched, 'no');
475476
assert.deepEqual(error.details?.devices, [
476477
{ id: bootedSimulator.id, name: bootedSimulator.name },
477478
{ id: secondBootedSimulator.id, name: secondBootedSimulator.name },

‎packages/device-selection/src/device-selection-resolver.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
matchesDeviceSelector,
1313
matchesDeviceNameSelector,
1414
platformSelectorsConflict,
15+
preferredDeviceCandidates,
1516
type PlatformSelector,
1617
pickNewestBootableDevice,
1718
resolveDevice,
@@ -171,6 +172,7 @@ async function resolveSingleProviderDevice(
171172
throw new AppError('AMBIGUOUS_MATCH', 'Multiple provider devices match this request.', {
172173
...deviceCandidateDetails(candidates),
173174
matches: candidates.length,
175+
dispatched: 'no',
174176
hint: 'Select the leased provider device explicitly with --device, --udid, or --serial.',
175177
retrySelectors: retrySelectorsFor(candidates),
176178
});
@@ -235,6 +237,7 @@ async function resolveAppInstalledSimulatorSelection(
235237
{
236238
appTarget,
237239
...deviceCandidateDetails(matches),
240+
dispatched: 'no',
238241
hint: 'Pass --udid to select the intended simulator explicitly.',
239242
retrySelectors: retrySelectorsFor(matches),
240243
},
@@ -247,13 +250,16 @@ function withDeviceRetrySelectors(
247250
extraHint?: string,
248251
): unknown {
249252
if (!(error instanceof AppError) || error.code !== 'AMBIGUOUS_MATCH') return error;
253+
// The kernel refuses only its equally preferred devices; a lower-ranked or shut-down match is
254+
// no answer to the tie, so only the tied devices become retry selectors.
255+
const tied = preferredDeviceCandidates([...candidates]);
250256
return new AppError(
251257
error.code,
252258
error.message,
253259
{
254260
...(error.details ?? {}),
255-
...deviceCandidateDetails(candidates),
256-
retrySelectors: retrySelectorsFor(candidates),
261+
...deviceCandidateDetails(tied),
262+
retrySelectors: retrySelectorsFor(tied),
257263
...(extraHint ? { hint: [error.details?.hint, extraHint].filter(Boolean).join(' ') } : {}),
258264
},
259265
error,

‎packages/kernel/src/device.ts‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -780,21 +780,18 @@ function throwAmbiguousDeviceSelection(candidates: DeviceInfo[]): never {
780780
// and MCP renderers print these candidates without a new shape to learn.
781781
devices: listed.map((device) => ({ id: device.id, name: device.name })),
782782
matches: candidates.length,
783+
// Selection precedes every device operation, so a refused tie never reached a device.
784+
dispatched: 'no',
783785
hint: buildAmbiguousDeviceHint(listed),
784786
},
785787
);
786788
}
787789

788790
function buildAmbiguousDeviceHint(candidates: DeviceInfo[]): string {
789-
const first = candidates[0];
790-
const identitySelector = first
791-
? `--${deviceIdentityFlag(first.platform) ?? 'udid'} ${first.id}`
792-
: `--udid <id>`;
793-
return (
794-
`Select the intended device explicitly, for example ${identitySelector} ` +
795-
`or --device ${JSON.stringify(first?.name ?? '<name>')}. ` +
796-
`Run agent-device devices to list them.`
791+
const selectors = candidates.map(
792+
(device) => `--${deviceIdentityFlag(device.platform) ?? 'udid'} ${device.id}`,
797793
);
794+
return `Retry with the id of the intended device: ${selectors.join(' or ')}.`;
798795
}
799796

800797
/**
@@ -803,7 +800,7 @@ function buildAmbiguousDeviceHint(candidates: DeviceInfo[]): string {
803800
* comparator's remaining tie-breaks are name order and discovery order, which encode nothing about
804801
* intent.
805802
*/
806-
function preferredDeviceCandidates(candidates: DeviceInfo[]): DeviceInfo[] {
803+
export function preferredDeviceCandidates(candidates: DeviceInfo[]): DeviceInfo[] {
807804
const ranked = isAppleDeviceCandidateSet(candidates)
808805
? candidatesWithBestAppleRank(candidates)
809806
: preferVirtualCandidates(candidates);

0 commit comments

Comments
 (0)