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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,8 @@ Boot diagnostics:
- Reason codes: `IOS_BOOT_TIMEOUT`, `IOS_RUNNER_CONNECT_TIMEOUT`, `ANDROID_BOOT_TIMEOUT`, `ADB_TRANSPORT_UNAVAILABLE`, `CI_RESOURCE_STARVATION_SUSPECTED`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
- Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors.
- Use `agent-device boot --platform ios|android|apple` when starting a new session only if `open` cannot find/connect to an available target.
- Android emulator boot by AVD name (GUI): `agent-device boot --platform android --device Pixel_9_Pro_XL`.
- Android headless emulator boot: `agent-device boot --platform android --device Pixel_9_Pro_XL --headless`.
- `--debug` captures retry telemetry in diagnostics logs.
- Set `AGENT_DEVICE_RETRY_LOGS=1` to also print retry telemetry directly to stderr (ad-hoc troubleshooting).

Expand Down
2 changes: 2 additions & 0 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ agent-device session list
```

Use `boot` only as fallback when `open` cannot find/connect to a ready target.
For Android emulators by AVD name, use `boot --platform android --device <avd-name>`.
For Android emulators without GUI, add `--headless`.
Use `--target mobile|tv` with `--platform` (required) to pick phone/tablet vs TV targets (AndroidTV/tvOS).

TV quick reference:
Expand Down
204 changes: 203 additions & 1 deletion src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from 'node:path';
import { handleSessionCommands } from '../session.ts';
import { SessionStore } from '../../session-store.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';
import { AppError } from '../../../utils/errors.ts';

function makeSessionStore(): SessionStore {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-handler-'));
Expand Down Expand Up @@ -312,7 +313,7 @@ test('boot succeeds for supported device in session', async () => {
});
assert.ok(response);
assert.equal(response?.ok, true);
assert.equal(ensureCalls, 1);
assert.equal(ensureCalls, 0);
if (response && response.ok) {
assert.equal(response.data?.platform, 'android');
assert.equal(response.data?.booted, true);
Expand Down Expand Up @@ -368,6 +369,207 @@ test('boot prefers explicit device selector over active session device', async (
}
});

test('boot --headless launches Android emulator when no running device matches', async () => {
const sessionStore = makeSessionStore();
const ensured: string[] = [];
const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = [];
const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'boot',
positionals: [],
flags: { platform: 'android', device: 'Pixel_9_Pro_XL', headless: true },
},
sessionName: 'default',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
ensureReady: async (device) => {
ensured.push(device.id);
},
resolveTargetDevice: async () => {
throw new AppError('DEVICE_NOT_FOUND', 'No devices found');
},
ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => {
launchCalls.push({ avdName, serial, headless });
return {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel_9_Pro_XL',
kind: 'emulator',
target: 'mobile',
booted: true,
};
},
});

assert.ok(response);
assert.equal(response?.ok, true);
assert.deepEqual(launchCalls, [{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: true }]);
assert.deepEqual(ensured, ['emulator-5554']);
if (response && response.ok) {
assert.equal(response.data?.platform, 'android');
assert.equal(response.data?.id, 'emulator-5554');
assert.equal(response.data?.device, 'Pixel_9_Pro_XL');
}
});

test('boot launches Android emulator with GUI when no running device matches', async () => {
const sessionStore = makeSessionStore();
const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = [];
const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'boot',
positionals: [],
flags: { platform: 'android', device: 'Pixel_9_Pro_XL' },
},
sessionName: 'default',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
ensureReady: async () => {},
resolveTargetDevice: async () => {
throw new AppError('DEVICE_NOT_FOUND', 'No devices found');
},
ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => {
launchCalls.push({ avdName, serial, headless });
return {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel_9_Pro_XL',
kind: 'emulator',
target: 'mobile',
booted: true,
};
},
});

assert.ok(response);
assert.equal(response?.ok, true);
assert.deepEqual(launchCalls, [{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]);
if (response && response.ok) {
assert.equal(response.data?.platform, 'android');
assert.equal(response.data?.id, 'emulator-5554');
assert.equal(response.data?.device, 'Pixel_9_Pro_XL');
}
});

test('boot --headless requires avd selector when device cannot be resolved', async () => {
const sessionStore = makeSessionStore();
let bootCalled = false;
const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'boot',
positionals: [],
flags: { platform: 'android', serial: 'emulator-5554', headless: true },
},
sessionName: 'default',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
ensureReady: async () => {},
resolveTargetDevice: async () => {
throw new AppError('DEVICE_NOT_FOUND', 'No devices found');
},
ensureAndroidEmulatorBoot: async () => {
bootCalled = true;
throw new Error('unexpected');
},
});

assert.ok(response);
assert.equal(response?.ok, false);
assert.equal(bootCalled, false);
if (response && !response.ok) {
assert.equal(response.error.code, 'INVALID_ARGS');
assert.match(response.error.message, /boot --headless requires --device <avd-name>/);
}
});

test('boot --headless rejects non-Android selectors', async () => {
const sessionStore = makeSessionStore();
let resolved = false;
const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'boot',
positionals: [],
flags: { platform: 'ios', device: 'iPhone 17 Pro', headless: true },
},
sessionName: 'default',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
ensureReady: async () => {},
resolveTargetDevice: async () => {
resolved = true;
throw new Error('unexpected resolve');
},
ensureAndroidEmulatorBoot: async () => {
throw new Error('unexpected emulator launch');
},
});

assert.ok(response);
assert.equal(response?.ok, false);
assert.equal(resolved, false);
if (response && !response.ok) {
assert.equal(response.error.code, 'INVALID_ARGS');
assert.match(response.error.message, /headless is supported only for Android emulators/i);
}
});

test('boot keeps --target validation when emulator is fallback-launched', async () => {
const sessionStore = makeSessionStore();
let ensured = false;
const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = [];
const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'boot',
positionals: [],
flags: { platform: 'android', target: 'tv', device: 'Pixel_9_Pro_XL' },
},
sessionName: 'default',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
ensureReady: async () => {
ensured = true;
},
resolveTargetDevice: async () => {
throw new AppError('DEVICE_NOT_FOUND', 'No Android TV devices found');
},
ensureAndroidEmulatorBoot: async ({ avdName, serial, headless }) => {
launchCalls.push({ avdName, serial, headless });
return {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel_9_Pro_XL',
kind: 'emulator',
target: 'mobile',
booted: true,
};
},
});

assert.ok(response);
assert.equal(response?.ok, false);
assert.equal(ensured, false);
assert.deepEqual(launchCalls, [{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]);
if (response && !response.ok) {
assert.equal(response.error.code, 'DEVICE_NOT_FOUND');
assert.match(response.error.message, /matching --target tv/i);
}
});

test('appstate on iOS requires active session on selected device', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'default';
Expand Down
Loading
Loading