From 248903da5129341364d9760492ec6873d7323429 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:03:32 +0000 Subject: [PATCH 1/9] feat(android): simulate an incoming call or SMS on an emulator Add `agent-device telephony call|accept|cancel `, `agent-device telephony sms `, and `client.command.telephony({ action, number, text })`. The Android owner sends each event through the emulator console (`adb emu gsm call|accept| cancel`, `adb emu sms send`), so a QA run on a video-streaming app can check that playback pauses and resumes around an incoming call or text. `sendTelephonyEvent` is a host-side device-runtime operation admitted on the emulator kind only; physical Android devices refuse with `unsupported-device-kind`, and every other platform and provider states its own refusal cell. The number is digits with an optional leading `+`. SMS text is passed as one adb argument with backslashes and line breaks escaped for the console's line protocol; other control characters are refused. A console `KO` reply, which adb reports with exit code 0, fails with `COMMAND_FAILED` and `reason: telephony-console-refused`. --- CHANGELOG.md | 1 + .../src/__tests__/command-result.test.ts | 4 + .../command-registry/src/command-result.ts | 2 + packages/command-registry/src/registry.ts | 17 ++ packages/contracts/package.json | 4 + packages/contracts/src/client-system.ts | 4 +- .../src/device-rotation-telephony.test.ts | 68 +++++++ packages/contracts/src/device-rotation.ts | 127 +++++++++++++ packages/contracts/src/facades/client.ts | 1 + packages/contracts/src/facades/device.ts | 9 + .../src/platform-runtime-operations.ts | 3 + .../src/platform-runtime-unavailable.test.ts | 1 + .../src/platform-runtime-unavailable.ts | 8 +- .../contracts/src/runtime-operation-names.ts | 1 + packages/contracts/src/telephony-runtime.ts | 20 ++ .../src/core/runtime-operation-effects.ts | 1 + .../__tests__/telephony-runtime.test.ts | 173 ++++++++++++++++++ .../execution/generic-runtime-execution.ts | 15 +- .../daemon/src/execution/telephony-runtime.ts | 71 +++++++ .../handlers/__tests__/install-source.test.ts | 1 + .../session-appstate-device-runtime.test.ts | 1 + .../handlers/__tests__/session-boot.test.ts | 1 + packages/platform-android/src/runtime.test.ts | 25 +++ packages/platform-android/src/runtime.ts | 6 + .../src/telephony/emulator-console.test.ts | 85 +++++++++ .../src/telephony/emulator-console.ts | 61 ++++++ .../src/telephony/runtime.test.ts | 91 +++++++++ .../platform-android/src/telephony/runtime.ts | 39 ++++ packages/platform-apple/src/runner-demand.ts | 2 + packages/platform-apple/src/runtime.test.ts | 21 +++ packages/platform-apple/src/runtime.ts | 6 + .../platform-harmonyos/src/runtime.test.ts | 2 + packages/platform-harmonyos/src/runtime.ts | 2 + packages/platform-linux/src/runtime.test.ts | 2 + packages/platform-linux/src/runtime.ts | 1 + packages/platform-vega/src/runtime.ts | 5 + packages/platform-web/src/runtime.test.ts | 2 + packages/platform-web/src/runtime.ts | 1 + .../provider-limrun/src/app-log-runtime.ts | 2 + packages/provider-limrun/src/facts-runtime.ts | 3 + .../src/interaction-operations.ts | 14 ++ .../src/platform-runtime.ts | 9 + .../__tests__/session-event-action.test.ts | 28 +++ .../src/session-event-action-presentation.ts | 21 ++- .../test-kit/src/runtime-operation-facts.ts | 1 + src/agent-device-client.ts | 2 + src/client/client-types.ts | 2 + src/commands/replay/replay-script.fixtures.ts | 1 + src/commands/system/index.test.ts | 62 +++++++ src/commands/system/index.ts | 96 +++++++++- src/commands/system/output.ts | 1 + src/platform-runtime-gateway.test.ts | 2 + .../android-telephony.test.ts | 152 +++++++++++++++ .../provider-device-runtime.fixtures.ts | 2 + website/docs/docs/client-api.md | 3 + website/docs/docs/commands.md | 11 ++ 56 files changed, 1289 insertions(+), 7 deletions(-) create mode 100644 packages/contracts/src/device-rotation-telephony.test.ts create mode 100644 packages/contracts/src/telephony-runtime.ts create mode 100644 packages/daemon/src/execution/__tests__/telephony-runtime.test.ts create mode 100644 packages/daemon/src/execution/telephony-runtime.ts create mode 100644 packages/platform-android/src/telephony/emulator-console.test.ts create mode 100644 packages/platform-android/src/telephony/emulator-console.ts create mode 100644 packages/platform-android/src/telephony/runtime.test.ts create mode 100644 packages/platform-android/src/telephony/runtime.ts create mode 100644 test/integration/provider-scenarios/android-telephony.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e87f570c69..ede0323ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,4 +10,5 @@ - `settings` refuses, before touching the device, a state its setting does not accept (`Invalid state: …`), a permission action or mode its target does not take (such as `permission deny accessibility`), and a stray extra argument (such as `clear-app-state `). The CLI and the daemon accept the same state spellings, in any casing or surrounding whitespace. - `alert wait 0` on Android reports an alert that is already visible instead of timing out. - `settings --help` describes each setting on its own line, and its usage folds settings that share a form. +- Added (android): `telephony call|accept|cancel ` and `telephony sms `, plus `client.command.telephony({ action, number, text })`, simulate an incoming phone call or SMS on an Android emulator through the emulator console (`adb emu gsm …`, `adb emu sms send …`), so a test can check how an app behaves when a call or message interrupts it, such as video playback pausing and resuming. The number must be digits with an optional leading `+`; SMS text arrives as written, with backslashes and line breaks escaped for the console. A console refusal (for example `accept` for a number with no call) fails with `COMMAND_FAILED` and `reason: telephony-console-refused`. Physical Android devices, iOS, and every other target and provider refuse with `UNSUPPORTED_OPERATION`. - Carry provider keyboard facts through snapshots. Appium captures, including wait polls and fill pre-captures, add one sequential keyboard visibility probe with a 500 ms timeout. Probe results preserve tap-time docked-tree checks; unavailable probes leave visibility unknown. diff --git a/packages/command-registry/src/__tests__/command-result.test.ts b/packages/command-registry/src/__tests__/command-result.test.ts index 4cc3f6a7db..2884e64567 100644 --- a/packages/command-registry/src/__tests__/command-result.test.ts +++ b/packages/command-registry/src/__tests__/command-result.test.ts @@ -7,6 +7,7 @@ import type { AppStateCommandResult, BootCommandResult, ShutdownCommandResult, + TelephonyCommandResult, TriggerAppEventCommandResult, } from '@agent-device/contracts/device'; import type { @@ -60,6 +61,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ const appstate: Equal, AppStateCommandResult> = true; const keyboard: Equal, KeyboardCommandResult> = true; const tvRemote: Equal, TvRemoteCommandResult> = true; + const telephony: Equal, TelephonyCommandResult> = true; const wait: Equal, WaitCommandResult> = true; const prepare: Equal, PrepareCommandResult> = true; const push: Equal, PushCommandResult> = true; @@ -90,6 +92,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ appstate, keyboard, tvRemote, + telephony, wait, prepare, push, @@ -132,6 +135,7 @@ test('CommandResultMap is seeded only from already-existing contract result type | 'appstate' | 'keyboard' | 'tv-remote' + | 'telephony' | 'wait' | 'scroll' | 'prepare' diff --git a/packages/command-registry/src/command-result.ts b/packages/command-registry/src/command-result.ts index 11f9d271e9..fa22d62e74 100644 --- a/packages/command-registry/src/command-result.ts +++ b/packages/command-registry/src/command-result.ts @@ -7,6 +7,7 @@ import type { AppStateCommandResult, BootCommandResult, ShutdownCommandResult, + TelephonyCommandResult, TriggerAppEventCommandResult, } from '@agent-device/contracts/device'; import type { @@ -82,6 +83,7 @@ export interface CommandResultMap { replay: ReplayCommandResult; scroll: ScrollCommandResult; shutdown: ShutdownCommandResult; + telephony: TelephonyCommandResult; test: ReplaySuiteResult; trace: TraceCommandResult; 'trigger-app-event': TriggerAppEventCommandResult; diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 303a38e35c..fcbe888458 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -43,6 +43,7 @@ import { alertRuntimePlanUses, actionButtonRuntimeUse, foldRuntimeUse, + telephonyRuntimeUse, appEventRuntimeUse, appStateRuntimeUses, appSwitcherRuntimeUse, @@ -1514,6 +1515,22 @@ export const RAW_COMMAND_DESCRIPTORS = [ timeoutPolicy: FOLD_TIMEOUT_POLICY, platformExecution: { kind: 'device-runtime', uses: [foldRuntimeUse] }, }, + { + name: 'telephony', + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), + catalog: { group: 'public' }, + frameworkTier: 'extended', + // Admission is the owner's `sendTelephonyEvent` fact. The event goes to the emulator console + // rather than the screen, so no blocking-dialog guard applies; the call screen or message + // notification it raises can still cover the app, so refs may be invalidated. + recordsSessionAction: true, + recordingEffect: 'mutates-app', + deviceClaimPolicy: 'require-owner', + daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: true, + platformExecution: { kind: 'device-runtime', uses: [telephonyRuntimeUse] }, + }, { name: 'scroll', ...(ownerFilesEnabled diff --git a/packages/contracts/package.json b/packages/contracts/package.json index e103d8532c..1c3a05598f 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -512,6 +512,10 @@ "types": "./src/tap-keyboard-occlusion.ts", "default": "./src/tap-keyboard-occlusion.ts" }, + "./telephony-runtime": { + "types": "./src/telephony-runtime.ts", + "default": "./src/telephony-runtime.ts" + }, "./touch-runtime": { "types": "./src/touch-runtime.ts", "default": "./src/touch-runtime.ts" diff --git a/packages/contracts/src/client-system.ts b/packages/contracts/src/client-system.ts index 1bf5dd6ca5..401cb9b0be 100644 --- a/packages/contracts/src/client-system.ts +++ b/packages/contracts/src/client-system.ts @@ -5,7 +5,7 @@ import type { BackMode } from './back-mode.ts'; import type { SelectorSnapshotCommandOptions } from './client-capture.ts'; import type { DeviceCommandBaseOptions } from './client-connection.ts'; import type { SettleCommandOptions } from './client-gesture.ts'; -import type { DeviceRotation, SetFoldPoseInput } from './device-rotation.ts'; +import type { DeviceRotation, SetFoldPoseInput, TelephonyEventInput } from './device-rotation.ts'; import type { TvRemoteButton } from './tv-remote.ts'; export type WaitCommandTarget = @@ -101,6 +101,8 @@ export type TvRemoteCommandOptions = DeviceCommandBaseOptions & { durationMs?: number; }; +export type TelephonyCommandOptions = DeviceCommandBaseOptions & TelephonyEventInput; + export type KeyboardCommandOptions = DeviceCommandBaseOptions & { action?: 'status' | 'dismiss' | 'enter' | 'return'; }; diff --git a/packages/contracts/src/device-rotation-telephony.test.ts b/packages/contracts/src/device-rotation-telephony.test.ts new file mode 100644 index 0000000000..da3bd99ea9 --- /dev/null +++ b/packages/contracts/src/device-rotation-telephony.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from 'vitest'; +import { + parseTelephonyEventInput, + parseTelephonyPositionals, + telephonyPositionals, +} from './device-rotation.ts'; + +test('parses each action with its number, normalizing the action and trimming the number', () => { + expect(parseTelephonyEventInput({ action: ' CALL ', number: ' 5551234 ' })).toEqual({ + action: 'call', + number: '5551234', + }); + expect(parseTelephonyEventInput({ action: 'accept', number: '+15551234567' })).toEqual({ + action: 'accept', + number: '+15551234567', + }); + expect(parseTelephonyEventInput({ action: 'cancel', number: '5551234' })).toEqual({ + action: 'cancel', + number: '5551234', + }); + expect(parseTelephonyEventInput({ action: 'sms', number: '5551234', text: ' hi ' })).toEqual({ + action: 'sms', + number: '5551234', + text: ' hi ', + }); +}); + +test('keeps SMS text verbatim, including quotes, backslashes, and line breaks', () => { + const text = `It's "on"\nC:\\videos $HOME`; + expect(parseTelephonyEventInput({ action: 'sms', number: '5551234', text })).toMatchObject({ + text, + }); +}); + +test('normalizes a CRLF line break to LF', () => { + expect( + parseTelephonyEventInput({ action: 'sms', number: '5551234', text: 'Line 1\r\nLine 2' }), + ).toMatchObject({ text: 'Line 1\nLine 2' }); +}); + +test.each([ + [{}, 'telephony requires an action'], + [{ action: 'ring', number: '5551234' }, 'Invalid telephony action: ring'], + [{ action: 'call' }, 'telephony call requires a phone number'], + [{ action: 'call', number: ' ' }, 'telephony call requires a phone number'], + [{ action: 'call', number: '555 1234' }, 'Invalid phone number'], + [{ action: 'call', number: '+1-555-1234' }, 'Invalid phone number'], + [{ action: 'call', number: '1'.repeat(21) }, 'Invalid phone number'], + [{ action: 'cancel', number: '5551234', text: 'hi' }, 'takes only a phone number'], + [{ action: 'sms', number: '5551234' }, 'telephony sms requires message text'], + [{ action: 'sms', number: '5551234', text: '' }, 'telephony sms requires message text'], + [{ action: 'sms', number: '5551234', text: 'a\rb' }, 'must not contain control characters'], + [{ action: 'sms', number: '5551234', text: 'a\u0000b' }, 'must not contain control characters'], +])('refuses %o with INVALID_ARGS', (input, message) => { + expect(() => parseTelephonyEventInput(input)).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS', message: expect.stringContaining(message) }), + ); +}); + +test('positionals join the SMS words and round-trip through the daemon positionals', () => { + const sms = parseTelephonyPositionals(['sms', '5551234', 'Your', 'code', 'is', '42']); + expect(sms).toEqual({ action: 'sms', number: '5551234', text: 'Your code is 42' }); + expect(telephonyPositionals(sms)).toEqual(['sms', '5551234', 'Your code is 42']); + expect(parseTelephonyPositionals(telephonyPositionals(sms))).toEqual(sms); + + const call = parseTelephonyPositionals(['call', '5551234']); + expect(telephonyPositionals(call)).toEqual(['call', '5551234']); +}); diff --git a/packages/contracts/src/device-rotation.ts b/packages/contracts/src/device-rotation.ts index 64b1411725..5e2f28a6ee 100644 --- a/packages/contracts/src/device-rotation.ts +++ b/packages/contracts/src/device-rotation.ts @@ -206,3 +206,130 @@ export function parseFoldKeyframesJson(value: string): readonly FoldKeyframe[] { } return parseFoldKeyframes(decoded); } + +// ---- Telephony ------------------------------------------------------------------------------- +// Lives beside the rotation and hinge vocabulary for the same eager-closure reason: the CLI and +// the session journal already evaluate this module, and a module of its own would join the +// eager closure of every entry that reads one (scripts/__tests__/eager-closure-budgets.ts). + +/** + * The telephony events an emulator can be asked to simulate: an incoming voice call (`call`), + * answering it (`accept`), ending it (`cancel`), and an inbound text message (`sms`). + */ +export const TELEPHONY_CALL_ACTIONS = ['call', 'accept', 'cancel'] as const; +export const TELEPHONY_ACTIONS = [...TELEPHONY_CALL_ACTIONS, 'sms'] as const; +export type TelephonyAction = (typeof TELEPHONY_ACTIONS)[number]; +type TelephonyCallAction = (typeof TELEPHONY_CALL_ACTIONS)[number]; + +export const TELEPHONY_USAGE = + 'telephony call|accept|cancel | telephony sms '; + +/** + * A caller number the emulator modem accepts: digits with an optional leading `+`, at most the + * 20 digits a GSM address carries. + */ +const TELEPHONY_NUMBER_PATTERN = /^\+?\d{1,20}$/; +/** + * The emulator console reads one command per line, so a line break inside the text would end the + * `sms send` command and start another. Line breaks are escaped for the console instead (a CRLF + * pair counts as one); every other control character, including a lone CR, is refused. + */ +// eslint-disable-next-line no-control-regex +const TELEPHONY_TEXT_FORBIDDEN_CHARACTERS = /[\u0000-\u0009\u000b-\u001f\u007f]/; + +/** Neutral intent for one telephony event, already validated by {@link parseTelephonyEventInput}. */ +export type TelephonyEventInput = + | Readonly<{ action: TelephonyCallAction; number: string }> + | Readonly<{ action: 'sms'; number: string; text: string }>; + +/** + * Closed result of the `telephony` command: a discriminated union on `action`. The call actions + * echo the number; `sms` also reports the length of the text it delivered. + */ +export type TelephonyCommandResult = + | { + action: TelephonyCallAction; + number: string; + message: string; + } + | { + action: 'sms'; + number: string; + textLength: number; + message: string; + }; + +function parseTelephonyAction(input: unknown): TelephonyAction { + if (input === undefined) { + throw new AppError('INVALID_ARGS', `telephony requires an action. Use ${TELEPHONY_USAGE}.`); + } + const normalized = typeof input === 'string' ? input.trim().toLowerCase() : undefined; + const action = TELEPHONY_ACTIONS.find((candidate) => candidate === normalized); + if (!action) { + throw new AppError( + 'INVALID_ARGS', + `Invalid telephony action: ${String(input)}. Use ${TELEPHONY_ACTIONS.join(', ')}.`, + ); + } + return action; +} + +export function parseTelephonyEventInput( + input: Readonly<{ action?: unknown; number?: unknown; text?: unknown }>, +): TelephonyEventInput { + const action = parseTelephonyAction(input.action); + const number = parseTelephonyNumber(input.number, action); + if (action !== 'sms') { + if (input.text !== undefined) { + throw new AppError('INVALID_ARGS', `telephony ${action} takes only a phone number.`); + } + return { action, number }; + } + return { action, number, text: parseTelephonySmsText(input.text) }; +} + +/** + * `telephony [text…]` as the CLI and the daemon receive it. The words after the + * number are the SMS text, joined with single spaces the way an unquoted shell argument list splits. + */ +export function parseTelephonyPositionals(positionals: readonly string[]): TelephonyEventInput { + const [action, number, ...words] = positionals; + return parseTelephonyEventInput({ + action, + number, + text: words.length > 0 ? words.join(' ') : undefined, + }); +} + +/** The daemon positionals for one validated event; the inverse of {@link parseTelephonyPositionals}. */ +export function telephonyPositionals(input: TelephonyEventInput): string[] { + return input.action === 'sms' + ? [input.action, input.number, input.text] + : [input.action, input.number]; +} + +function parseTelephonyNumber(input: unknown, action: TelephonyAction): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new AppError('INVALID_ARGS', `telephony ${action} requires a phone number.`); + } + const number = input.trim(); + if (!TELEPHONY_NUMBER_PATTERN.test(number)) { + throw new AppError('INVALID_ARGS', `Invalid phone number: ${number}.`, { + hint: 'Use digits with an optional leading +, for example 5551234 or +15551234567.', + }); + } + return number; +} + +function parseTelephonySmsText(input: unknown): string { + if (typeof input !== 'string' || input === '') { + throw new AppError('INVALID_ARGS', 'telephony sms requires message text.'); + } + const text = input.replaceAll('\r\n', '\n'); + if (TELEPHONY_TEXT_FORBIDDEN_CHARACTERS.test(text)) { + throw new AppError('INVALID_ARGS', 'telephony sms text must not contain control characters.', { + hint: 'Line breaks (LF or CRLF) are allowed; tabs, lone carriage returns, and other control characters are not.', + }); + } + return text; +} diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index d751df86ee..78499ed2da 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -121,6 +121,7 @@ export type { OrientationCommandOptions, PrepareCommandOptions, ReactNativeCommandOptions, + TelephonyCommandOptions, TvRemoteCommandOptions, ViewportCommandOptions, WaitCommandOptions, diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 5df44feb85..6a8eb0e5ae 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -45,6 +45,12 @@ export { isDeviceRotation, parseDeviceRotation, parseFoldPose, + TELEPHONY_ACTIONS, + TELEPHONY_CALL_ACTIONS, + TELEPHONY_USAGE, + parseTelephonyEventInput, + parseTelephonyPositionals, + telephonyPositionals, } from '../device-rotation.ts'; export type { DeviceRotation, @@ -52,6 +58,9 @@ export type { FoldKeyframe, FoldScreenCoordinateSpace, SetFoldPoseInput, + TelephonyAction, + TelephonyCommandResult, + TelephonyEventInput, } from '../device-rotation.ts'; export type { BootCommandResult, ShutdownCommandResult } from '../device.ts'; export type { diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 3d11d8e2bf..a3c04d59c5 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -23,6 +23,7 @@ import type { ElementTextRuntimeOperations } from './element-text-runtime.ts'; import type { BackRuntimeOperations } from './back-runtime.ts'; import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; import type { FoldRuntimeOperations } from './fold-runtime.ts'; +import type { TelephonyRuntimeOperations } from './telephony-runtime.ts'; import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; @@ -77,6 +78,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & BackRuntimeOperations & OrientationRuntimeOperations & FoldRuntimeOperations & + TelephonyRuntimeOperations & TvRemoteRuntimeOperations & KeyboardRuntimeOperations & ClipboardRuntimeOperations & @@ -112,6 +114,7 @@ export const backRuntimeUse = defineUse({ required: ['back'] }); export const homeRuntimeUse = defineUse({ required: ['home'] }); export const orientationRuntimeUse = defineUse({ required: ['setOrientation'] }); export const foldRuntimeUse = defineUse({ required: ['setFoldPose'] }); +export const telephonyRuntimeUse = defineUse({ required: ['sendTelephonyEvent'] }); export const tvRemoteRuntimeUse = defineUse({ required: ['tvRemote'] }); export const keyboardStatusUse = defineUse({ required: ['keyboardStatus'] }); export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] }); diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index e54cf1516b..d479d6e852 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -46,6 +46,7 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = { clipboard: { available: false, reason: 'unsupported-provider-mode' }, systemButton: { available: false, reason: 'unsupported-provider-mode' }, fold: { available: false, reason: 'unsupported-provider-mode' }, + telephony: { available: false, reason: 'unsupported-provider-mode' }, triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' }, settings: { available: false, reason: 'unsupported-provider-mode' }, readAlert: { available: false, reason: 'unsupported-provider-mode' }, diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 8fe5ff79ee..7021f70222 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -58,6 +58,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ back: RuntimeOperationUnavailability; orientation: RuntimeOperationUnavailability; fold: RuntimeOperationUnavailability; + telephony: RuntimeOperationUnavailability; tvRemote: RuntimeOperationUnavailability; keyboard: RuntimeOperationUnavailability; clipboard: RuntimeOperationUnavailability; @@ -114,6 +115,7 @@ const UNAVAILABLE_CELLS = { back: true, orientation: true, fold: true, + telephony: true, tvRemote: true, keyboard: true, clipboard: true, @@ -236,9 +238,11 @@ export function createUnavailablePlatformRuntimeFacts( ...elementTextRuntimeOperationFacts({ readTextAtPoint: frozen.elementText }), ...backRuntimeOperationFacts({ back: frozen.back }), ...orientationRuntimeOperationFacts({ orientation: frozen.orientation }), - // Stated directly rather than through `foldRuntimeOperationFacts`, so this hub does not - // evaluate the fold contract for owners that never pose a hinge (eager-closure budgets). + // Stated directly rather than through `foldRuntimeOperationFacts` and + // `telephonyRuntimeOperationFacts`, so this hub does not evaluate either contract for owners + // that never pose a hinge or simulate a call (eager-closure budgets). setFoldPose: frozen.fold, + sendTelephonyEvent: frozen.telephony, ...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }), ...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }), ...clipboardRuntimeOperationFacts({ unsupported: frozen.clipboard }), diff --git a/packages/contracts/src/runtime-operation-names.ts b/packages/contracts/src/runtime-operation-names.ts index 55d799875e..32cdf3b31a 100644 --- a/packages/contracts/src/runtime-operation-names.ts +++ b/packages/contracts/src/runtime-operation-names.ts @@ -76,6 +76,7 @@ export const RUNTIME_OPERATION_NAMES = [ 'screenRecordingStart', 'scrollDirection', 'sendPushNotification', + 'sendTelephonyEvent', 'setFoldPose', 'setOrientation', 'setSetting', diff --git a/packages/contracts/src/telephony-runtime.ts b/packages/contracts/src/telephony-runtime.ts new file mode 100644 index 0000000000..0ef0ec8bd8 --- /dev/null +++ b/packages/contracts/src/telephony-runtime.ts @@ -0,0 +1,20 @@ +import type { RuntimeOperationFact } from './platform-runtime.ts'; +import type { TelephonyEventInput } from './device-rotation.ts'; + +/** + * One simulated telephony event. The owner returns nothing: the emulator console acknowledges the + * event, and what the app does with the call or message is observed through the next capture. + */ +export type TelephonyRuntimeOperations = Readonly<{ + sendTelephonyEvent(input: TelephonyEventInput): Promise; +}>; + +export type TelephonyRuntimeOperationFacts = Readonly<{ + sendTelephonyEvent: RuntimeOperationFact; +}>; + +export function telephonyRuntimeOperationFacts( + input: Readonly<{ telephony: RuntimeOperationFact }>, +): TelephonyRuntimeOperationFacts { + return Object.freeze({ sendTelephonyEvent: input.telephony }); +} diff --git a/packages/daemon/src/core/runtime-operation-effects.ts b/packages/daemon/src/core/runtime-operation-effects.ts index 5fcb5d109a..dc5fa7d2e3 100644 --- a/packages/daemon/src/core/runtime-operation-effects.ts +++ b/packages/daemon/src/core/runtime-operation-effects.ts @@ -83,6 +83,7 @@ export const RUNTIME_OPERATION_EFFECTS: Readonly< screenRecordingStart: 'repeatable', scrollDirection: 'mutates', sendPushNotification: 'mutates', + sendTelephonyEvent: 'mutates', setFoldPose: 'mutates', setOrientation: 'mutates', setSetting: 'mutates', diff --git a/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts b/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts new file mode 100644 index 0000000000..86c173bc05 --- /dev/null +++ b/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts @@ -0,0 +1,173 @@ +import { daemonPlatformServicesFixture } from '../../__tests__/support/platform-services-fixture.ts'; +import { expect, test, vi } from 'vitest'; + +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type RuntimeFacts, + type RuntimeOperationFact, +} from '@agent-device/contracts/platform-runtime'; +import { + telephonyRuntimeUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import { telephonyRuntimeOperationFacts } from '@agent-device/contracts/telephony-runtime'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; +import { makeSession } from '../../__tests__/support/session-factories.ts'; +import type { + BindDeviceRuntime, + InspectDeviceRuntimeFacts, +} from '../../core/request-runtime-binding.ts'; +import type { GenericPlatformExecutionParams } from '../../core/generic-leaf-execution.ts'; +import { resolveBoundTelephonyRuntime } from '../telephony-runtime.ts'; +import { expectRefusesUnavailableExactOwnerFact } from '../../__tests__/support/runtime-binding-conformance.ts'; + +// File-scoped id: this owner binding's `local-family` kind reaches the real on-disk device-claim +// admission, so a shared id risks a cross-file claim collision under parallel execution. +const testDevice = { + id: 'telephony-runtime-device', + name: 'Pixel', + platform: 'android', + kind: 'emulator', + target: 'mobile', + booted: true, +} as const satisfies DeviceInfo; +const available = Object.freeze({ available: true } as const); +const unavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind' as const, +}); + +function telephonyExecutionParams(positionals: string[]): GenericPlatformExecutionParams { + const session = makeSession('telephony-runtime', { device: testDevice }); + return { + session, + sessionName: session.name, + logPath: '/tmp/daemon.log', + command: 'telephony', + request: { command: 'telephony', positionals, token: 't', session: session.name }, + positionals, + out: undefined, + dispatchContext: {}, + }; +} + +function runtimeHarness(fact: RuntimeOperationFact = available) { + const sendTelephonyEvent = vi.fn(async () => {}); + const facts: RuntimeFacts = { + device: { ...deviceShape(testDevice), providerMode: 'local' }, + operations: { + ...telephonyRuntimeOperationFacts({ telephony: fact }), + } as RuntimeFacts['operations'], + }; + const binding = { + device: testDevice, + owner: localRuntimeOwner('android'), + facts, + operations: { sendTelephonyEvent }, + [Symbol.asyncDispose]: async () => {}, + } satisfies DeviceBinding; + const inspectFacts: InspectDeviceRuntimeFacts = vi.fn(async () => facts); + const bindDevice = vi.fn(async (_device, use) => + narrowDeviceBinding(binding, use), + ) as unknown as BindDeviceRuntime; + return { sendTelephonyEvent, inspectFacts, bindDevice }; +} + +async function runTelephony(positionals: string[], harness = runtimeHarness()) { + const resolved = await resolveBoundTelephonyRuntime({ + platformServices: daemonPlatformServicesFixture(), + device: testDevice, + positionals, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + if (!resolved.ok) throw new Error('telephony was not admitted'); + return await resolved.execute(telephonyExecutionParams(positionals)); +} + +test.each([ + [['call', '5551234'], { action: 'call', number: '5551234' }, 'Simulated an incoming call'], + [['accept', '5551234'], { action: 'accept', number: '5551234' }, 'Accepted the call'], + [['cancel', '5551234'], { action: 'cancel', number: '5551234' }, 'Ended the call'], +] as const)('%o sends %o to the admitted owner', async (positionals, input, message) => { + const harness = runtimeHarness(); + expect(await runTelephony([...positionals], harness)).toEqual({ + ...input, + message: `${message} from 5551234`, + }); + expect(harness.bindDevice).toHaveBeenCalledWith(testDevice, telephonyRuntimeUse); + expect(harness.sendTelephonyEvent).toHaveBeenCalledExactlyOnceWith(input); +}); + +test('sms joins the words after the number into one message and reports its length', async () => { + const harness = runtimeHarness(); + expect(await runTelephony(['sms', '+15551234567', 'Your', 'code', 'is 42'], harness)).toEqual({ + action: 'sms', + number: '+15551234567', + textLength: 'Your code is 42'.length, + message: 'Simulated an incoming SMS from +15551234567', + }); + expect(harness.sendTelephonyEvent).toHaveBeenCalledWith({ + action: 'sms', + number: '+15551234567', + text: 'Your code is 42', + }); +}); + +test.each([ + [[], 'telephony requires an action'], + [['ring', '5551234'], 'Invalid telephony action'], + [['call'], 'telephony call requires a phone number'], + [['call', '555-1234'], 'Invalid phone number'], + [['call', '5551234', 'extra'], 'takes only a phone number'], + [['sms', '5551234'], 'telephony sms requires message text'], + [['sms', '5551234', 'tab\there'], 'must not contain control characters'], +])('refuses %o before inspection or binding', async (positionals, message) => { + const harness = runtimeHarness(); + await expect( + resolveBoundTelephonyRuntime({ + platformServices: daemonPlatformServicesFixture(), + device: testDevice, + positionals, + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }), + ).rejects.toMatchObject({ code: 'INVALID_ARGS', message: expect.stringContaining(message) }); + expect(harness.inspectFacts).not.toHaveBeenCalled(); + expect(harness.bindDevice).not.toHaveBeenCalled(); +}); + +test('refuses a physical device on the route with the fact reason, never sending', async () => { + const harness = runtimeHarness({ + available: false, + reason: 'unsupported-device-kind', + hint: 'TELEPHONY-KIND-HINT-SENTINEL', + }); + const resolved = await resolveBoundTelephonyRuntime({ + platformServices: daemonPlatformServicesFixture(), + device: testDevice, + positionals: ['call', '5551234'], + inspectFacts: harness.inspectFacts, + bindDevice: harness.bindDevice, + }); + expect(resolved.ok).toBe(false); + if (resolved.ok || resolved.response.ok) throw new Error('the kind fact admitted telephony'); + expect(resolved.response.error).toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + message: 'telephony is not supported on this device', + hint: 'TELEPHONY-KIND-HINT-SENTINEL', + details: { reason: 'unsupported-device-kind' }, + }); + expect(harness.bindDevice).not.toHaveBeenCalled(); + expect(harness.sendTelephonyEvent).not.toHaveBeenCalled(); +}); + +test('rejects an unavailable exact-owner fact before binding', async () => { + await expectRefusesUnavailableExactOwnerFact({ + command: 'telephony', + device: testDevice, + unavailable, + }); +}); diff --git a/packages/daemon/src/execution/generic-runtime-execution.ts b/packages/daemon/src/execution/generic-runtime-execution.ts index 8aa38b4749..cb4d22a051 100644 --- a/packages/daemon/src/execution/generic-runtime-execution.ts +++ b/packages/daemon/src/execution/generic-runtime-execution.ts @@ -45,8 +45,7 @@ export async function resolveGenericRuntimeExecution( switch (params.req.command) { case 'screenshot': { // This branch alone owns an in-process command surface and the interaction implementations - // beneath it; the other leaves here are bound runtimes the dispatcher reaches directly, so - // only this one's module is deferred out of the daemon's startup closure. + // beneath it, so its module is deferred out of the daemon's startup closure. const { resolveScreenshotGenericExecution } = await import('./screenshot-runtime.ts'); return await resolveScreenshotGenericExecution(params); } @@ -104,6 +103,18 @@ export async function resolveGenericRuntimeExecution( platformServices: params.platformServices, bindDevice: params.bindDevice, }); + case 'telephony': { + // Deferred like screenshot's leaf, so the daemon's startup closure stays at its merge-base + // size (scripts/__tests__/eager-closure-budgets.ts). + const { resolveBoundTelephonyRuntime } = await import('./telephony-runtime.ts'); + return await resolveBoundTelephonyRuntime({ + device: params.session.device, + positionals: params.req.positionals ?? [], + inspectFacts: params.inspectFacts, + platformServices: params.platformServices, + bindDevice: params.bindDevice, + }); + } case 'tv-remote': return await resolveBoundTvRemoteRuntime({ device: params.session.device, diff --git a/packages/daemon/src/execution/telephony-runtime.ts b/packages/daemon/src/execution/telephony-runtime.ts new file mode 100644 index 0000000000..bd5b0ea043 --- /dev/null +++ b/packages/daemon/src/execution/telephony-runtime.ts @@ -0,0 +1,71 @@ +import { telephonyRuntimeUse } from '@agent-device/contracts/platform-runtime-operations'; +import type { BoundDeviceRuntime } from '@agent-device/contracts/platform-runtime'; +import { + parseTelephonyPositionals, + type TelephonyCommandResult, + type TelephonyEventInput, +} from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { ResolvedGenericExecution } from '../core/generic-leaf-execution.ts'; +import { + resolveBoundGenericRuntime, + type RuntimeAdmissionBindings, +} from '../core/runtime-admission.ts'; + +/** + * The one place `telephony` reaches a device (ADR 0019). The event is parsed before admission, so + * an invalid number or text is refused before any owner is inspected or bound. + */ +export async function resolveBoundTelephonyRuntime( + params: { + device: DeviceInfo; + positionals: readonly string[]; + } & RuntimeAdmissionBindings, +): Promise { + const input = parseTelephonyPositionals(params.positionals); + return await resolveBoundGenericRuntime( + { + command: 'telephony', + device: params.device, + use: telephonyRuntimeUse, + inspectFacts: params.inspectFacts, + platformServices: params.platformServices, + bindDevice: params.bindDevice, + }, + (runtime) => executeTelephonyEvent(runtime, input), + ); +} + +async function executeTelephonyEvent( + runtime: BoundDeviceRuntime, + input: TelephonyEventInput, +): Promise { + await runtime.operations.sendTelephonyEvent(input); + switch (input.action) { + case 'call': + return { + action: input.action, + number: input.number, + message: `Simulated an incoming call from ${input.number}`, + }; + case 'accept': + return { + action: input.action, + number: input.number, + message: `Accepted the call from ${input.number}`, + }; + case 'cancel': + return { + action: input.action, + number: input.number, + message: `Ended the call from ${input.number}`, + }; + case 'sms': + return { + action: input.action, + number: input.number, + textLength: input.text.length, + message: `Simulated an incoming SMS from ${input.number}`, + }; + } +} diff --git a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts index 0b0ebb07dc..186e6f6547 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/install-source.test.ts @@ -465,6 +465,7 @@ function sourceRuntimeFacts( ...clipboardRuntimeOperationFacts({ unsupported: unavailable }), ...systemButtonRuntimeOperationFacts({ unsupported: unavailable }), setFoldPose: unavailable, + sendTelephonyEvent: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, readSetting: unavailable, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts index a6b70410e2..21bc8ffffe 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-appstate-device-runtime.test.ts @@ -71,6 +71,7 @@ test('appstate rejects web before Android app-state backend dispatch', async () clipboard: { available: false, reason: 'unsupported-platform-leaf' }, systemButton: { available: false, reason: 'unsupported-platform-leaf' }, fold: { available: false, reason: 'unsupported-platform-leaf' }, + telephony: { available: false, reason: 'unsupported-platform-leaf' }, triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' }, settings: { available: false, reason: 'unsupported-platform-leaf' }, readAlert: { available: false, reason: 'unsupported-platform-leaf' }, diff --git a/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts b/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts index 0b593bd6d6..fae6b779c1 100644 --- a/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts +++ b/packages/daemon/src/sessions/handlers/__tests__/session-boot.test.ts @@ -486,6 +486,7 @@ test('boot rejects --headless outside Android directly', async () => { clipboard: { available: false, reason: 'owner-capability-missing' }, systemButton: { available: false, reason: 'owner-capability-missing' }, fold: { available: false, reason: 'owner-capability-missing' }, + telephony: { available: false, reason: 'owner-capability-missing' }, triggerAppEvent: { available: false, reason: 'owner-capability-missing' }, settings: { available: false, reason: 'owner-capability-missing' }, readAlert: { available: false, reason: 'owner-capability-missing' }, diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 74066ba81c..66bbfab378 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -246,6 +246,31 @@ test('Android admits the fold fact on emulators and refuses every other kind', a } }); +test('Android admits the telephony fact on emulators and refuses every other kind', async () => { + const emulator = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost()), + ANDROID_EMULATOR, + ); + expect(emulator.facts.operations.sendTelephonyEvent).toEqual({ available: true }); + expect(emulator.operations.sendTelephonyEvent).toEqual(expect.any(Function)); + for (const runtimeDevice of [ + { ...ANDROID_EMULATOR, kind: 'device' as const }, + { ...ANDROID_EMULATOR, kind: 'simulator' as const }, + UNKNOWN_KIND_DEVICE, + ]) { + const binding = await bindOrdinary( + createAndroidPlatformRuntime(androidNavigationHost()), + runtimeDevice, + ); + expect(binding.facts.operations.sendTelephonyEvent).toMatchObject({ + available: false, + reason: 'unsupported-device-kind', + hint: expect.stringContaining('physical device has no console'), + }); + expect(binding.operations.sendTelephonyEvent).toBeUndefined(); + } +}); + // R55 parity: the retired `clipboard` bucket was `ANDROID_ALL` (emulator/device/unknown) with no // Android admission closure, so `cmd clipboard get/set text` is admitted on every real kind and // refused only on the synthetic `simulator` row the bucket never listed. (`unknown` is the diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index a5267bcabb..3db16e55f1 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -52,6 +52,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { AndroidSetting } from './settings.ts'; import { createHostAudioProbeCaptureOperations } from '@agent-device/capture-kit'; import { androidFoldableFacts, createAndroidFoldableOperations } from './foldable/runtime.ts'; +import { androidTelephonyFacts, createAndroidTelephonyOperations } from './telephony/runtime.ts'; import { androidAudioProbeCaptureFact } from './audio-runtime.ts'; import { ANDROID_CLIPBOARD_SHELL_COMMAND_UNAVAILABLE_HINT } from './clipboard-shell-response.ts'; import { createAndroidPerfOperations } from './perf/runtime.ts'; @@ -348,6 +349,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor appSwitcher: androidTouchFact(device), }), ...androidFoldableFacts(device), + ...androidTelephonyFacts(device), // The deep link opens through `am start`, admitted wherever the retired `ANDROID_ALL` // bucket admitted it. ...appEventRuntimeOperationFacts({ triggerAppEvent: androidTouchFact(device) }), @@ -476,6 +478,10 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor device: request.device, signal: request.scope.signal, }), + ...createAndroidTelephonyOperations({ + device: request.device, + signal: request.scope.signal, + }), ensureReady: async (input: EnsureReadyInput) => await ensureAndroidReady( host, diff --git a/packages/platform-android/src/telephony/emulator-console.test.ts b/packages/platform-android/src/telephony/emulator-console.test.ts new file mode 100644 index 0000000000..24d36976d2 --- /dev/null +++ b/packages/platform-android/src/telephony/emulator-console.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('../adb.ts', () => ({ runAndroidAdb: vi.fn() })); + +import { runAndroidAdb } from '../adb.ts'; +import { ANDROID_EMULATOR } from '../runtime.fixtures.ts'; +import { sendAndroidTelephonyEvent } from './emulator-console.ts'; + +const mockAdb = vi.mocked(runAndroidAdb); + +function answer(stdout: string, exitCode = 0) { + return { stdout, stderr: '', exitCode }; +} + +beforeEach(() => { + mockAdb.mockReset(); + mockAdb.mockResolvedValue(answer('OK\n')); +}); + +test.each([ + [{ action: 'call', number: '5551234' }, ['emu', 'gsm', 'call', '5551234']], + [{ action: 'accept', number: '5551234' }, ['emu', 'gsm', 'accept', '5551234']], + [{ action: 'cancel', number: '+15551234567' }, ['emu', 'gsm', 'cancel', '+15551234567']], + [{ action: 'sms', number: '5551234', text: 'hello' }, ['emu', 'sms', 'send', '5551234', 'hello']], +] as const)('sends %o to the emulator console as %o', async (input, args) => { + const { signal } = new AbortController(); + await sendAndroidTelephonyEvent(ANDROID_EMULATOR, input, { signal }); + expect(mockAdb).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR, args, { + signal, + allowFailure: true, + }); +}); + +test('passes SMS text with spaces, quotes, and shell metacharacters as one argument', async () => { + const text = `Don't "pause" $HOME; echo $(id) & *`; + await sendAndroidTelephonyEvent(ANDROID_EMULATOR, { action: 'sms', number: '5551234', text }); + expect(mockAdb.mock.calls[0]?.[1]).toEqual(['emu', 'sms', 'send', '5551234', text]); +}); + +test('escapes backslashes and line breaks for the console instead of ending its command line', async () => { + await sendAndroidTelephonyEvent(ANDROID_EMULATOR, { + action: 'sms', + number: '5551234', + text: 'line one\nC:\\path\\n', + }); + expect(mockAdb.mock.calls[0]?.[1]).toEqual([ + 'emu', + 'sms', + 'send', + '5551234', + String.raw`line one\nC:\\path\\n`, + ]); + expect(mockAdb.mock.calls[0]?.[1].at(-1)).not.toContain('\n'); +}); + +test('reports a console KO reply as a typed refusal even though adb exits 0', async () => { + mockAdb.mockResolvedValueOnce(answer('KO: no current call for this number\n')); + await expect( + sendAndroidTelephonyEvent(ANDROID_EMULATOR, { action: 'accept', number: '5551234' }), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + deviceId: ANDROID_EMULATOR.id, + reason: 'telephony-console-refused', + consoleReason: 'no current call for this number', + hint: expect.stringContaining('telephony call '), + }, + }); +}); + +test('reports an adb failure with the adb exit details rather than a console refusal', async () => { + mockAdb.mockResolvedValueOnce({ stdout: '', stderr: 'error: device offline', exitCode: 1 }); + const failure = sendAndroidTelephonyEvent(ANDROID_EMULATOR, { + action: 'call', + number: '5551234', + }); + await expect(failure).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: expect.stringContaining('exited with code 1'), + details: expect.objectContaining({ deviceId: ANDROID_EMULATOR.id, exitCode: 1 }), + }); + await expect(failure).rejects.not.toMatchObject({ + details: { reason: 'telephony-console-refused' }, + }); +}); diff --git a/packages/platform-android/src/telephony/emulator-console.ts b/packages/platform-android/src/telephony/emulator-console.ts new file mode 100644 index 0000000000..a28ca0d0e4 --- /dev/null +++ b/packages/platform-android/src/telephony/emulator-console.ts @@ -0,0 +1,61 @@ +import type { TelephonyEventInput } from '@agent-device/contracts/device'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; + +import { runAndroidAdb } from '../adb.ts'; +import { androidAdbResultError } from '../adb-failure.ts'; + +const CONSOLE_REFUSED_HINT = + 'The emulator console refused the event. accept and cancel need a call from that same number (start one with telephony call ), and an emulator image without a modem has no telephony to simulate.'; + +/** + * Simulates one telephony event through the emulator console (`adb emu gsm …` / `adb emu sms send + * …`). The console answers `KO: ` on stdout, with a zero exit code, when it refuses an + * event, so the reply is checked as well as the exit code. + */ +export async function sendAndroidTelephonyEvent( + device: DeviceInfo, + input: TelephonyEventInput, + options: { signal?: AbortSignal } = {}, +): Promise { + const args = emulatorConsoleArgs(input); + const result = await runAndroidAdb(device, args, { signal: options.signal, allowFailure: true }); + if (result.exitCode !== 0) { + // Names the console command without its number or message text. + const command = args.slice(0, 3).join(' '); + throw androidAdbResultError(`adb ${command} exited with code ${result.exitCode}`, result, { + deviceId: device.id, + }); + } + const refusal = /^KO\b:?[ \t]*(.*)$/m.exec(result.stdout); + if (refusal) { + throw new AppError('COMMAND_FAILED', `The emulator console refused telephony ${input.action}`, { + deviceId: device.id, + reason: 'telephony-console-refused', + stdout: result.stdout.trim(), + ...(refusal[1] ? { consoleReason: refusal[1].trim() } : {}), + hint: CONSOLE_REFUSED_HINT, + }); + } +} + +function emulatorConsoleArgs(input: TelephonyEventInput): string[] { + switch (input.action) { + case 'call': + case 'accept': + case 'cancel': + return ['emu', 'gsm', input.action, input.number]; + case 'sms': + return ['emu', 'sms', 'send', input.number, escapeConsoleSmsText(input.text)]; + } +} + +/** + * `adb emu` writes its arguments to the console as one space-joined line, and `sms send` takes the + * rest of that line as the message. The console decodes `\\`, `\n`, `\xNN`, and `\uNNNN` escapes + * in it and rejects any other backslash, so a literal backslash is doubled and a line break is + * sent as `\n` rather than ending the console command. + */ +function escapeConsoleSmsText(text: string): string { + return text.replaceAll('\\', String.raw`\\`).replaceAll('\n', String.raw`\n`); +} diff --git a/packages/platform-android/src/telephony/runtime.test.ts b/packages/platform-android/src/telephony/runtime.test.ts new file mode 100644 index 0000000000..dc67621e9e --- /dev/null +++ b/packages/platform-android/src/telephony/runtime.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('./emulator-console.ts', () => ({ sendAndroidTelephonyEvent: vi.fn() })); + +import { + localRuntimeOwner, + narrowDeviceBinding, + type DeviceBinding, + type RuntimeFacts, +} from '@agent-device/contracts/platform-runtime'; +import { + telephonyRuntimeUse, + type PlatformRuntimeOperations, +} from '@agent-device/contracts/platform-runtime-operations'; +import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; + +import { ANDROID_EMULATOR, UNKNOWN_KIND_DEVICE } from '../runtime.fixtures.ts'; +import { sendAndroidTelephonyEvent } from './emulator-console.ts'; +import { androidTelephonyFacts, createAndroidTelephonyOperations } from './runtime.ts'; + +const mockSend = vi.mocked(sendAndroidTelephonyEvent); + +const physical: DeviceInfo = { ...ANDROID_EMULATOR, kind: 'device' }; + +/** A full owner binding whose telephony cell is derived only from the fact under test. */ +function telephonyBinding(device: DeviceInfo): DeviceBinding { + const { sendTelephonyEvent } = androidTelephonyFacts(device); + return { + device, + owner: localRuntimeOwner('android'), + facts: { + device: { ...deviceShape(device), providerMode: 'local' }, + operations: { sendTelephonyEvent } as RuntimeFacts['operations'], + }, + operations: createAndroidTelephonyOperations({ device, signal: new AbortController().signal }), + [Symbol.asyncDispose]: async () => {}, + }; +} + +beforeEach(() => { + mockSend.mockReset(); + mockSend.mockResolvedValue(undefined); +}); + +test('an emulator admits sendTelephonyEvent and binds it to the bound signal', async () => { + expect(androidTelephonyFacts(ANDROID_EMULATOR).sendTelephonyEvent).toEqual({ available: true }); + const { signal } = new AbortController(); + const operations = createAndroidTelephonyOperations({ device: ANDROID_EMULATOR, signal }); + + const input = { action: 'sms', number: '5551234', text: 'hello' } as const; + await operations.sendTelephonyEvent?.(input); + expect(mockSend).toHaveBeenCalledWith(ANDROID_EMULATOR, input, { signal }); +}); + +test('a physical device and an unknown kind refuse sendTelephonyEvent with the typed kind fact', () => { + for (const device of [physical, UNKNOWN_KIND_DEVICE]) { + expect(androidTelephonyFacts(device).sendTelephonyEvent).toMatchObject({ + available: false, + reason: 'unsupported-device-kind', + hint: expect.stringContaining('physical device has no console'), + }); + const operations = createAndroidTelephonyOperations({ + device, + signal: new AbortController().signal, + }); + expect(operations).not.toHaveProperty('sendTelephonyEvent'); + } + expect(mockSend).not.toHaveBeenCalled(); +}); + +test('the narrowed telephony use refuses a physical device as UNSUPPORTED_OPERATION', () => { + expect(() => narrowDeviceBinding(telephonyBinding(physical), telephonyRuntimeUse)).toThrow( + expect.objectContaining({ + code: 'UNSUPPORTED_OPERATION', + details: expect.objectContaining({ reason: 'unsupported-device-kind' }), + }), + ); +}); + +test('an aborted scope never reaches the emulator console', async () => { + const controller = new AbortController(); + const operations = createAndroidTelephonyOperations({ + device: ANDROID_EMULATOR, + signal: controller.signal, + }); + controller.abort(); + await expect( + operations.sendTelephonyEvent?.({ action: 'call', number: '5551234' }), + ).rejects.toThrow(); + expect(mockSend).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-android/src/telephony/runtime.ts b/packages/platform-android/src/telephony/runtime.ts new file mode 100644 index 0000000000..6a17df8b2a --- /dev/null +++ b/packages/platform-android/src/telephony/runtime.ts @@ -0,0 +1,39 @@ +import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; +import { whenAdmitted } from '@agent-device/contracts/platform-runtime'; +import type { TelephonyEventInput } from '@agent-device/contracts/device'; +import { telephonyRuntimeOperationFacts } from '@agent-device/contracts/telephony-runtime'; +import type { DeviceInfo } from '@agent-device/kernel/device'; + +import { sendAndroidTelephonyEvent } from './emulator-console.ts'; + +const available = Object.freeze({ available: true } as const); + +const telephonyKindUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind', + hint: 'telephony simulates calls and SMS through the Android emulator console; a physical device has no console, so place a real call or send a real message to it instead.', +} as const); + +/** The emulator console is the only telephony control adb has, so the kind is the whole admission. */ +function androidTelephonyFact(device: DeviceInfo): RuntimeOperationFact { + return device.kind === 'emulator' ? available : telephonyKindUnavailable; +} + +/** The telephony cell: `sendTelephonyEvent`. */ +export function androidTelephonyFacts(device: DeviceInfo) { + return telephonyRuntimeOperationFacts({ telephony: androidTelephonyFact(device) }); +} + +/** Binds `sendTelephonyEvent` when {@link androidTelephonyFacts} admitted it. */ +export function createAndroidTelephonyOperations(params: { + device: DeviceInfo; + signal: AbortSignal; +}) { + const { device, signal } = params; + return whenAdmitted(androidTelephonyFacts(device).sendTelephonyEvent, () => ({ + sendTelephonyEvent: async (input: TelephonyEventInput) => { + signal.throwIfAborted(); + await sendAndroidTelephonyEvent(device, input, { signal }); + }, + })); +} diff --git a/packages/platform-apple/src/runner-demand.ts b/packages/platform-apple/src/runner-demand.ts index b665535be3..0e3ecc47fa 100644 --- a/packages/platform-apple/src/runner-demand.ts +++ b/packages/platform-apple/src/runner-demand.ts @@ -53,6 +53,8 @@ const APPLE_SIMULATOR_OPERATION_HOSTS: Readonly< setSetting: 'simulator', readSetting: 'simulator', setFoldPose: 'simulator', + // Refused on every Apple leaf at admission, so nothing waits on the runner. + sendTelephonyEvent: 'simulator', // Observation: the AX bridge presents regular and raw trees; custom actions need XCTest. captureSnapshot: 'simulator', captureSnapshotWithoutActiveApp: 'simulator', diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index 3c5f7df86f..a6cfc60736 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -374,6 +374,27 @@ test.each(Object.entries(leaves))( }, ); +test.each(Object.entries(leaves))( + 'refuses the telephony fact for the %s leaf', + async (_name, device) => { + const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + expect(binding.facts.operations.sendTelephonyEvent).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: expect.stringContaining('Android emulator console'), + }); + expect(binding.operations.sendTelephonyEvent).toBeUndefined(); + }, +); + // A simulator scoped to a non-default set is refused by the owner's own fact, before any display // probe or HID effect: CoreDevice's display inventory and hinge-angle readback cannot resolve it, // so ADR 0025's post-dispatch verification is impossible. The default-set iOS leaf stays admitted. diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index a536d89d22..9f387781d0 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -104,6 +104,11 @@ const focusKindUnavailable = unavailableFact( 'unsupported-device-kind', 'focus is supported on Apple simulators and physical devices.', ); +/** Neither simctl nor CoreDevice injects a call or text message into an Apple target. */ +const telephonyUnavailable = unavailableFact( + 'unsupported-platform-leaf', + 'telephony simulates calls and SMS through the Android emulator console; Apple simulators and devices expose no host control that injects a call or text message.', +); const appStateUnavailable = unavailableFact( 'unsupported-platform-leaf', "Apple appstate is unavailable here: the runner reads the session app's XCUIApplication state on iOS-family simulators and physical devices only, and the Apple target answers no sessionless foreground probe. The per-command answer is the targetActivation disclosure, which a capture carries when its command had to re-activate the session app (#2682).", @@ -309,6 +314,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR ...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }), ...appleNavigationFacts(device), ...appleFoldableFacts(device), + sendTelephonyEvent: telephonyUnavailable, ...appleSystemFacts(device), ...audioProbeRuntimeOperationFacts({ capture: appleAudioProbeCaptureFact(device), diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 5f590a9f25..45ccb4fbfd 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -102,6 +102,8 @@ test.each([ expect(binding.operations.actionButton).toBeUndefined(); expect(facts.operations.setFoldPose).toMatchObject({ available: false }); expect(binding.operations.setFoldPose).toBeUndefined(); + expect(facts.operations.sendTelephonyEvent).toMatchObject({ available: false }); + expect(binding.operations.sendTelephonyEvent).toBeUndefined(); // Public orientation and TV-remote operations remain unavailable unconditionally. expect(facts.operations.setOrientation).toEqual({ available: false, diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index 1063b035e2..0325c77f0b 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -248,6 +248,8 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor }), // HarmonyOS devices have no foldable hinge control for HDC to pose. setFoldPose: harmonyPlatformLeafUnavailable, + // HDC has no telephony control to simulate a call or text message. + sendTelephonyEvent: harmonyPlatformLeafUnavailable, // HarmonyOS has no trigger-app-event implementation. ...appEventRuntimeOperationFacts({ triggerAppEvent: harmonyPlatformLeafUnavailable }), ...settingsRuntimeOperationFacts({ diff --git a/packages/platform-linux/src/runtime.test.ts b/packages/platform-linux/src/runtime.test.ts index 9da74e6353..67f8b8c9fe 100644 --- a/packages/platform-linux/src/runtime.test.ts +++ b/packages/platform-linux/src/runtime.test.ts @@ -222,6 +222,8 @@ function expectLinuxNavigationAndKeyboardFacts( 'actionButton', // Nor does it have a foldable hinge to pose. 'setFoldPose', + // Nor a modem to simulate a call or text message on. + 'sendTelephonyEvent', // R57: the retired `trigger-app-event` descriptor declared `linux: {}` too. 'triggerAppEvent', // R58/R59: and so did `settings` and `alert`. diff --git a/packages/platform-linux/src/runtime.ts b/packages/platform-linux/src/runtime.ts index 603ec8fc71..c04c74bfd2 100644 --- a/packages/platform-linux/src/runtime.ts +++ b/packages/platform-linux/src/runtime.ts @@ -206,6 +206,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts // own `appSwitcher` throws unsupported, and no Linux leaf has hardware buttons. systemButton: linuxPlatformLeafUnavailable, fold: linuxPlatformLeafUnavailable, + telephony: linuxPlatformLeafUnavailable, // The retired `trigger-app-event` descriptor declared `linux: {}`. triggerAppEvent: linuxPlatformLeafUnavailable, // The retired `settings` descriptor declared `linux: {}` too. diff --git a/packages/platform-vega/src/runtime.ts b/packages/platform-vega/src/runtime.ts index 5f8274091b..2236f8ff4e 100644 --- a/packages/platform-vega/src/runtime.ts +++ b/packages/platform-vega/src/runtime.ts @@ -153,6 +153,10 @@ const foldUnavailable = unavailableFact( 'unsupported-platform-leaf', 'fold is not supported on Vega OS.', ); +const telephonyUnavailable = unavailableFact( + 'unsupported-platform-leaf', + 'telephony is not supported on Vega OS.', +); const clipboardUnavailable = unavailableFact( 'unsupported-platform-leaf', 'clipboard is not supported on Vega OS.', @@ -199,6 +203,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts clipboard: clipboardUnavailable, systemButton: systemButtonUnavailable, fold: foldUnavailable, + telephony: telephonyUnavailable, triggerAppEvent: appEventUnavailable, settings: settingsUnavailable, readAlert: alertUnavailable, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index 129df46109..e9eac1a1c3 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -217,6 +217,8 @@ test('clipboard, the app switcher, app events, settings and alerts carry no web // A foldable hinge is posed through the host's iOS simulator HID helper; the web target has // none. 'setFoldPose', + // Telephony events go through an Android emulator console, which a browser has no analogue of. + 'sendTelephonyEvent', 'triggerAppEvent', // R58/R59: the retired `settings` and `alert` descriptors declared no web leaf either. 'setSetting', diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index 8042097880..b571fb1a36 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -415,6 +415,7 @@ function webRuntimeFacts( }), ...systemButtonRuntimeOperationFacts({ unsupported: navigationUnavailable }), setFoldPose: navigationUnavailable, + sendTelephonyEvent: navigationUnavailable, ...appEventRuntimeOperationFacts({ triggerAppEvent: navigationUnavailable }), ...settingsRuntimeOperationFacts({ target: navigationUnavailable, diff --git a/packages/provider-limrun/src/app-log-runtime.ts b/packages/provider-limrun/src/app-log-runtime.ts index 1f93bc24e6..81abca07d3 100644 --- a/packages/provider-limrun/src/app-log-runtime.ts +++ b/packages/provider-limrun/src/app-log-runtime.ts @@ -3,6 +3,7 @@ import type { AppsFilter, ProviderPortReverseOptions } from '@agent-device/contr import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import { LIMRUN_FOLD_UNAVAILABLE, + LIMRUN_TELEPHONY_UNAVAILABLE, bindLimrunInteractionOperations, } from './interaction-operations.ts'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; @@ -118,6 +119,7 @@ export function createLimrunPlatformRuntimeOwner( clipboard: liveSessionUnavailable, systemButton: liveSessionUnavailable, fold: LIMRUN_FOLD_UNAVAILABLE, + telephony: LIMRUN_TELEPHONY_UNAVAILABLE, triggerAppEvent: liveSessionUnavailable, settings: liveSessionUnavailable, readAlert: liveSessionUnavailable, diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index 02d0fc798e..0ffe116439 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -23,6 +23,7 @@ import { limrunAlertOperationFacts, limrunSystemButtonOperationFacts, limrunFoldOperationFacts, + limrunTelephonyOperationFacts, limrunClipboardOperationFacts, limrunNavigationOperationFacts, } from './interaction-operations.ts'; @@ -186,6 +187,7 @@ export function limrunAppLogFacts( ...limrunClipboardOperationFacts(), ...limrunSystemButtonOperationFacts(device), ...limrunFoldOperationFacts(), + ...limrunTelephonyOperationFacts(), ...limrunAppEventOperationFacts(device), ...limrunSettingsOperationFacts(device), ...limrunAlertOperationFacts(device), @@ -249,6 +251,7 @@ export function limrunAppLogRecoveryFacts( ...limrunClipboardOperationFacts(liveSessionUnavailable), ...limrunSystemButtonOperationFacts(device, liveSessionUnavailable), ...limrunFoldOperationFacts(), + ...limrunTelephonyOperationFacts(), ...limrunAppEventOperationFacts(device, liveSessionUnavailable), ...limrunSettingsOperationFacts(device, liveSessionUnavailable), ...limrunAlertOperationFacts(device, liveSessionUnavailable), diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 6410609599..5a4e70e99e 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -324,11 +324,25 @@ export const LIMRUN_FOLD_UNAVAILABLE = unavailableFact( 'fold runs a HID helper through simctl spawn inside a foldable iPhone simulator on the daemon host; a Limrun session has no such simulator.', ); +/** + * `telephony` drives the console of an emulator the daemon host runs through local adb; a Limrun + * session's device is not one. + */ +export const LIMRUN_TELEPHONY_UNAVAILABLE = unavailableFact( + 'unsupported-provider-mode', + 'telephony simulates calls and SMS through the console of an Android emulator on the daemon host; a Limrun session exposes no emulator console.', +); + /** The fold refusal both Limrun legs share. */ export function limrunFoldOperationFacts() { return Object.freeze({ setFoldPose: LIMRUN_FOLD_UNAVAILABLE }); } +/** The telephony refusal both Limrun legs share. */ +export function limrunTelephonyOperationFacts() { + return Object.freeze({ sendTelephonyEvent: LIMRUN_TELEPHONY_UNAVAILABLE }); +} + /** * `trigger-app-event` is the one system leaf both direct-session legs genuinely serve: each * implements `open`, and a deep link is exactly what that method routes (`openUrl` on iOS, the diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index d1b29c95b1..e08d277e45 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -222,6 +222,12 @@ const foldUnavailable = unavailableFact( 'fold runs a HID helper through simctl spawn inside a foldable iPhone simulator on the daemon host; a WebDriver backend has no such simulator.', ); +/** No WebDriver backend reaches an emulator console, so no call or text message is simulated. */ +const telephonyUnavailable = unavailableFact( + 'unsupported-provider-mode', + 'telephony simulates calls and SMS through the console of an Android emulator on the daemon host; a WebDriver backend exposes no emulator console.', +); + /** * The WebDriver interactor supplies no alert members at all: ADR 0019 keeps a stub that throws * `unsupported` out of an interactor, and the provider's capability map declares @@ -486,6 +492,7 @@ function webDriverFacts( return createUnavailablePlatformRuntimeFacts(device, options.owner, { ...createFullyUnavailablePlatformRuntimeFacts(inactiveSession), fold: foldUnavailable, + telephony: telephonyUnavailable, }); } const deployment = options.deployment?.fact(device) ?? deploymentUnavailable; @@ -509,6 +516,7 @@ function webDriverFacts( clipboard: clipboardUnavailable, systemButton: systemButtonUnavailable, fold: foldUnavailable, + telephony: telephonyUnavailable, triggerAppEvent: appEventUnavailable, settings: settingsUnavailable, readAlert: alertUnavailable, @@ -620,6 +628,7 @@ function webDriverFacts( appSwitcher: declared('appSwitcher', appSwitcherUnavailable), }), setFoldPose: foldUnavailable, + sendTelephonyEvent: telephonyUnavailable, // The deep link opens through the same reachable interactor `open` every lifecycle command // drives on this provider. ...appEventRuntimeOperationFacts({ diff --git a/packages/session-journal/src/__tests__/session-event-action.test.ts b/packages/session-journal/src/__tests__/session-event-action.test.ts index 211a562e54..469b4ed69f 100644 --- a/packages/session-journal/src/__tests__/session-event-action.test.ts +++ b/packages/session-journal/src/__tests__/session-event-action.test.ts @@ -300,3 +300,31 @@ test('record and trace events expose only bounded artifact basenames', () => { showTouches: false, }); }); + +test('telephony action events name the simulated event and carry neither number nor text', () => { + const sms = action( + 'telephony', + { + action: 'sms', + number: '5551234', + textLength: 11, + message: 'Simulated an incoming SMS from 5551234', + }, + { positionals: ['sms', '5551234', 'private msg'] }, + ); + const call = action('telephony', { action: 'call', number: '5551234' }); + const cancel = action('telephony', { action: 'cancel', number: '5551234' }); + + assert.equal(buildActionSummary(sms), 'Simulated an incoming SMS'); + assert.equal(buildActionSummary(call), 'Simulated an incoming call'); + assert.equal(buildActionSummary(cancel), 'Ended the simulated call'); + assert.equal(buildActionSummary(action('telephony', {})), 'Simulated a telephony event'); + assert.deepEqual(buildActionDetails(sms), { + command: 'telephony', + action: 'sms', + positionals: ['', '', ''], + }); + const serialized = JSON.stringify(buildActionDetails(sms)); + assert.equal(serialized.includes('5551234'), false); + assert.equal(serialized.includes('private msg'), false); +}); diff --git a/packages/session-journal/src/session-event-action-presentation.ts b/packages/session-journal/src/session-event-action-presentation.ts index ee196cf3b7..461050cab3 100644 --- a/packages/session-journal/src/session-event-action-presentation.ts +++ b/packages/session-journal/src/session-event-action-presentation.ts @@ -1,5 +1,5 @@ import type { SessionAction } from '@agent-device/contracts/session'; -import { DEVICE_ROTATIONS, FOLD_POSES } from '@agent-device/contracts/device'; +import { DEVICE_ROTATIONS, FOLD_POSES, TELEPHONY_ACTIONS } from '@agent-device/contracts/device'; import { BACK_MODES } from '@agent-device/contracts/back-mode'; import { TV_REMOTE_BUTTONS } from '@agent-device/contracts/tv-remote'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; @@ -43,6 +43,8 @@ export function buildStructuredActionSummary(action: SessionAction): string | un return buildOrientationActionSummary(result); case PUBLIC_COMMANDS.fold: return buildFoldActionSummary(result); + case PUBLIC_COMMANDS.telephony: + return buildTelephonyActionSummary(result); case PUBLIC_COMMANDS.viewport: return buildViewportActionSummary(result); case PUBLIC_COMMANDS.clipboard: @@ -90,6 +92,8 @@ export function buildStructuredActionDetails(action: SessionAction): Record): string { return pose ? `Folded to ${pose}` : 'Changed fold pose'; } +function buildTelephonyActionSummary(result: Record): string { + switch (readEnum(result.action, TELEPHONY_ACTIONS)) { + case 'call': + return 'Simulated an incoming call'; + case 'accept': + return 'Accepted the simulated call'; + case 'cancel': + return 'Ended the simulated call'; + case 'sms': + return 'Simulated an incoming SMS'; + case undefined: + return 'Simulated a telephony event'; + } +} + function buildOrientationActionSummary(result: Record): string { const orientation = readEnum(result.orientation, DEVICE_ROTATIONS); if (!orientation) return 'Changed orientation'; diff --git a/packages/test-kit/src/runtime-operation-facts.ts b/packages/test-kit/src/runtime-operation-facts.ts index 778a1a7ed2..a82a646a52 100644 --- a/packages/test-kit/src/runtime-operation-facts.ts +++ b/packages/test-kit/src/runtime-operation-facts.ts @@ -69,6 +69,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre ...clipboardRuntimeOperationFacts({ unsupported: unavailable }), ...systemButtonRuntimeOperationFacts({ unsupported: unavailable }), setFoldPose: unavailable, + sendTelephonyEvent: unavailable, triggerAppEvent: unavailable, setSetting: unavailable, readSetting: unavailable, diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index 84550644a7..421b6ddc9f 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -172,6 +172,8 @@ export function createAgentDeviceClient( await executeCommand>('clipboard', options), tvRemote: async (options) => await executeCommand>('tv-remote', options), + telephony: async (options) => + await executeCommand>('telephony', options), reactNative: async (options) => await executeCommand('react-native', options), doctor: async (options = {}) => await executeCommand>('doctor', options), diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 77bb3be7ea..e51dfa8956 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -127,6 +127,7 @@ import type { SettingsUpdateOptions, SwipeGestureOptions, SwipeOptions, + TelephonyCommandOptions, TraceOptions, TransformGestureOptions, TvRemoteCommandOptions, @@ -171,6 +172,7 @@ export type AgentDeviceCommandClient = { appSwitcher: (options?: AppSwitcherCommandOptions) => Promise>; actionButton: (options?: ActionButtonCommandOptions) => Promise>; tvRemote: (options: TvRemoteCommandOptions) => Promise>; + telephony: (options: TelephonyCommandOptions) => Promise>; wait: (options: WaitCommandOptions) => Promise>; alert: (options?: AlertCommandOptions) => Promise; appState: (options?: AppStateCommandOptions) => Promise>; diff --git a/src/commands/replay/replay-script.fixtures.ts b/src/commands/replay/replay-script.fixtures.ts index e763d7005a..e670560a12 100644 --- a/src/commands/replay/replay-script.fixtures.ts +++ b/src/commands/replay/replay-script.fixtures.ts @@ -133,6 +133,7 @@ const REPLAY_SCRIPT_LINE_PLANS = { 'app-switcher': GENERIC_REPLAY_LINE, 'action-button': GENERIC_REPLAY_LINE, fold: GENERIC_REPLAY_LINE, + telephony: GENERIC_REPLAY_LINE, apps: GENERIC_REPLAY_LINE, appstate: GENERIC_REPLAY_LINE, artifacts: GENERIC_REPLAY_LINE, diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index ea17878188..e4c70e1a71 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -7,10 +7,12 @@ import type { FoldCommandOptions, HomeCommandOptions, OrientationCommandOptions, + TelephonyCommandOptions, TvRemoteCommandOptions, } from '../../client/client-types.ts'; import type { CommandResult } from '@agent-device/command-registry/command-result'; import { readInputFromCli } from '../cli-grammar/registry.ts'; +import { findCommandMetadata } from '../command-metadata.ts'; import type { CliFlags } from '@agent-device/contracts/command'; import { backCliReader, @@ -23,6 +25,8 @@ import { keyboardDaemonWriter, orientationCliReader, orientationDaemonWriter, + telephonyCliReader, + telephonyDaemonWriter, tvRemoteCliReader, tvRemoteDaemonWriter, systemCommandFamily, @@ -380,3 +384,61 @@ test('fold keyframes survive CLI and daemon projection without a preset', () => }); expect(() => foldCliReader(['open'], flags({ keyframes: JSON.stringify(keyframes) }))).toThrow(); }); + +test('the typed client exposes telephony with its closed result', () => { + expectTypeOf().toEqualTypeOf< + (options: TelephonyCommandOptions) => Promise> + >(); +}); + +test('the CLI reader parses each action and joins unquoted SMS words', () => { + expect(telephonyCliReader(['call', '5551234'], flags({ platform: 'android' }))).toMatchObject({ + platform: 'android', + action: 'call', + number: '5551234', + }); + expect(telephonyCliReader(['Accept', '5551234'], flags())).toMatchObject({ action: 'accept' }); + expect(telephonyCliReader(['cancel', '5551234'], flags())).toMatchObject({ action: 'cancel' }); + expect(telephonyCliReader(['sms', '5551234', 'Meet', 'at', '8'], flags())).toMatchObject({ + action: 'sms', + number: '5551234', + text: 'Meet at 8', + }); +}); + +test('the CLI reader refuses a malformed event before any request is written', () => { + expect(() => telephonyCliReader(['sms', '5551234'], flags())).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS', message: expect.stringContaining('text') }), + ); + expect(() => telephonyCliReader(['call', 'abc'], flags())).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS', message: 'Invalid phone number: abc.' }), + ); +}); + +test('the daemon writer sends the SMS text as one positional, quotes and all', () => { + const text = `Don't "stop" the stream`; + expect(telephonyDaemonWriter({ action: 'sms', number: '5551234', text })).toMatchObject({ + command: 'telephony', + positionals: ['sms', '5551234', text], + }); + expect(telephonyDaemonWriter({ action: 'cancel', number: '5551234' })).toMatchObject({ + command: 'telephony', + positionals: ['cancel', '5551234'], + }); + expect(() => telephonyDaemonWriter({ action: 'call', number: '5551234', text: 'x' })).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS' }), + ); +}); + +test('structured input validates through the same parser as the CLI', () => { + const metadata = findCommandMetadata('telephony'); + expect(metadata.readInput({ action: 'sms', number: '+15551234567', text: 'hi' })).toMatchObject({ + action: 'sms', + number: '+15551234567', + text: 'hi', + }); + expect(() => metadata.readInput({ action: 'call', number: '555 1234' })).toThrow( + expect.objectContaining({ code: 'INVALID_ARGS' }), + ); + expect(metadata.inputSchema).toMatchObject({ required: ['action', 'number'] }); +}); diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 2c0e56e05a..fb771a580e 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -1,6 +1,9 @@ import { FOLD_FLAGS } from '@agent-device/command-registry/flag-groups'; import type { CommandResultMap } from '@agent-device/command-registry/command-result'; -import type { ClipboardCommandOptions } from '@agent-device/contracts/client'; +import type { + ClipboardCommandOptions, + TelephonyCommandOptions, +} from '@agent-device/contracts/client'; import { type FoldKeyframe, FOLD_SCREEN_COORDINATE_SPACE, @@ -12,6 +15,12 @@ import { FOLD_POSES, FOLD_POSE_USAGE, parseDeviceRotation, + TELEPHONY_ACTIONS, + TELEPHONY_CALL_ACTIONS, + TELEPHONY_USAGE, + parseTelephonyEventInput, + parseTelephonyPositionals, + telephonyPositionals, } from '@agent-device/contracts/device'; import { type BackMode, BACK_MODES } from '@agent-device/contracts/back-mode'; import { @@ -70,6 +79,7 @@ const ACTION_BUTTON_COMMAND_NAME = 'action-button'; const KEYBOARD_COMMAND_NAME = 'keyboard'; const CLIPBOARD_COMMAND_NAME = 'clipboard'; const TV_REMOTE_COMMAND_NAME = 'tv-remote'; +const TELEPHONY_COMMAND_NAME = 'telephony'; const TV_REMOTE_LONGPRESS_PRESET_MS = 500; const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const; @@ -145,6 +155,29 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { }, ['action', 'button', 'message'], ), + // packages/contracts/src/device-rotation.ts — discriminated union on `action`. + telephony: { + type: 'object', + oneOf: [ + objectSchema( + { + action: enumSchema(TELEPHONY_CALL_ACTIONS), + number: stringSchema(), + message: stringSchema(), + }, + ['action', 'number', 'message'], + ), + objectSchema( + { + action: constSchema('sms'), + number: stringSchema(), + textLength: numberSchema('Length of the delivered message text.'), + message: stringSchema(), + }, + ['action', 'number', 'textLength', 'message'], + ), + ], + }, // packages/contracts/src/clipboard.ts — discriminated union on `action`. clipboard: { type: 'object', @@ -220,6 +253,7 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { | 'fold' | 'action-button' | 'tv-remote' + | 'telephony' | 'clipboard' | 'appstate' | 'keyboard' @@ -273,6 +307,9 @@ const actionButtonCommandDescription = const tvRemoteCommandDescription = 'Press or long-press a TV remote or D-pad button on Android TV, tvOS, or Vega OS. Choose the button and optional hold duration through the input fields. The aliases ok, center, and enter all map to select.'; +const telephonyCommandDescription = + 'Simulate an incoming phone call or SMS on an Android emulator through the emulator console, for example to check how an app reacts when a call or message interrupts playback. call starts an incoming call from the number, accept answers it, cancel ends it, and sms delivers a text message from the number. The call screen or message notification can cover the app, so re-snapshot before using refs from before the event. Local Android emulators only; physical Android devices, iOS, and every other target refuse with UNSUPPORTED_OPERATION.'; + const backCommandMetadata = defineFieldCommandMetadata(BACK_COMMAND_NAME, backCommandDescription, { mode: enumField(BACK_MODES), ...postActionObservationFields(BACK_COMMAND_NAME), @@ -362,6 +399,31 @@ const tvRemoteCommandMetadata = defineFieldCommandMetadata( }, ); +const telephonyFields = { + action: requiredField( + enumField( + TELEPHONY_ACTIONS, + 'call starts an incoming call, accept answers it, cancel ends it, sms delivers a text message.', + ), + ), + number: requiredField( + stringField('Caller or sender phone number: digits with an optional leading +.'), + ), + text: stringField('Message text for sms; required for sms and refused for the call actions.'), +}; + +const telephonyCommandMetadata = defineFieldCommandMetadata( + TELEPHONY_COMMAND_NAME, + telephonyCommandDescription, + telephonyFields, + { + readInput: (input) => { + const { action, number, text, ...common } = readFieldInput(input, telephonyFields); + return { ...common, ...parseTelephonyEventInput({ action, number, text }) }; + }, + }, +); + const backCliSchema = { usageOverride: 'back [--in-app|--system] [--settle]', usageFlags: [], @@ -398,6 +460,13 @@ const tvRemoteCliSchema = { allowedFlags: ['durationMs'], } as const satisfies CommandSchemaOverride; +const telephonyCliSchema = { + usageOverride: TELEPHONY_USAGE, + listUsageOverride: 'telephony call|accept|cancel|sms [text]', + positionalArgs: ['call|accept|cancel|sms', 'number', 'text?'], + allowsExtraPositionals: true, +} as const satisfies CommandSchemaOverride; + export const backCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), mode: flags.backMode, @@ -431,6 +500,11 @@ export const tvRemoteCliReader: CliReader = (positionals, flags) => ({ ...readTvRemoteInput(positionals, flags.durationMs), }); +export const telephonyCliReader: CliReader = (positionals, flags) => ({ + ...commonInputFromFlags(flags), + ...parseTelephonyPositionals(positionals), +}); + export const backDaemonWriter: DaemonWriter = (input) => request(BACK_COMMAND_NAME, [], { ...input, @@ -461,6 +535,10 @@ export const tvRemoteDaemonWriter: DaemonWriter = direct(TV_REMOTE_COMMAND_NAME, requiredDaemonString(input.button, 'tv-remote requires button'), ]); +export const telephonyDaemonWriter: DaemonWriter = direct(TELEPHONY_COMMAND_NAME, (input) => + telephonyPositionals(parseTelephonyEventInput(input)), +); + const appStateCommandFacet = defineParameterlessCommandFacet({ name: APPSTATE_COMMAND_NAME, description: appStateCommandDescription, @@ -583,6 +661,21 @@ const tvRemoteCommandFacet = defineCommandFacet({ cliOutputFormatter: systemCliOutputFormatters['tv-remote'], }); +const telephonyCommandFacet = defineCommandFacet({ + name: TELEPHONY_COMMAND_NAME, + text: { + summary: 'Simulate an incoming call or SMS on an Android emulator', + cliDetail: + 'Android emulators only, through the emulator console. Quote the SMS text, or pass it as the remaining words. Re-snapshot after the event: the call screen or notification can cover the app.', + }, + metadata: telephonyCommandMetadata, + run: (client, input) => client.command.telephony(input as TelephonyCommandOptions), + cliSchema: telephonyCliSchema, + cliReader: telephonyCliReader, + daemonWriter: telephonyDaemonWriter, + cliOutputFormatter: systemCliOutputFormatters.telephony, +}); + export const systemCommandFamily = defineCommandFamilyFromFacets({ name: 'system', commands: [ @@ -596,6 +689,7 @@ export const systemCommandFamily = defineCommandFamilyFromFacets({ keyboardCommandFacet, clipboardCommandFacet, tvRemoteCommandFacet, + telephonyCommandFacet, ], }); diff --git a/src/commands/system/output.ts b/src/commands/system/output.ts index d95661d5e3..e0ce129983 100644 --- a/src/commands/system/output.ts +++ b/src/commands/system/output.ts @@ -52,6 +52,7 @@ export const systemCliOutputFormatters = withSettleCapableNotes({ keyboard: resultOutput(keyboardCliOutput), clipboard: resultOutput(clipboardCliOutput), 'tv-remote': messageOutput, + telephony: messageOutput, } satisfies Record); function formatAppState(data: AppStateCommandResult): string { diff --git a/src/platform-runtime-gateway.test.ts b/src/platform-runtime-gateway.test.ts index e7706fdc37..118fc9a912 100644 --- a/src/platform-runtime-gateway.test.ts +++ b/src/platform-runtime-gateway.test.ts @@ -57,6 +57,7 @@ describe('composed platform runtime gateway', () => { clipboard: unavailable, systemButton: unavailable, fold: unavailable, + telephony: unavailable, triggerAppEvent: unavailable, settings: unavailable, readAlert: unavailable, @@ -155,6 +156,7 @@ describe('composed platform runtime gateway', () => { clipboard: unavailable, systemButton: unavailable, fold: unavailable, + telephony: unavailable, triggerAppEvent: unavailable, settings: unavailable, readAlert: unavailable, diff --git a/test/integration/provider-scenarios/android-telephony.test.ts b/test/integration/provider-scenarios/android-telephony.test.ts new file mode 100644 index 0000000000..6c9c633144 --- /dev/null +++ b/test/integration/provider-scenarios/android-telephony.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AndroidAdbProvider } from '@agent-device/platform-android/mechanics'; +import { assertRpcError } from './assertions.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; +import { runProviderScenario } from './scenario.ts'; + +const PROVIDER_SCENARIO_ANDROID_EMULATOR: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + target: 'mobile', + booted: true, +}; + +const PROVIDER_SCENARIO_ANDROID_PHONE: DeviceInfo = { + ...PROVIDER_SCENARIO_ANDROID_EMULATOR, + id: 'R5CT1234567', + name: 'Pixel 9', + kind: 'device', +}; + +function recordingAdbProvider(reply: (args: readonly string[]) => string = () => 'OK\n') { + const calls: string[][] = []; + const provider: AndroidAdbProvider = { + exec: async (args) => { + calls.push([...args]); + return { stdout: reply(args), stderr: '', exitCode: 0 }; + }, + }; + return { calls, provider }; +} + +const SMS_TEXT = `Don't "stop"\nC:\\videos`; + +function emulatorConsoleCalls(calls: readonly string[][]): string[][] { + return calls.map((call) => call.slice(call.indexOf('emu'))).filter((call) => call[0] === 'emu'); +} + +test('Provider-backed integration Android emulator telephony flow drives the emulator console', async () => { + const adb = recordingAdbProvider(); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adb.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID_EMULATOR], + }), + async (daemon) => { + daemon.publishSession('default', { + name: 'default', + device: PROVIDER_SCENARIO_ANDROID_EMULATOR, + createdAt: Date.now(), + actions: [], + }); + + await runProviderScenario(daemon, [ + { + name: 'ring', + command: 'telephony', + positionals: ['call', '5551234'], + flags: { platform: 'android' }, + expectData: { + action: 'call', + number: '5551234', + message: 'Simulated an incoming call from 5551234', + }, + }, + { + name: 'hang up', + command: 'telephony', + positionals: ['cancel', '5551234'], + flags: { platform: 'android' }, + expectData: { action: 'cancel', number: '5551234' }, + }, + { + name: 'text', + command: 'telephony', + positionals: ['sms', '+15551234567', SMS_TEXT], + flags: { platform: 'android' }, + expectData: { action: 'sms', number: '+15551234567', textLength: SMS_TEXT.length }, + }, + ]); + + assert.deepEqual(emulatorConsoleCalls(adb.calls), [ + ['emu', 'gsm', 'call', '5551234'], + ['emu', 'gsm', 'cancel', '5551234'], + ['emu', 'sms', 'send', '+15551234567', String.raw`Don't "stop"\nC:\\videos`], + ]); + assert.deepEqual( + daemon.session('default')?.actions.map((action) => action.command), + ['telephony', 'telephony', 'telephony'], + ); + }, + ); +}); + +test('Provider-backed integration Android telephony reports a console refusal with its reason', async () => { + const adb = recordingAdbProvider(() => 'KO: no call with this number\n'); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adb.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID_EMULATOR], + }), + async (daemon) => { + daemon.publishSession('default', { + name: 'default', + device: PROVIDER_SCENARIO_ANDROID_EMULATOR, + createdAt: Date.now(), + actions: [], + }); + + const data = assertRpcError( + await daemon.callCommand('telephony', ['accept', '5551234'], { platform: 'android' }), + 'COMMAND_FAILED', + /refused telephony accept/, + ); + assert.equal((data.details as Record)?.reason, 'telephony-console-refused'); + }, + ); +}); + +test('Provider-backed integration Android telephony refuses a physical device before adb', async () => { + const adb = recordingAdbProvider(); + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adb.provider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID_PHONE], + }), + async (daemon) => { + daemon.publishSession('default', { + name: 'default', + device: PROVIDER_SCENARIO_ANDROID_PHONE, + createdAt: Date.now(), + actions: [], + }); + + assertRpcError( + await daemon.callCommand('telephony', ['call', '5551234'], { platform: 'android' }), + 'UNSUPPORTED_OPERATION', + /telephony is not supported on this device/, + ); + assert.deepEqual(emulatorConsoleCalls(adb.calls), []); + }, + ); +}); diff --git a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts index af1a15f125..5d496b981b 100644 --- a/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts +++ b/test/integration/provider-scenarios/provider-device-runtime.fixtures.ts @@ -273,6 +273,8 @@ function providerScenarioRuntimeFacts( // A provider session has no host simulator for the fold HID helper, so no fixture leg poses // a hinge. setFoldPose: fakeProviderUnavailable, + // A provider device exposes no emulator console on the daemon host. + sendTelephonyEvent: fakeProviderUnavailable, // Provider-owned iOS keyboard actions ride the same runner transport the shared interactor // does (#1297): a fixture scenario that can drive the interactor at all can drive these. ...keyboardRuntimeOperationFacts({ diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index 77dc2d31c3..d9d506a74a 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -199,6 +199,8 @@ await client.command.tvRemote({ await client.command.appSwitcher(); await client.command.actionButton(); await client.command.fold({ pose: 'open' }); +await client.command.telephony({ action: 'call', number: '5551234' }); +await client.command.telephony({ action: 'sms', number: '5551234', text: 'Your code is 1234' }); await client.command.fold({ keyframes: [ { atMs: 0, angle: 0 }, @@ -233,6 +235,7 @@ Supported command methods: - `appSwitcher` - `actionButton` - `fold` +- `telephony` - `keyboard` - `clipboard` - `tvRemote` diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b154a7e8c1..f334915e0c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -58,6 +58,9 @@ agent-device action-button agent-device fold closed agent-device fold half-open agent-device fold open +agent-device telephony call 5551234 +agent-device telephony cancel 5551234 +agent-device telephony sms 5551234 "Your code is 1234" agent-device device status --platform android --serial emulator-5554 agent-device device release --stale agent-device daemon stop --clean @@ -120,6 +123,14 @@ Fold a foldable device: - `UNSUPPORTED_OPERATION` covers single-panel devices (`single-panel-device` on Android) and simulators in a non-default `--ios-simulator-device-set` (`unsupported-device-scope`; use the default set). Physical devices are refused. - On Android, `fold` confirms the pose from the device state the emulator commits and reports `hingeAngleDegrees` as the hinge sensor reads it: the angle the AVD profile defines for the posture, such as 0°, 90°, and 180° on the Pixel folds or 15°, 90°, and 165° on the generic 7.6in Foldable. It also dismisses the lock screen that folding to the cover display can raise. For panels, poses, and timings, see `agent-device help foldable`. +Simulate an incoming call or SMS: + +- `telephony call ` starts an incoming call from `` on an Android emulator; `telephony accept ` answers it and `telephony cancel ` ends it. `telephony sms ` delivers a text message from ``. Use it to check how an app reacts when a call or message arrives, for example whether video playback pauses and resumes. +- The events go through the emulator console (`adb emu gsm call|accept|cancel`, `adb emu sms send`), so only local Android emulators driven through adb support them. Physical Android devices refuse with `UNSUPPORTED_OPERATION` and `reason: unsupported-device-kind`; iOS, macOS, tvOS, web, Linux, HarmonyOS, Vega, and Limrun or WebDriver (Appium) sessions refuse with `UNSUPPORTED_OPERATION` too. +- `` is digits with an optional leading `+`, up to 20 digits. Quote the SMS text, or pass it as the remaining words; quotes, backslashes, and line breaks (LF, or CRLF sent as LF) arrive as written. Tabs, lone carriage returns, and other control characters are refused. +- The emulator console answers some events with an error, such as `accept` or `cancel` for a number with no call. That fails with `COMMAND_FAILED`, `reason: telephony-console-refused`, and the console's own `consoleReason`. +- The call screen or message notification can cover the app. Re-snapshot before using refs from before the event. + Remote daemons and tenants: - `--tenant`, `--session-isolation tenant`, `--run-id`, and `--lease-id` enforce lease admission on tenant-scoped daemons. From 6df83c4a71a9b02048f028f0ea459d3ed1ccf2f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 14:03:32 +0000 Subject: [PATCH 2/9] chore(gates): register telephony in the exports snapshot, coverage, and conformance tables The contracts export snapshot names the `telephony-runtime` subpath, the six-platform coverage declaration and its Android evidence row classify the command, the runtime-binding conformance table resolves it, and the provider output guard drives it. --- .../support/runtime-binding-conformance.ts | 11 +++++++ .../layering/contracts-exports.snapshot.json | 1 + .../command-coverage/declarations.ts | 32 +++++++++++++++++++ test/integration/command-coverage/evidence.ts | 6 ++++ .../apple-platform-output-guard.test.ts | 1 + 5 files changed, 51 insertions(+) diff --git a/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts b/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts index c2c89de1c7..0020116cca 100644 --- a/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts +++ b/packages/daemon/src/__tests__/support/runtime-binding-conformance.ts @@ -32,6 +32,7 @@ import { resolveBoundSystemButtonRuntime, type SystemButtonCommand, } from '../../execution/system-button-runtime.ts'; +import { resolveBoundTelephonyRuntime } from '../../execution/telephony-runtime.ts'; import { resolveBoundTvRemoteRuntime } from '../../execution/tv-remote-runtime.ts'; import { resolveBoundTypeTextRuntime } from '../../execution/type-text-runtime.ts'; import { resolveBoundViewportRuntime } from '../../execution/viewport-runtime.ts'; @@ -107,6 +108,16 @@ export const conformedRuntimeBindings = { resolve: async (device, bindings) => refusable(await resolveBoundFoldRuntime({ device, positionals: ['open'], ...bindings })), }, + telephony: { + resolve: async (device, bindings) => + refusable( + await resolveBoundTelephonyRuntime({ + device, + positionals: ['call', '5551234'], + ...bindings, + }), + ), + }, focus: { resolve: async (device, bindings) => refusable(await resolveBoundFocusRuntime({ device, positionals: ['40', '90'], ...bindings })), diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index a4f6860006..4a8d202843 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -125,6 +125,7 @@ "@agent-device/contracts/startup-recovery-fence", "@agent-device/contracts/system-button-runtime", "@agent-device/contracts/tap-keyboard-occlusion", + "@agent-device/contracts/telephony-runtime", "@agent-device/contracts/touch-runtime", "@agent-device/contracts/tv-remote", "@agent-device/contracts/tv-remote-runtime", diff --git a/test/integration/command-coverage/declarations.ts b/test/integration/command-coverage/declarations.ts index ee8247810d..801377b72d 100644 --- a/test/integration/command-coverage/declarations.ts +++ b/test/integration/command-coverage/declarations.ts @@ -18,6 +18,7 @@ import { ANDROID_APPLICATION_LIFECYCLE_CONTRACT_EVIDENCE, ANDROID_FOLD_RUNTIME_CONTRACT_EVIDENCE, ANDROID_HOVER_RUNTIME_CONTRACT_EVIDENCE, + ANDROID_TELEPHONY_RUNTIME_CONTRACT_EVIDENCE, ANDROID_TV_REMOTE_RUNTIME_CONTRACT_EVIDENCE, ANDROID_UNINSTALL_CONTRACT_EVIDENCE, ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE, @@ -1431,6 +1432,37 @@ const COMMAND_COVERAGE_DECLARATIONS = { 'the exact-owner runtime fact refuses a hinge pose on the Linux desktop', ), }, + [C.telephony]: { + androidEmulator: androidEmulator.contract( + ANDROID_TELEPHONY_RUNTIME_CONTRACT_EVIDENCE, + 'the Android emulator advertises the console telephony event and binds it; a physical device is refused by the fact', + ), + iosSimulator: iosSimulator.contract( + 'packages/platform-apple/src/runtime.test.ts', + 'refuses the telephony fact for the %s leaf', + 'the exact-owner runtime fact refuses a simulated call or SMS on the iOS simulator leaf', + ), + macos: macos.contract( + 'packages/platform-apple/src/runtime.test.ts', + 'refuses the telephony fact for the %s leaf', + 'the exact-owner runtime fact refuses a simulated call or SMS on the macOS host leaf', + ), + tvos: tvos.contract( + 'packages/platform-apple/src/runtime.test.ts', + 'refuses the telephony fact for the %s leaf', + 'the exact-owner runtime fact refuses a simulated call or SMS on the tvOS leaf', + ), + web: web.contract( + 'packages/platform-web/src/runtime.test.ts', + 'clipboard, the app switcher, app events, settings and alerts carry no web bucket', + 'the exact-owner runtime fact refuses a simulated call or SMS on the web target', + ), + linux: linux.contract( + LINUX_RUNTIME_EVIDENCE.path, + LINUX_RUNTIME_EVIDENCE.test, + 'the exact-owner runtime fact refuses a simulated call or SMS on the Linux desktop', + ), + }, [C.installFromSource]: { androidEmulator: androidEmulator.contract( ANDROID_INSTALL_SOURCE_CONTRACT_EVIDENCE, diff --git a/test/integration/command-coverage/evidence.ts b/test/integration/command-coverage/evidence.ts index 79edf41383..342a1d2273 100644 --- a/test/integration/command-coverage/evidence.ts +++ b/test/integration/command-coverage/evidence.ts @@ -55,6 +55,12 @@ export const ANDROID_UNINSTALL_CONTRACT_EVIDENCE: AndroidContractEvidence = [C.uninstall], 'Provider-backed integration Android uninstall removes an installed package and no-ops on a missing one', ); +export const ANDROID_TELEPHONY_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = + defineAndroidContractEvidence( + 'packages/platform-android/src/runtime.test.ts', + [C.telephony], + 'Android admits the telephony fact on emulators and refuses every other kind', + ); export const ANDROID_VIEWPORT_RUNTIME_CONTRACT_EVIDENCE: AndroidContractEvidence = defineAndroidContractEvidence( 'packages/daemon/src/execution/__tests__/viewport-runtime.test.ts', diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index fe2ccee83b..e34429a78f 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -129,6 +129,7 @@ const DRIVEN_COMMANDS: Record = { [PUBLIC_COMMANDS.appSwitcher]: () => one(), [PUBLIC_COMMANDS.actionButton]: () => one(), [PUBLIC_COMMANDS.fold]: () => one(['open']), + [PUBLIC_COMMANDS.telephony]: () => one(['call', '5551234']), // -- orchestration (drive to an error response; still scanned) -- [PUBLIC_COMMANDS.artifacts]: () => one(), From cbcdf4dd1ddc0b89ec2f7d59974f1dfd62bd6384 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:20:08 +0000 Subject: [PATCH 3/9] refactor(telephony): share the Android emulator console call and trim the surface Fold and telephony each ran `adb emu`, checked the exit code, and read a `KO` reply. One `runEmulatorConsole` in platform-android now does that, bounded by a 10s timeout, and returns a refusal for each caller to classify with its own reason. The telephony module becomes `telephony/console-event.ts`. The single-caller `contracts/telephony-runtime` module is gone: its operations type lives with the telephony vocabulary and Android states its cell directly. The daemon leaf reads call messages from one table, the facet passes typed input without a cast, and the command description is two sentences. The WebDriver and Limrun refusal hints now say what agent-device does rather than what those backends can do, since Appium can simulate GSM calls on an emulator. The docs and changelog stop claiming the console delivers SMS text verbatim, which no live run has confirmed. Vega's refusal test covers telephony. --- CHANGELOG.md | 2 +- packages/contracts/package.json | 4 -- packages/contracts/src/device-rotation.ts | 8 +++ packages/contracts/src/facades/device.ts | 1 + .../src/platform-runtime-operations.ts | 2 +- .../src/platform-runtime-unavailable.ts | 5 +- packages/contracts/src/telephony-runtime.ts | 20 ------ .../__tests__/telephony-runtime.test.ts | 3 +- .../daemon/src/execution/telephony-runtime.ts | 44 +++++------- .../src/emulator-console.test.ts | 58 ++++++++++++++++ .../platform-android/src/emulator-console.ts | 42 +++++++++++ .../platform-android/src/foldable/pose.ts | 32 ++------- ...-console.test.ts => console-event.test.ts} | 68 ++++++++---------- .../{emulator-console.ts => console-event.ts} | 32 +++------ .../src/telephony/runtime.test.ts | 69 ++----------------- .../platform-android/src/telephony/runtime.ts | 5 +- packages/platform-vega/src/runtime.test.ts | 6 ++ .../src/interaction-operations.ts | 6 +- .../src/platform-runtime.ts | 3 +- src/commands/system/index.ts | 9 +-- website/docs/docs/commands.md | 4 +- 21 files changed, 198 insertions(+), 225 deletions(-) delete mode 100644 packages/contracts/src/telephony-runtime.ts create mode 100644 packages/platform-android/src/emulator-console.test.ts create mode 100644 packages/platform-android/src/emulator-console.ts rename packages/platform-android/src/telephony/{emulator-console.test.ts => console-event.test.ts} (51%) rename packages/platform-android/src/telephony/{emulator-console.ts => console-event.ts} (54%) diff --git a/CHANGELOG.md b/CHANGELOG.md index ede0323ce6..527ea16424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,5 +10,5 @@ - `settings` refuses, before touching the device, a state its setting does not accept (`Invalid state: …`), a permission action or mode its target does not take (such as `permission deny accessibility`), and a stray extra argument (such as `clear-app-state `). The CLI and the daemon accept the same state spellings, in any casing or surrounding whitespace. - `alert wait 0` on Android reports an alert that is already visible instead of timing out. - `settings --help` describes each setting on its own line, and its usage folds settings that share a form. -- Added (android): `telephony call|accept|cancel ` and `telephony sms `, plus `client.command.telephony({ action, number, text })`, simulate an incoming phone call or SMS on an Android emulator through the emulator console (`adb emu gsm …`, `adb emu sms send …`), so a test can check how an app behaves when a call or message interrupts it, such as video playback pausing and resuming. The number must be digits with an optional leading `+`; SMS text arrives as written, with backslashes and line breaks escaped for the console. A console refusal (for example `accept` for a number with no call) fails with `COMMAND_FAILED` and `reason: telephony-console-refused`. Physical Android devices, iOS, and every other target and provider refuse with `UNSUPPORTED_OPERATION`. +- Added (android): `telephony call|accept|cancel ` and `telephony sms `, plus `client.command.telephony({ action, number, text })`, simulate an incoming phone call or SMS on an Android emulator through the emulator console (`adb emu gsm …`, `adb emu sms send …`), so a test can check how an app behaves when a call or message interrupts it, such as video playback pausing and resuming. The number must be digits with an optional leading `+`; SMS text is sent as one adb argument, with backslashes and line breaks escaped for the console. A console refusal (for example `accept` for a number with no call) fails with `COMMAND_FAILED` and `reason: telephony-console-refused`. Physical Android devices, iOS, and every other target and provider refuse with `UNSUPPORTED_OPERATION`. - Carry provider keyboard facts through snapshots. Appium captures, including wait polls and fill pre-captures, add one sequential keyboard visibility probe with a 500 ms timeout. Probe results preserve tap-time docked-tree checks; unavailable probes leave visibility unknown. diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 1c3a05598f..e103d8532c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -512,10 +512,6 @@ "types": "./src/tap-keyboard-occlusion.ts", "default": "./src/tap-keyboard-occlusion.ts" }, - "./telephony-runtime": { - "types": "./src/telephony-runtime.ts", - "default": "./src/telephony-runtime.ts" - }, "./touch-runtime": { "types": "./src/touch-runtime.ts", "default": "./src/touch-runtime.ts" diff --git a/packages/contracts/src/device-rotation.ts b/packages/contracts/src/device-rotation.ts index 5e2f28a6ee..ab114b718a 100644 --- a/packages/contracts/src/device-rotation.ts +++ b/packages/contracts/src/device-rotation.ts @@ -242,6 +242,14 @@ export type TelephonyEventInput = | Readonly<{ action: TelephonyCallAction; number: string }> | Readonly<{ action: 'sms'; number: string; text: string }>; +/** + * One simulated telephony event. The owner returns nothing: the emulator console acknowledges the + * event, and what the app does with the call or message is observed through the next capture. + */ +export type TelephonyRuntimeOperations = Readonly<{ + sendTelephonyEvent(input: TelephonyEventInput): Promise; +}>; + /** * Closed result of the `telephony` command: a discriminated union on `action`. The call actions * echo the number; `sms` also reports the length of the text it delivered. diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 6a8eb0e5ae..0f000b130e 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -61,6 +61,7 @@ export type { TelephonyAction, TelephonyCommandResult, TelephonyEventInput, + TelephonyRuntimeOperations, } from '../device-rotation.ts'; export type { BootCommandResult, ShutdownCommandResult } from '../device.ts'; export type { diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index a3c04d59c5..fa0f21166c 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -23,7 +23,7 @@ import type { ElementTextRuntimeOperations } from './element-text-runtime.ts'; import type { BackRuntimeOperations } from './back-runtime.ts'; import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; import type { FoldRuntimeOperations } from './fold-runtime.ts'; -import type { TelephonyRuntimeOperations } from './telephony-runtime.ts'; +import type { TelephonyRuntimeOperations } from './device-rotation.ts'; import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 7021f70222..758153f01c 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -238,9 +238,8 @@ export function createUnavailablePlatformRuntimeFacts( ...elementTextRuntimeOperationFacts({ readTextAtPoint: frozen.elementText }), ...backRuntimeOperationFacts({ back: frozen.back }), ...orientationRuntimeOperationFacts({ orientation: frozen.orientation }), - // Stated directly rather than through `foldRuntimeOperationFacts` and - // `telephonyRuntimeOperationFacts`, so this hub does not evaluate either contract for owners - // that never pose a hinge or simulate a call (eager-closure budgets). + // Stated directly rather than through `foldRuntimeOperationFacts`, so this hub does not + // evaluate the fold contract for owners that never pose a hinge (eager-closure budgets). setFoldPose: frozen.fold, sendTelephonyEvent: frozen.telephony, ...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }), diff --git a/packages/contracts/src/telephony-runtime.ts b/packages/contracts/src/telephony-runtime.ts deleted file mode 100644 index 0ef0ec8bd8..0000000000 --- a/packages/contracts/src/telephony-runtime.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RuntimeOperationFact } from './platform-runtime.ts'; -import type { TelephonyEventInput } from './device-rotation.ts'; - -/** - * One simulated telephony event. The owner returns nothing: the emulator console acknowledges the - * event, and what the app does with the call or message is observed through the next capture. - */ -export type TelephonyRuntimeOperations = Readonly<{ - sendTelephonyEvent(input: TelephonyEventInput): Promise; -}>; - -export type TelephonyRuntimeOperationFacts = Readonly<{ - sendTelephonyEvent: RuntimeOperationFact; -}>; - -export function telephonyRuntimeOperationFacts( - input: Readonly<{ telephony: RuntimeOperationFact }>, -): TelephonyRuntimeOperationFacts { - return Object.freeze({ sendTelephonyEvent: input.telephony }); -} diff --git a/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts b/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts index 86c173bc05..91af197150 100644 --- a/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts +++ b/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts @@ -12,7 +12,6 @@ import { telephonyRuntimeUse, type PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; -import { telephonyRuntimeOperationFacts } from '@agent-device/contracts/telephony-runtime'; import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; import { makeSession } from '../../__tests__/support/session-factories.ts'; import type { @@ -58,7 +57,7 @@ function runtimeHarness(fact: RuntimeOperationFact = available) { const facts: RuntimeFacts = { device: { ...deviceShape(testDevice), providerMode: 'local' }, operations: { - ...telephonyRuntimeOperationFacts({ telephony: fact }), + sendTelephonyEvent: fact, } as RuntimeFacts['operations'], }; const binding = { diff --git a/packages/daemon/src/execution/telephony-runtime.ts b/packages/daemon/src/execution/telephony-runtime.ts index bd5b0ea043..e50bed77f0 100644 --- a/packages/daemon/src/execution/telephony-runtime.ts +++ b/packages/daemon/src/execution/telephony-runtime.ts @@ -36,36 +36,28 @@ export async function resolveBoundTelephonyRuntime( ); } +const CALL_EVENT_MESSAGES = { + call: 'Simulated an incoming call from', + accept: 'Accepted the call from', + cancel: 'Ended the call from', +} as const satisfies Record, string>; + async function executeTelephonyEvent( runtime: BoundDeviceRuntime, input: TelephonyEventInput, ): Promise { await runtime.operations.sendTelephonyEvent(input); - switch (input.action) { - case 'call': - return { - action: input.action, - number: input.number, - message: `Simulated an incoming call from ${input.number}`, - }; - case 'accept': - return { - action: input.action, - number: input.number, - message: `Accepted the call from ${input.number}`, - }; - case 'cancel': - return { - action: input.action, - number: input.number, - message: `Ended the call from ${input.number}`, - }; - case 'sms': - return { - action: input.action, - number: input.number, - textLength: input.text.length, - message: `Simulated an incoming SMS from ${input.number}`, - }; + if (input.action === 'sms') { + return { + action: input.action, + number: input.number, + textLength: input.text.length, + message: `Simulated an incoming SMS from ${input.number}`, + }; } + return { + action: input.action, + number: input.number, + message: `${CALL_EVENT_MESSAGES[input.action]} ${input.number}`, + }; } diff --git a/packages/platform-android/src/emulator-console.test.ts b/packages/platform-android/src/emulator-console.test.ts new file mode 100644 index 0000000000..197024311d --- /dev/null +++ b/packages/platform-android/src/emulator-console.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +vi.mock('./adb.ts', () => ({ runAndroidAdb: vi.fn() })); + +import { runAndroidAdb } from './adb.ts'; +import { runEmulatorConsole } from './emulator-console.ts'; +import { ANDROID_EMULATOR } from './runtime.fixtures.ts'; + +const mockAdb = vi.mocked(runAndroidAdb); + +function answer(stdout: string, exitCode = 0) { + return { stdout, stderr: '', exitCode }; +} + +beforeEach(() => { + mockAdb.mockReset(); +}); + +test('runs the command under adb emu with the caller signal and a bounded timeout', async () => { + mockAdb.mockResolvedValueOnce(answer('OK\n')); + const { signal } = new AbortController(); + await expect(runEmulatorConsole(ANDROID_EMULATOR, ['posture', '3'], signal)).resolves.toEqual({ + stdout: 'OK\n', + }); + expect(mockAdb).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR, ['emu', 'posture', '3'], { + signal, + allowFailure: true, + timeoutMs: expect.any(Number), + }); +}); + +test.each([ + ['KO: bad phone number format\n', 'bad phone number format'], + ['some banner\nKO: no call\n', 'no call'], + ['KO\nnext line\n', ''], +])( + 'returns the KO reply %j as a refusal, which adb reports with exit 0', + async (stdout, refusal) => { + mockAdb.mockResolvedValueOnce(answer(stdout)); + await expect( + runEmulatorConsole(ANDROID_EMULATOR, ['gsm', 'accept', '5551234'], undefined), + ).resolves.toEqual({ stdout, refusal }); + }, +); + +test('throws the adb failure for a failed exit, naming the command but not its operands', async () => { + mockAdb.mockResolvedValueOnce({ stdout: '', stderr: 'error: device offline', exitCode: 1 }); + const failure = runEmulatorConsole( + ANDROID_EMULATOR, + ['sms', 'send', '5551234', 'secret'], + undefined, + ); + await expect(failure).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: 'adb emu sms send exited with code 1', + details: expect.objectContaining({ deviceId: ANDROID_EMULATOR.id, exitCode: 1 }), + }); +}); diff --git a/packages/platform-android/src/emulator-console.ts b/packages/platform-android/src/emulator-console.ts new file mode 100644 index 0000000000..3d81fe0370 --- /dev/null +++ b/packages/platform-android/src/emulator-console.ts @@ -0,0 +1,42 @@ +import type { DeviceInfo } from '@agent-device/kernel/device'; + +import { runAndroidAdb } from './adb.ts'; +import { androidAdbResultError } from './adb-failure.ts'; + +/** A console command answers in well under a second; this bounds a console that stopped answering. */ +const EMULATOR_CONSOLE_TIMEOUT_MS = 10_000; + +export type EmulatorConsoleReply = Readonly<{ + stdout: string; + /** The reason after `KO:` when the console refused the command (empty for a bare `KO`). */ + refusal?: string; +}>; + +/** + * Runs one emulator console command (`adb emu `). adb exits 0 whether the console answers + * `OK` or `KO: `, so a refusal comes back in the reply for the caller to classify with its + * own reason; only a failed adb exit throws. A failure names the first two command words, never the + * caller's operands. + */ +export async function runEmulatorConsole( + device: DeviceInfo, + command: readonly string[], + signal: AbortSignal | undefined, +): Promise { + const result = await runAndroidAdb(device, ['emu', ...command], { + signal, + allowFailure: true, + timeoutMs: EMULATOR_CONSOLE_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + throw androidAdbResultError( + `adb emu ${command.slice(0, 2).join(' ')} exited with code ${result.exitCode}`, + result, + { deviceId: device.id }, + ); + } + const refusal = /^KO\b:?[ \t]*(.*)$/m.exec(result.stdout); + return refusal + ? { stdout: result.stdout, refusal: refusal[1]!.trim() } + : { stdout: result.stdout }; +} diff --git a/packages/platform-android/src/foldable/pose.ts b/packages/platform-android/src/foldable/pose.ts index 56b050de38..3fe8f0526b 100644 --- a/packages/platform-android/src/foldable/pose.ts +++ b/packages/platform-android/src/foldable/pose.ts @@ -5,7 +5,8 @@ import { sleep } from '@agent-device/host-kit/retry'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { runAndroidAdb, runAndroidShell } from '../adb.ts'; +import { runAndroidShell } from '../adb.ts'; +import { runEmulatorConsole } from '../emulator-console.ts'; import { androidAdbResultError } from '../adb-failure.ts'; import type { AndroidAdbExecutorResult } from '../adb-transport.ts'; @@ -145,8 +146,8 @@ async function sendEmulatorPosture( postureId: string, signal: AbortSignal | undefined, ): Promise { - const sent = await emulatorConsole(device, ['emu', 'posture', postureId], signal); - if (/^KO\b/m.test(sent.stdout)) { + const sent = await runEmulatorConsole(device, ['posture', postureId], signal); + if (sent.refusal !== undefined) { throw new AppError('COMMAND_FAILED', `Unable to set the emulator posture for ${pose}`, { deviceId: device.id, reason: 'fold-posture-dispatch-failed', @@ -254,11 +255,7 @@ async function readHingeAngle( device: DeviceInfo, signal: AbortSignal | undefined, ): Promise { - const { stdout } = await emulatorConsole( - device, - ['emu', 'sensor', 'get', 'hinge-angle0'], - signal, - ); + const { stdout } = await runEmulatorConsole(device, ['sensor', 'get', 'hinge-angle0'], signal); const match = /hinge-angle0\s*=\s*(-?\d+(?:\.\d+)?)/.exec(stdout); if (!match) { throw new AppError('COMMAND_FAILED', `${device.name} reports no hinge angle`, { @@ -291,22 +288,3 @@ async function shell( } return result; } - -/** The emulator console through adb, with the same exit-code check as {@link shell}. */ -async function emulatorConsole( - device: DeviceInfo, - args: readonly string[], - signal: AbortSignal | undefined, -): Promise { - const result = await runAndroidAdb(device, args, { signal, allowFailure: true }); - if (result.exitCode !== 0) { - throw androidAdbResultError( - `adb ${args.join(' ')} exited with code ${result.exitCode}`, - result, - { - deviceId: device.id, - }, - ); - } - return result; -} diff --git a/packages/platform-android/src/telephony/emulator-console.test.ts b/packages/platform-android/src/telephony/console-event.test.ts similarity index 51% rename from packages/platform-android/src/telephony/emulator-console.test.ts rename to packages/platform-android/src/telephony/console-event.test.ts index 24d36976d2..afd406a23b 100644 --- a/packages/platform-android/src/telephony/emulator-console.test.ts +++ b/packages/platform-android/src/telephony/console-event.test.ts @@ -4,12 +4,16 @@ vi.mock('../adb.ts', () => ({ runAndroidAdb: vi.fn() })); import { runAndroidAdb } from '../adb.ts'; import { ANDROID_EMULATOR } from '../runtime.fixtures.ts'; -import { sendAndroidTelephonyEvent } from './emulator-console.ts'; +import { sendAndroidTelephonyEvent } from './console-event.ts'; const mockAdb = vi.mocked(runAndroidAdb); -function answer(stdout: string, exitCode = 0) { - return { stdout, stderr: '', exitCode }; +function answer(stdout: string) { + return { stdout, stderr: '', exitCode: 0 }; +} + +function sentArgs(): readonly string[] | undefined { + return mockAdb.mock.calls[0]?.[1]; } beforeEach(() => { @@ -25,16 +29,17 @@ test.each([ ] as const)('sends %o to the emulator console as %o', async (input, args) => { const { signal } = new AbortController(); await sendAndroidTelephonyEvent(ANDROID_EMULATOR, input, { signal }); - expect(mockAdb).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR, args, { - signal, - allowFailure: true, - }); + expect(mockAdb).toHaveBeenCalledExactlyOnceWith( + ANDROID_EMULATOR, + args, + expect.objectContaining({ signal }), + ); }); test('passes SMS text with spaces, quotes, and shell metacharacters as one argument', async () => { const text = `Don't "pause" $HOME; echo $(id) & *`; await sendAndroidTelephonyEvent(ANDROID_EMULATOR, { action: 'sms', number: '5551234', text }); - expect(mockAdb.mock.calls[0]?.[1]).toEqual(['emu', 'sms', 'send', '5551234', text]); + expect(sentArgs()).toEqual(['emu', 'sms', 'send', '5551234', text]); }); test('escapes backslashes and line breaks for the console instead of ending its command line', async () => { @@ -43,43 +48,30 @@ test('escapes backslashes and line breaks for the console instead of ending its number: '5551234', text: 'line one\nC:\\path\\n', }); - expect(mockAdb.mock.calls[0]?.[1]).toEqual([ - 'emu', - 'sms', - 'send', - '5551234', - String.raw`line one\nC:\\path\\n`, - ]); - expect(mockAdb.mock.calls[0]?.[1].at(-1)).not.toContain('\n'); + expect(sentArgs()).toEqual(['emu', 'sms', 'send', '5551234', String.raw`line one\nC:\\path\\n`]); }); -test('reports a console KO reply as a typed refusal even though adb exits 0', async () => { - mockAdb.mockResolvedValueOnce(answer('KO: no current call for this number\n')); - await expect( - sendAndroidTelephonyEvent(ANDROID_EMULATOR, { action: 'accept', number: '5551234' }), - ).rejects.toMatchObject({ +test.each([ + ['KO: no current call for this number\n', { consoleReason: 'no current call for this number' }], + ['KO\n', {}], +])('reports the console reply %j as telephony-console-refused', async (stdout, extra) => { + mockAdb.mockResolvedValueOnce(answer(stdout)); + const refusal = sendAndroidTelephonyEvent(ANDROID_EMULATOR, { + action: 'accept', + number: '5551234', + }); + await expect(refusal).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { deviceId: ANDROID_EMULATOR.id, reason: 'telephony-console-refused', - consoleReason: 'no current call for this number', hint: expect.stringContaining('telephony call '), + ...extra, }, }); -}); - -test('reports an adb failure with the adb exit details rather than a console refusal', async () => { - mockAdb.mockResolvedValueOnce({ stdout: '', stderr: 'error: device offline', exitCode: 1 }); - const failure = sendAndroidTelephonyEvent(ANDROID_EMULATOR, { - action: 'call', - number: '5551234', - }); - await expect(failure).rejects.toMatchObject({ - code: 'COMMAND_FAILED', - message: expect.stringContaining('exited with code 1'), - details: expect.objectContaining({ deviceId: ANDROID_EMULATOR.id, exitCode: 1 }), - }); - await expect(failure).rejects.not.toMatchObject({ - details: { reason: 'telephony-console-refused' }, - }); + if (!('consoleReason' in extra)) { + await expect(refusal).rejects.not.toMatchObject({ + details: { consoleReason: expect.anything() }, + }); + } }); diff --git a/packages/platform-android/src/telephony/emulator-console.ts b/packages/platform-android/src/telephony/console-event.ts similarity index 54% rename from packages/platform-android/src/telephony/emulator-console.ts rename to packages/platform-android/src/telephony/console-event.ts index a28ca0d0e4..750de36d0f 100644 --- a/packages/platform-android/src/telephony/emulator-console.ts +++ b/packages/platform-android/src/telephony/console-event.ts @@ -2,51 +2,37 @@ import type { TelephonyEventInput } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; -import { runAndroidAdb } from '../adb.ts'; -import { androidAdbResultError } from '../adb-failure.ts'; +import { runEmulatorConsole } from '../emulator-console.ts'; const CONSOLE_REFUSED_HINT = 'The emulator console refused the event. accept and cancel need a call from that same number (start one with telephony call ), and an emulator image without a modem has no telephony to simulate.'; -/** - * Simulates one telephony event through the emulator console (`adb emu gsm …` / `adb emu sms send - * …`). The console answers `KO: ` on stdout, with a zero exit code, when it refuses an - * event, so the reply is checked as well as the exit code. - */ +/** Simulates one telephony event through the emulator console (`gsm …` / `sms send …`). */ export async function sendAndroidTelephonyEvent( device: DeviceInfo, input: TelephonyEventInput, options: { signal?: AbortSignal } = {}, ): Promise { - const args = emulatorConsoleArgs(input); - const result = await runAndroidAdb(device, args, { signal: options.signal, allowFailure: true }); - if (result.exitCode !== 0) { - // Names the console command without its number or message text. - const command = args.slice(0, 3).join(' '); - throw androidAdbResultError(`adb ${command} exited with code ${result.exitCode}`, result, { - deviceId: device.id, - }); - } - const refusal = /^KO\b:?[ \t]*(.*)$/m.exec(result.stdout); - if (refusal) { + const reply = await runEmulatorConsole(device, consoleCommand(input), options.signal); + if (reply.refusal !== undefined) { throw new AppError('COMMAND_FAILED', `The emulator console refused telephony ${input.action}`, { deviceId: device.id, reason: 'telephony-console-refused', - stdout: result.stdout.trim(), - ...(refusal[1] ? { consoleReason: refusal[1].trim() } : {}), + stdout: reply.stdout.trim(), + ...(reply.refusal ? { consoleReason: reply.refusal } : {}), hint: CONSOLE_REFUSED_HINT, }); } } -function emulatorConsoleArgs(input: TelephonyEventInput): string[] { +function consoleCommand(input: TelephonyEventInput): string[] { switch (input.action) { case 'call': case 'accept': case 'cancel': - return ['emu', 'gsm', input.action, input.number]; + return ['gsm', input.action, input.number]; case 'sms': - return ['emu', 'sms', 'send', input.number, escapeConsoleSmsText(input.text)]; + return ['sms', 'send', input.number, escapeConsoleSmsText(input.text)]; } } diff --git a/packages/platform-android/src/telephony/runtime.test.ts b/packages/platform-android/src/telephony/runtime.test.ts index dc67621e9e..e160593ddc 100644 --- a/packages/platform-android/src/telephony/runtime.test.ts +++ b/packages/platform-android/src/telephony/runtime.test.ts @@ -1,80 +1,25 @@ import { beforeEach, expect, test, vi } from 'vitest'; -vi.mock('./emulator-console.ts', () => ({ sendAndroidTelephonyEvent: vi.fn() })); +vi.mock('./console-event.ts', () => ({ sendAndroidTelephonyEvent: vi.fn() })); -import { - localRuntimeOwner, - narrowDeviceBinding, - type DeviceBinding, - type RuntimeFacts, -} from '@agent-device/contracts/platform-runtime'; -import { - telephonyRuntimeUse, - type PlatformRuntimeOperations, -} from '@agent-device/contracts/platform-runtime-operations'; -import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device'; - -import { ANDROID_EMULATOR, UNKNOWN_KIND_DEVICE } from '../runtime.fixtures.ts'; -import { sendAndroidTelephonyEvent } from './emulator-console.ts'; -import { androidTelephonyFacts, createAndroidTelephonyOperations } from './runtime.ts'; +import { ANDROID_EMULATOR } from '../runtime.fixtures.ts'; +import { sendAndroidTelephonyEvent } from './console-event.ts'; +import { createAndroidTelephonyOperations } from './runtime.ts'; const mockSend = vi.mocked(sendAndroidTelephonyEvent); -const physical: DeviceInfo = { ...ANDROID_EMULATOR, kind: 'device' }; - -/** A full owner binding whose telephony cell is derived only from the fact under test. */ -function telephonyBinding(device: DeviceInfo): DeviceBinding { - const { sendTelephonyEvent } = androidTelephonyFacts(device); - return { - device, - owner: localRuntimeOwner('android'), - facts: { - device: { ...deviceShape(device), providerMode: 'local' }, - operations: { sendTelephonyEvent } as RuntimeFacts['operations'], - }, - operations: createAndroidTelephonyOperations({ device, signal: new AbortController().signal }), - [Symbol.asyncDispose]: async () => {}, - }; -} - beforeEach(() => { mockSend.mockReset(); mockSend.mockResolvedValue(undefined); }); -test('an emulator admits sendTelephonyEvent and binds it to the bound signal', async () => { - expect(androidTelephonyFacts(ANDROID_EMULATOR).sendTelephonyEvent).toEqual({ available: true }); +test('the emulator operation sends the event with the bound request signal', async () => { const { signal } = new AbortController(); const operations = createAndroidTelephonyOperations({ device: ANDROID_EMULATOR, signal }); const input = { action: 'sms', number: '5551234', text: 'hello' } as const; await operations.sendTelephonyEvent?.(input); - expect(mockSend).toHaveBeenCalledWith(ANDROID_EMULATOR, input, { signal }); -}); - -test('a physical device and an unknown kind refuse sendTelephonyEvent with the typed kind fact', () => { - for (const device of [physical, UNKNOWN_KIND_DEVICE]) { - expect(androidTelephonyFacts(device).sendTelephonyEvent).toMatchObject({ - available: false, - reason: 'unsupported-device-kind', - hint: expect.stringContaining('physical device has no console'), - }); - const operations = createAndroidTelephonyOperations({ - device, - signal: new AbortController().signal, - }); - expect(operations).not.toHaveProperty('sendTelephonyEvent'); - } - expect(mockSend).not.toHaveBeenCalled(); -}); - -test('the narrowed telephony use refuses a physical device as UNSUPPORTED_OPERATION', () => { - expect(() => narrowDeviceBinding(telephonyBinding(physical), telephonyRuntimeUse)).toThrow( - expect.objectContaining({ - code: 'UNSUPPORTED_OPERATION', - details: expect.objectContaining({ reason: 'unsupported-device-kind' }), - }), - ); + expect(mockSend).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR, input, { signal }); }); test('an aborted scope never reaches the emulator console', async () => { @@ -86,6 +31,6 @@ test('an aborted scope never reaches the emulator console', async () => { controller.abort(); await expect( operations.sendTelephonyEvent?.({ action: 'call', number: '5551234' }), - ).rejects.toThrow(); + ).rejects.toMatchObject({ name: 'AbortError' }); expect(mockSend).not.toHaveBeenCalled(); }); diff --git a/packages/platform-android/src/telephony/runtime.ts b/packages/platform-android/src/telephony/runtime.ts index 6a17df8b2a..5064f67db1 100644 --- a/packages/platform-android/src/telephony/runtime.ts +++ b/packages/platform-android/src/telephony/runtime.ts @@ -1,10 +1,9 @@ import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime'; import { whenAdmitted } from '@agent-device/contracts/platform-runtime'; import type { TelephonyEventInput } from '@agent-device/contracts/device'; -import { telephonyRuntimeOperationFacts } from '@agent-device/contracts/telephony-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { sendAndroidTelephonyEvent } from './emulator-console.ts'; +import { sendAndroidTelephonyEvent } from './console-event.ts'; const available = Object.freeze({ available: true } as const); @@ -21,7 +20,7 @@ function androidTelephonyFact(device: DeviceInfo): RuntimeOperationFact { /** The telephony cell: `sendTelephonyEvent`. */ export function androidTelephonyFacts(device: DeviceInfo) { - return telephonyRuntimeOperationFacts({ telephony: androidTelephonyFact(device) }); + return Object.freeze({ sendTelephonyEvent: androidTelephonyFact(device) }); } /** Binds `sendTelephonyEvent` when {@link androidTelephonyFacts} admitted it. */ diff --git a/packages/platform-vega/src/runtime.test.ts b/packages/platform-vega/src/runtime.test.ts index 82b25904cc..14727c4e07 100644 --- a/packages/platform-vega/src/runtime.test.ts +++ b/packages/platform-vega/src/runtime.test.ts @@ -196,6 +196,12 @@ test.each([ }); expect(binding.operations[button]).toBeUndefined(); } + expect(binding.facts.operations.sendTelephonyEvent).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'telephony is not supported on Vega OS.', + }); + expect(binding.operations.sendTelephonyEvent).toBeUndefined(); expect(binding.facts.operations.triggerAppEvent).toMatchObject({ available: false, reason: 'unsupported-platform-leaf', diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 5a4e70e99e..48b5c66519 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -324,13 +324,9 @@ export const LIMRUN_FOLD_UNAVAILABLE = unavailableFact( 'fold runs a HID helper through simctl spawn inside a foldable iPhone simulator on the daemon host; a Limrun session has no such simulator.', ); -/** - * `telephony` drives the console of an emulator the daemon host runs through local adb; a Limrun - * session's device is not one. - */ export const LIMRUN_TELEPHONY_UNAVAILABLE = unavailableFact( 'unsupported-provider-mode', - 'telephony simulates calls and SMS through the console of an Android emulator on the daemon host; a Limrun session exposes no emulator console.', + "telephony sends calls and SMS to an Android emulator's console through local adb; agent-device does not route it through a Limrun session.", ); /** The fold refusal both Limrun legs share. */ diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index e08d277e45..b00a6df9f2 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -222,10 +222,9 @@ const foldUnavailable = unavailableFact( 'fold runs a HID helper through simctl spawn inside a foldable iPhone simulator on the daemon host; a WebDriver backend has no such simulator.', ); -/** No WebDriver backend reaches an emulator console, so no call or text message is simulated. */ const telephonyUnavailable = unavailableFact( 'unsupported-provider-mode', - 'telephony simulates calls and SMS through the console of an Android emulator on the daemon host; a WebDriver backend exposes no emulator console.', + "telephony sends calls and SMS to an Android emulator's console through local adb; agent-device does not drive a WebDriver backend's own call or SMS simulation.", ); /** diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index fb771a580e..a5aced91bf 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -1,9 +1,6 @@ import { FOLD_FLAGS } from '@agent-device/command-registry/flag-groups'; import type { CommandResultMap } from '@agent-device/command-registry/command-result'; -import type { - ClipboardCommandOptions, - TelephonyCommandOptions, -} from '@agent-device/contracts/client'; +import type { ClipboardCommandOptions } from '@agent-device/contracts/client'; import { type FoldKeyframe, FOLD_SCREEN_COORDINATE_SPACE, @@ -308,7 +305,7 @@ const tvRemoteCommandDescription = 'Press or long-press a TV remote or D-pad button on Android TV, tvOS, or Vega OS. Choose the button and optional hold duration through the input fields. The aliases ok, center, and enter all map to select.'; const telephonyCommandDescription = - 'Simulate an incoming phone call or SMS on an Android emulator through the emulator console, for example to check how an app reacts when a call or message interrupts playback. call starts an incoming call from the number, accept answers it, cancel ends it, and sms delivers a text message from the number. The call screen or message notification can cover the app, so re-snapshot before using refs from before the event. Local Android emulators only; physical Android devices, iOS, and every other target refuse with UNSUPPORTED_OPERATION.'; + 'Simulate an incoming phone call or SMS on a local Android emulator through its console: call rings from the number, accept answers, cancel ends the call, and sms delivers a text message. The call screen or notification can cover the app, so re-snapshot afterwards.'; const backCommandMetadata = defineFieldCommandMetadata(BACK_COMMAND_NAME, backCommandDescription, { mode: enumField(BACK_MODES), @@ -669,7 +666,7 @@ const telephonyCommandFacet = defineCommandFacet({ 'Android emulators only, through the emulator console. Quote the SMS text, or pass it as the remaining words. Re-snapshot after the event: the call screen or notification can cover the app.', }, metadata: telephonyCommandMetadata, - run: (client, input) => client.command.telephony(input as TelephonyCommandOptions), + run: (client, input) => client.command.telephony(input), cliSchema: telephonyCliSchema, cliReader: telephonyCliReader, daemonWriter: telephonyDaemonWriter, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index f334915e0c..65c8f3963a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -126,8 +126,8 @@ Fold a foldable device: Simulate an incoming call or SMS: - `telephony call ` starts an incoming call from `` on an Android emulator; `telephony accept ` answers it and `telephony cancel ` ends it. `telephony sms ` delivers a text message from ``. Use it to check how an app reacts when a call or message arrives, for example whether video playback pauses and resumes. -- The events go through the emulator console (`adb emu gsm call|accept|cancel`, `adb emu sms send`), so only local Android emulators driven through adb support them. Physical Android devices refuse with `UNSUPPORTED_OPERATION` and `reason: unsupported-device-kind`; iOS, macOS, tvOS, web, Linux, HarmonyOS, Vega, and Limrun or WebDriver (Appium) sessions refuse with `UNSUPPORTED_OPERATION` too. -- `` is digits with an optional leading `+`, up to 20 digits. Quote the SMS text, or pass it as the remaining words; quotes, backslashes, and line breaks (LF, or CRLF sent as LF) arrive as written. Tabs, lone carriage returns, and other control characters are refused. +- The events go through the emulator console (`adb emu gsm call|accept|cancel`, `adb emu sms send`), so only local Android emulators driven through adb support them. Physical Android devices refuse with `UNSUPPORTED_OPERATION` and `reason: unsupported-device-kind`; iOS, macOS, tvOS, web, Linux, HarmonyOS, Vega, and Limrun and WebDriver (Appium) sessions refuse with `UNSUPPORTED_OPERATION` too: agent-device sends telephony only through local adb and does not drive Appium's own call or SMS simulation. +- `` is digits with an optional leading `+`, up to 20 digits. Quote the SMS text, or pass it as the remaining words; the text is sent as one argument, with backslashes and line breaks (LF, or CRLF sent as LF) escaped for the emulator console. Tabs, lone carriage returns, and other control characters are refused. - The emulator console answers some events with an error, such as `accept` or `cancel` for a number with no call. That fails with `COMMAND_FAILED`, `reason: telephony-console-refused`, and the console's own `consoleReason`. - The call screen or message notification can cover the app. Re-snapshot before using refs from before the event. From bcb9bf0dc1c73e99a7db441a8f0ad51cc1cdea61 Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 16:19:40 +0200 Subject: [PATCH 4/9] refactor(telephony): name the adb-only backends in help and share the mutating traits The help summary and detail now say telephony works on local adb-driven emulators, and that WebDriver (Appium) and Limrun sessions refuse it. The registry entry spreads the generic mutating traits and turns off only the blocking-dialog guard. The console-event sender takes a required signal. The emulator-console doc comment limits its no-operands claim to a non-zero exit, because a timeout error still carries the adb args. --- packages/command-registry/src/registry.ts | 8 ++--- packages/contracts/src/device-rotation.ts | 5 ++- .../execution/generic-runtime-execution.ts | 3 +- .../platform-android/src/emulator-console.ts | 4 +-- .../src/telephony/console-event.test.ts | 34 +++++++++++++------ .../src/telephony/console-event.ts | 4 +-- .../src/telephony/runtime.test.ts | 2 +- .../platform-android/src/telephony/runtime.ts | 2 +- src/commands/system/index.ts | 4 +-- 9 files changed, 36 insertions(+), 30 deletions(-) diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index fcbe888458..76de176176 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -1523,12 +1523,8 @@ export const RAW_COMMAND_DESCRIPTORS = [ // Admission is the owner's `sendTelephonyEvent` fact. The event goes to the emulator console // rather than the screen, so no blocking-dialog guard applies; the call screen or message // notification it raises can still cover the app, so refs may be invalidated. - recordsSessionAction: true, - recordingEffect: 'mutates-app', - deviceClaimPolicy: 'require-owner', - daemon: { route: 'generic', refFrameEffect: 'may-invalidate' }, - timeoutPolicy: DEFAULT_TIMEOUT_POLICY, - batchable: true, + ...GENERIC_MUTATING_COMMAND_TRAITS, + daemon: { ...GENERIC_MUTATING_COMMAND_TRAITS.daemon, androidBlockingDialogGuard: false }, platformExecution: { kind: 'device-runtime', uses: [telephonyRuntimeUse] }, }, { diff --git a/packages/contracts/src/device-rotation.ts b/packages/contracts/src/device-rotation.ts index ab114b718a..f6fb6ed9c4 100644 --- a/packages/contracts/src/device-rotation.ts +++ b/packages/contracts/src/device-rotation.ts @@ -208,9 +208,8 @@ export function parseFoldKeyframesJson(value: string): readonly FoldKeyframe[] { } // ---- Telephony ------------------------------------------------------------------------------- -// Lives beside the rotation and hinge vocabulary for the same eager-closure reason: the CLI and -// the session journal already evaluate this module, and a module of its own would join the -// eager closure of every entry that reads one (scripts/__tests__/eager-closure-budgets.ts). +// The CLI and session journal load this module eagerly; a separate telephony module would grow +// their eager closures past scripts/__tests__/eager-closure-budgets.ts. /** * The telephony events an emulator can be asked to simulate: an incoming voice call (`call`), diff --git a/packages/daemon/src/execution/generic-runtime-execution.ts b/packages/daemon/src/execution/generic-runtime-execution.ts index cb4d22a051..0ff52d6d71 100644 --- a/packages/daemon/src/execution/generic-runtime-execution.ts +++ b/packages/daemon/src/execution/generic-runtime-execution.ts @@ -104,8 +104,7 @@ export async function resolveGenericRuntimeExecution( bindDevice: params.bindDevice, }); case 'telephony': { - // Deferred like screenshot's leaf, so the daemon's startup closure stays at its merge-base - // size (scripts/__tests__/eager-closure-budgets.ts). + // Deferred to keep the daemon's eager closure within scripts/__tests__/eager-closure-budgets.ts. const { resolveBoundTelephonyRuntime } = await import('./telephony-runtime.ts'); return await resolveBoundTelephonyRuntime({ device: params.session.device, diff --git a/packages/platform-android/src/emulator-console.ts b/packages/platform-android/src/emulator-console.ts index 3d81fe0370..f74d1c2472 100644 --- a/packages/platform-android/src/emulator-console.ts +++ b/packages/platform-android/src/emulator-console.ts @@ -15,8 +15,8 @@ export type EmulatorConsoleReply = Readonly<{ /** * Runs one emulator console command (`adb emu `). adb exits 0 whether the console answers * `OK` or `KO: `, so a refusal comes back in the reply for the caller to classify with its - * own reason; only a failed adb exit throws. A failure names the first two command words, never the - * caller's operands. + * own reason; only a failed adb exit throws. A non-zero exit names the first two command words, + * never the caller's operands. */ export async function runEmulatorConsole( device: DeviceInfo, diff --git a/packages/platform-android/src/telephony/console-event.test.ts b/packages/platform-android/src/telephony/console-event.test.ts index afd406a23b..942b35c957 100644 --- a/packages/platform-android/src/telephony/console-event.test.ts +++ b/packages/platform-android/src/telephony/console-event.test.ts @@ -28,7 +28,7 @@ test.each([ [{ action: 'sms', number: '5551234', text: 'hello' }, ['emu', 'sms', 'send', '5551234', 'hello']], ] as const)('sends %o to the emulator console as %o', async (input, args) => { const { signal } = new AbortController(); - await sendAndroidTelephonyEvent(ANDROID_EMULATOR, input, { signal }); + await sendAndroidTelephonyEvent(ANDROID_EMULATOR, input, signal); expect(mockAdb).toHaveBeenCalledExactlyOnceWith( ANDROID_EMULATOR, args, @@ -38,16 +38,24 @@ test.each([ test('passes SMS text with spaces, quotes, and shell metacharacters as one argument', async () => { const text = `Don't "pause" $HOME; echo $(id) & *`; - await sendAndroidTelephonyEvent(ANDROID_EMULATOR, { action: 'sms', number: '5551234', text }); + await sendAndroidTelephonyEvent( + ANDROID_EMULATOR, + { action: 'sms', number: '5551234', text }, + new AbortController().signal, + ); expect(sentArgs()).toEqual(['emu', 'sms', 'send', '5551234', text]); }); test('escapes backslashes and line breaks for the console instead of ending its command line', async () => { - await sendAndroidTelephonyEvent(ANDROID_EMULATOR, { - action: 'sms', - number: '5551234', - text: 'line one\nC:\\path\\n', - }); + await sendAndroidTelephonyEvent( + ANDROID_EMULATOR, + { + action: 'sms', + number: '5551234', + text: 'line one\nC:\\path\\n', + }, + new AbortController().signal, + ); expect(sentArgs()).toEqual(['emu', 'sms', 'send', '5551234', String.raw`line one\nC:\\path\\n`]); }); @@ -56,10 +64,14 @@ test.each([ ['KO\n', {}], ])('reports the console reply %j as telephony-console-refused', async (stdout, extra) => { mockAdb.mockResolvedValueOnce(answer(stdout)); - const refusal = sendAndroidTelephonyEvent(ANDROID_EMULATOR, { - action: 'accept', - number: '5551234', - }); + const refusal = sendAndroidTelephonyEvent( + ANDROID_EMULATOR, + { + action: 'accept', + number: '5551234', + }, + new AbortController().signal, + ); await expect(refusal).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { diff --git a/packages/platform-android/src/telephony/console-event.ts b/packages/platform-android/src/telephony/console-event.ts index 750de36d0f..11ad9a0eea 100644 --- a/packages/platform-android/src/telephony/console-event.ts +++ b/packages/platform-android/src/telephony/console-event.ts @@ -11,9 +11,9 @@ const CONSOLE_REFUSED_HINT = export async function sendAndroidTelephonyEvent( device: DeviceInfo, input: TelephonyEventInput, - options: { signal?: AbortSignal } = {}, + signal: AbortSignal, ): Promise { - const reply = await runEmulatorConsole(device, consoleCommand(input), options.signal); + const reply = await runEmulatorConsole(device, consoleCommand(input), signal); if (reply.refusal !== undefined) { throw new AppError('COMMAND_FAILED', `The emulator console refused telephony ${input.action}`, { deviceId: device.id, diff --git a/packages/platform-android/src/telephony/runtime.test.ts b/packages/platform-android/src/telephony/runtime.test.ts index e160593ddc..1968fa5983 100644 --- a/packages/platform-android/src/telephony/runtime.test.ts +++ b/packages/platform-android/src/telephony/runtime.test.ts @@ -19,7 +19,7 @@ test('the emulator operation sends the event with the bound request signal', asy const input = { action: 'sms', number: '5551234', text: 'hello' } as const; await operations.sendTelephonyEvent?.(input); - expect(mockSend).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR, input, { signal }); + expect(mockSend).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR, input, signal); }); test('an aborted scope never reaches the emulator console', async () => { diff --git a/packages/platform-android/src/telephony/runtime.ts b/packages/platform-android/src/telephony/runtime.ts index 5064f67db1..2d725a545f 100644 --- a/packages/platform-android/src/telephony/runtime.ts +++ b/packages/platform-android/src/telephony/runtime.ts @@ -32,7 +32,7 @@ export function createAndroidTelephonyOperations(params: { return whenAdmitted(androidTelephonyFacts(device).sendTelephonyEvent, () => ({ sendTelephonyEvent: async (input: TelephonyEventInput) => { signal.throwIfAborted(); - await sendAndroidTelephonyEvent(device, input, { signal }); + await sendAndroidTelephonyEvent(device, input, signal); }, })); } diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index a5aced91bf..98b40e3410 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -661,9 +661,9 @@ const tvRemoteCommandFacet = defineCommandFacet({ const telephonyCommandFacet = defineCommandFacet({ name: TELEPHONY_COMMAND_NAME, text: { - summary: 'Simulate an incoming call or SMS on an Android emulator', + summary: 'Simulate an incoming call or SMS on a local adb-driven Android emulator', cliDetail: - 'Android emulators only, through the emulator console. Quote the SMS text, or pass it as the remaining words. Re-snapshot after the event: the call screen or notification can cover the app.', + 'Local adb-driven Android emulators only, through the emulator console; WebDriver (Appium) and Limrun sessions refuse it. Quote the SMS text, or pass it as the remaining words. Re-snapshot after the event: the call screen or notification can cover the app.', }, metadata: telephonyCommandMetadata, run: (client, input) => client.command.telephony(input), From ff9b6e76cbf9df7ac1deb43fcf0ab816459745ff Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 16:35:54 +0200 Subject: [PATCH 5/9] fix(telephony): cap numbers at 15 digits and model the sms text rule in the input schema A call number longer than the emulator modem's 16-character field could ring from a truncated number, so a later accept or cancel with the full number missed it. Numbers now stop at 15 digits, the E.164 maximum. SMS text also refuses C1 control characters, and textLength counts code points like the other text-reporting commands. The input schema requires text for sms and refuses it for the call actions. Help and docs now say telephony reaches emulators through adb, including an ADB provider that forwards adb emu, rather than "local" emulators only. Tests pin the skipped dialog guard, the physical-device refusal reason, the console call behind a refusal, the 10s console timeout, and a physical iOS device. A daemon test that repeated the shared unavailable-fact test is gone. --- CHANGELOG.md | 2 +- .../src/device-rotation-telephony.test.ts | 7 +++++- packages/contracts/src/device-rotation.ts | 10 ++++---- packages/contracts/src/facades/device.ts | 1 - .../__tests__/daemon-command-registry.test.ts | 1 + .../__tests__/telephony-runtime.test.ts | 25 ------------------- .../daemon/src/execution/telephony-runtime.ts | 2 +- .../src/emulator-console.test.ts | 2 +- .../platform-android/src/emulator-console.ts | 5 ++-- .../platform-android/src/foldable/pose.ts | 1 - packages/platform-apple/src/runtime.test.ts | 2 +- .../__tests__/session-event-action.test.ts | 4 +++ src/commands/system/index.test.ts | 2 +- src/commands/system/index.ts | 19 +++++++++++--- .../android-telephony.test.ts | 4 ++- website/docs/docs/commands.md | 4 +-- 16 files changed, 43 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 527ea16424..9a181fce29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,5 +10,5 @@ - `settings` refuses, before touching the device, a state its setting does not accept (`Invalid state: …`), a permission action or mode its target does not take (such as `permission deny accessibility`), and a stray extra argument (such as `clear-app-state `). The CLI and the daemon accept the same state spellings, in any casing or surrounding whitespace. - `alert wait 0` on Android reports an alert that is already visible instead of timing out. - `settings --help` describes each setting on its own line, and its usage folds settings that share a form. -- Added (android): `telephony call|accept|cancel ` and `telephony sms `, plus `client.command.telephony({ action, number, text })`, simulate an incoming phone call or SMS on an Android emulator through the emulator console (`adb emu gsm …`, `adb emu sms send …`), so a test can check how an app behaves when a call or message interrupts it, such as video playback pausing and resuming. The number must be digits with an optional leading `+`; SMS text is sent as one adb argument, with backslashes and line breaks escaped for the console. A console refusal (for example `accept` for a number with no call) fails with `COMMAND_FAILED` and `reason: telephony-console-refused`. Physical Android devices, iOS, and every other target and provider refuse with `UNSUPPORTED_OPERATION`. +- Added (android): `telephony call|accept|cancel ` and `telephony sms `, plus `client.command.telephony({ action, number, text })`, simulate an incoming phone call or SMS on an Android emulator through the emulator console (`adb emu gsm …`, `adb emu sms send …`), so a test can check how an app behaves when a call or message interrupts it, such as video playback pausing and resuming. The number must be up to 15 digits with an optional leading `+`; SMS text is sent as one adb argument, with backslashes and line breaks escaped for the console. A console refusal (for example `accept` for a number with no call) fails with `COMMAND_FAILED` and `reason: telephony-console-refused`. Physical Android devices, iOS, every other target, and the WebDriver (Appium) and Limrun providers refuse with `UNSUPPORTED_OPERATION`. - Carry provider keyboard facts through snapshots. Appium captures, including wait polls and fill pre-captures, add one sequential keyboard visibility probe with a 500 ms timeout. Probe results preserve tap-time docked-tree checks; unavailable probes leave visibility unknown. diff --git a/packages/contracts/src/device-rotation-telephony.test.ts b/packages/contracts/src/device-rotation-telephony.test.ts index da3bd99ea9..8ac4b870b3 100644 --- a/packages/contracts/src/device-rotation-telephony.test.ts +++ b/packages/contracts/src/device-rotation-telephony.test.ts @@ -14,6 +14,10 @@ test('parses each action with its number, normalizing the action and trimming th action: 'accept', number: '+15551234567', }); + expect(parseTelephonyEventInput({ action: 'call', number: `+${'1'.repeat(15)}` })).toEqual({ + action: 'call', + number: `+${'1'.repeat(15)}`, + }); expect(parseTelephonyEventInput({ action: 'cancel', number: '5551234' })).toEqual({ action: 'cancel', number: '5551234', @@ -45,12 +49,13 @@ test.each([ [{ action: 'call', number: ' ' }, 'telephony call requires a phone number'], [{ action: 'call', number: '555 1234' }, 'Invalid phone number'], [{ action: 'call', number: '+1-555-1234' }, 'Invalid phone number'], - [{ action: 'call', number: '1'.repeat(21) }, 'Invalid phone number'], + [{ action: 'call', number: '1'.repeat(16) }, 'Invalid phone number'], [{ action: 'cancel', number: '5551234', text: 'hi' }, 'takes only a phone number'], [{ action: 'sms', number: '5551234' }, 'telephony sms requires message text'], [{ action: 'sms', number: '5551234', text: '' }, 'telephony sms requires message text'], [{ action: 'sms', number: '5551234', text: 'a\rb' }, 'must not contain control characters'], [{ action: 'sms', number: '5551234', text: 'a\u0000b' }, 'must not contain control characters'], + [{ action: 'sms', number: '5551234', text: 'a\u0085b' }, 'must not contain control characters'], ])('refuses %o with INVALID_ARGS', (input, message) => { expect(() => parseTelephonyEventInput(input)).toThrow( expect.objectContaining({ code: 'INVALID_ARGS', message: expect.stringContaining(message) }), diff --git a/packages/contracts/src/device-rotation.ts b/packages/contracts/src/device-rotation.ts index f6fb6ed9c4..70dff8bbe6 100644 --- a/packages/contracts/src/device-rotation.ts +++ b/packages/contracts/src/device-rotation.ts @@ -217,24 +217,24 @@ export function parseFoldKeyframesJson(value: string): readonly FoldKeyframe[] { */ export const TELEPHONY_CALL_ACTIONS = ['call', 'accept', 'cancel'] as const; export const TELEPHONY_ACTIONS = [...TELEPHONY_CALL_ACTIONS, 'sms'] as const; -export type TelephonyAction = (typeof TELEPHONY_ACTIONS)[number]; +type TelephonyAction = (typeof TELEPHONY_ACTIONS)[number]; type TelephonyCallAction = (typeof TELEPHONY_CALL_ACTIONS)[number]; export const TELEPHONY_USAGE = 'telephony call|accept|cancel | telephony sms '; /** - * A caller number the emulator modem accepts: digits with an optional leading `+`, at most the - * 20 digits a GSM address carries. + * A caller number: digits with an optional leading `+`, at most the 15 digits of an E.164 number, + * which also fits the emulator modem's 16-character call number field. */ -const TELEPHONY_NUMBER_PATTERN = /^\+?\d{1,20}$/; +const TELEPHONY_NUMBER_PATTERN = /^\+?\d{1,15}$/; /** * The emulator console reads one command per line, so a line break inside the text would end the * `sms send` command and start another. Line breaks are escaped for the console instead (a CRLF * pair counts as one); every other control character, including a lone CR, is refused. */ // eslint-disable-next-line no-control-regex -const TELEPHONY_TEXT_FORBIDDEN_CHARACTERS = /[\u0000-\u0009\u000b-\u001f\u007f]/; +const TELEPHONY_TEXT_FORBIDDEN_CHARACTERS = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/; /** Neutral intent for one telephony event, already validated by {@link parseTelephonyEventInput}. */ export type TelephonyEventInput = diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 0f000b130e..083ed62627 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -58,7 +58,6 @@ export type { FoldKeyframe, FoldScreenCoordinateSpace, SetFoldPoseInput, - TelephonyAction, TelephonyCommandResult, TelephonyEventInput, TelephonyRuntimeOperations, diff --git a/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts b/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts index cbc621a946..355866dc23 100644 --- a/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts +++ b/packages/daemon/src/core/__tests__/daemon-command-registry.test.ts @@ -168,6 +168,7 @@ test('daemon command registry preserves Android modal and lock-policy traits', ( } assert.equal(shouldGuardAndroidBlockingDialog(PUBLIC_COMMANDS.get), false); + assert.equal(shouldGuardAndroidBlockingDialog(PUBLIC_COMMANDS.telephony), false); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.apps), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.capabilities), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.devices), true); diff --git a/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts b/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts index 91af197150..696bb3029d 100644 --- a/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts +++ b/packages/daemon/src/execution/__tests__/telephony-runtime.test.ts @@ -138,31 +138,6 @@ test.each([ expect(harness.bindDevice).not.toHaveBeenCalled(); }); -test('refuses a physical device on the route with the fact reason, never sending', async () => { - const harness = runtimeHarness({ - available: false, - reason: 'unsupported-device-kind', - hint: 'TELEPHONY-KIND-HINT-SENTINEL', - }); - const resolved = await resolveBoundTelephonyRuntime({ - platformServices: daemonPlatformServicesFixture(), - device: testDevice, - positionals: ['call', '5551234'], - inspectFacts: harness.inspectFacts, - bindDevice: harness.bindDevice, - }); - expect(resolved.ok).toBe(false); - if (resolved.ok || resolved.response.ok) throw new Error('the kind fact admitted telephony'); - expect(resolved.response.error).toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - message: 'telephony is not supported on this device', - hint: 'TELEPHONY-KIND-HINT-SENTINEL', - details: { reason: 'unsupported-device-kind' }, - }); - expect(harness.bindDevice).not.toHaveBeenCalled(); - expect(harness.sendTelephonyEvent).not.toHaveBeenCalled(); -}); - test('rejects an unavailable exact-owner fact before binding', async () => { await expectRefusesUnavailableExactOwnerFact({ command: 'telephony', diff --git a/packages/daemon/src/execution/telephony-runtime.ts b/packages/daemon/src/execution/telephony-runtime.ts index e50bed77f0..b402f80efb 100644 --- a/packages/daemon/src/execution/telephony-runtime.ts +++ b/packages/daemon/src/execution/telephony-runtime.ts @@ -51,7 +51,7 @@ async function executeTelephonyEvent( return { action: input.action, number: input.number, - textLength: input.text.length, + textLength: Array.from(input.text).length, message: `Simulated an incoming SMS from ${input.number}`, }; } diff --git a/packages/platform-android/src/emulator-console.test.ts b/packages/platform-android/src/emulator-console.test.ts index 197024311d..6d806fb3ae 100644 --- a/packages/platform-android/src/emulator-console.test.ts +++ b/packages/platform-android/src/emulator-console.test.ts @@ -25,7 +25,7 @@ test('runs the command under adb emu with the caller signal and a bounded timeou expect(mockAdb).toHaveBeenCalledExactlyOnceWith(ANDROID_EMULATOR, ['emu', 'posture', '3'], { signal, allowFailure: true, - timeoutMs: expect.any(Number), + timeoutMs: 10_000, }); }); diff --git a/packages/platform-android/src/emulator-console.ts b/packages/platform-android/src/emulator-console.ts index f74d1c2472..b8d24fbd2a 100644 --- a/packages/platform-android/src/emulator-console.ts +++ b/packages/platform-android/src/emulator-console.ts @@ -6,7 +6,7 @@ import { androidAdbResultError } from './adb-failure.ts'; /** A console command answers in well under a second; this bounds a console that stopped answering. */ const EMULATOR_CONSOLE_TIMEOUT_MS = 10_000; -export type EmulatorConsoleReply = Readonly<{ +type EmulatorConsoleReply = Readonly<{ stdout: string; /** The reason after `KO:` when the console refused the command (empty for a bare `KO`). */ refusal?: string; @@ -15,8 +15,7 @@ export type EmulatorConsoleReply = Readonly<{ /** * Runs one emulator console command (`adb emu `). adb exits 0 whether the console answers * `OK` or `KO: `, so a refusal comes back in the reply for the caller to classify with its - * own reason; only a failed adb exit throws. A non-zero exit names the first two command words, - * never the caller's operands. + * own reason; only a failed adb exit throws, naming the first two command words. */ export async function runEmulatorConsole( device: DeviceInfo, diff --git a/packages/platform-android/src/foldable/pose.ts b/packages/platform-android/src/foldable/pose.ts index 3fe8f0526b..2b9ec9b852 100644 --- a/packages/platform-android/src/foldable/pose.ts +++ b/packages/platform-android/src/foldable/pose.ts @@ -139,7 +139,6 @@ async function readDeviceStates( return states; } -/** The console answers `OK` or `KO: …` on stdout and exits 0 either way, so the reply is the verdict. */ async function sendEmulatorPosture( device: DeviceInfo, pose: FoldPose, diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index a6cfc60736..7e61078a51 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -374,7 +374,7 @@ test.each(Object.entries(leaves))( }, ); -test.each(Object.entries(leaves))( +test.each([...Object.entries(leaves), ['ios physical device', appleDevice({ kind: 'device' })]])( 'refuses the telephony fact for the %s leaf', async (_name, device) => { const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ diff --git a/packages/session-journal/src/__tests__/session-event-action.test.ts b/packages/session-journal/src/__tests__/session-event-action.test.ts index 469b4ed69f..4f3be01054 100644 --- a/packages/session-journal/src/__tests__/session-event-action.test.ts +++ b/packages/session-journal/src/__tests__/session-event-action.test.ts @@ -317,6 +317,10 @@ test('telephony action events name the simulated event and carry neither number assert.equal(buildActionSummary(sms), 'Simulated an incoming SMS'); assert.equal(buildActionSummary(call), 'Simulated an incoming call'); + assert.equal( + buildActionSummary(action('telephony', { action: 'accept', number: '5551234' })), + 'Accepted the simulated call', + ); assert.equal(buildActionSummary(cancel), 'Ended the simulated call'); assert.equal(buildActionSummary(action('telephony', {})), 'Simulated a telephony event'); assert.deepEqual(buildActionDetails(sms), { diff --git a/src/commands/system/index.test.ts b/src/commands/system/index.test.ts index e4c70e1a71..acb6a3c78f 100644 --- a/src/commands/system/index.test.ts +++ b/src/commands/system/index.test.ts @@ -406,7 +406,7 @@ test('the CLI reader parses each action and joins unquoted SMS words', () => { }); }); -test('the CLI reader refuses a malformed event before any request is written', () => { +test('the CLI reader refuses a malformed event', () => { expect(() => telephonyCliReader(['sms', '5551234'], flags())).toThrow( expect.objectContaining({ code: 'INVALID_ARGS', message: expect.stringContaining('text') }), ); diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 98b40e3410..f4077800b3 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -305,7 +305,7 @@ const tvRemoteCommandDescription = 'Press or long-press a TV remote or D-pad button on Android TV, tvOS, or Vega OS. Choose the button and optional hold duration through the input fields. The aliases ok, center, and enter all map to select.'; const telephonyCommandDescription = - 'Simulate an incoming phone call or SMS on a local Android emulator through its console: call rings from the number, accept answers, cancel ends the call, and sms delivers a text message. The call screen or notification can cover the app, so re-snapshot afterwards.'; + 'Simulate an incoming phone call or SMS on an Android emulator through its console over adb: call rings from the number, accept answers, cancel ends the call, and sms delivers a text message. The call screen or notification can cover the app, so re-snapshot afterwards.'; const backCommandMetadata = defineFieldCommandMetadata(BACK_COMMAND_NAME, backCommandDescription, { mode: enumField(BACK_MODES), @@ -409,7 +409,7 @@ const telephonyFields = { text: stringField('Message text for sms; required for sms and refused for the call actions.'), }; -const telephonyCommandMetadata = defineFieldCommandMetadata( +const telephonyFieldMetadata = defineFieldCommandMetadata( TELEPHONY_COMMAND_NAME, telephonyCommandDescription, telephonyFields, @@ -421,6 +421,17 @@ const telephonyCommandMetadata = defineFieldCommandMetadata( }, ); +const telephonyCommandMetadata = { + ...telephonyFieldMetadata, + inputSchema: { + ...telephonyFieldMetadata.inputSchema, + oneOf: [ + { properties: { action: constSchema('sms') }, required: ['text'] }, + { properties: { action: enumSchema(TELEPHONY_CALL_ACTIONS) }, not: { required: ['text'] } }, + ], + }, +}; + const backCliSchema = { usageOverride: 'back [--in-app|--system] [--settle]', usageFlags: [], @@ -661,9 +672,9 @@ const tvRemoteCommandFacet = defineCommandFacet({ const telephonyCommandFacet = defineCommandFacet({ name: TELEPHONY_COMMAND_NAME, text: { - summary: 'Simulate an incoming call or SMS on a local adb-driven Android emulator', + summary: 'Simulate an incoming call or SMS on an Android emulator over adb', cliDetail: - 'Local adb-driven Android emulators only, through the emulator console; WebDriver (Appium) and Limrun sessions refuse it. Quote the SMS text, or pass it as the remaining words. Re-snapshot after the event: the call screen or notification can cover the app.', + 'Android emulators reached through adb only, including an ADB provider that forwards adb emu; WebDriver (Appium) and Limrun sessions refuse it. Quote the SMS text, or pass it as the remaining words. Re-snapshot after the event: the call screen or notification can cover the app.', }, metadata: telephonyCommandMetadata, run: (client, input) => client.command.telephony(input), diff --git a/test/integration/provider-scenarios/android-telephony.test.ts b/test/integration/provider-scenarios/android-telephony.test.ts index 6c9c633144..5a5bef4476 100644 --- a/test/integration/provider-scenarios/android-telephony.test.ts +++ b/test/integration/provider-scenarios/android-telephony.test.ts @@ -120,6 +120,7 @@ test('Provider-backed integration Android telephony reports a console refusal wi /refused telephony accept/, ); assert.equal((data.details as Record)?.reason, 'telephony-console-refused'); + assert.deepEqual(emulatorConsoleCalls(adb.calls), [['emu', 'gsm', 'accept', '5551234']]); }, ); }); @@ -141,11 +142,12 @@ test('Provider-backed integration Android telephony refuses a physical device be actions: [], }); - assertRpcError( + const data = assertRpcError( await daemon.callCommand('telephony', ['call', '5551234'], { platform: 'android' }), 'UNSUPPORTED_OPERATION', /telephony is not supported on this device/, ); + assert.equal((data.details as Record)?.reason, 'unsupported-device-kind'); assert.deepEqual(emulatorConsoleCalls(adb.calls), []); }, ); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 65c8f3963a..135c27daa2 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -126,8 +126,8 @@ Fold a foldable device: Simulate an incoming call or SMS: - `telephony call ` starts an incoming call from `` on an Android emulator; `telephony accept ` answers it and `telephony cancel ` ends it. `telephony sms ` delivers a text message from ``. Use it to check how an app reacts when a call or message arrives, for example whether video playback pauses and resumes. -- The events go through the emulator console (`adb emu gsm call|accept|cancel`, `adb emu sms send`), so only local Android emulators driven through adb support them. Physical Android devices refuse with `UNSUPPORTED_OPERATION` and `reason: unsupported-device-kind`; iOS, macOS, tvOS, web, Linux, HarmonyOS, Vega, and Limrun and WebDriver (Appium) sessions refuse with `UNSUPPORTED_OPERATION` too: agent-device sends telephony only through local adb and does not drive Appium's own call or SMS simulation. -- `` is digits with an optional leading `+`, up to 20 digits. Quote the SMS text, or pass it as the remaining words; the text is sent as one argument, with backslashes and line breaks (LF, or CRLF sent as LF) escaped for the emulator console. Tabs, lone carriage returns, and other control characters are refused. +- The events go through the emulator console (`adb emu gsm call|accept|cancel`, `adb emu sms send`), so only Android emulators reached through adb support them, including an ADB provider whose executor forwards `adb emu`. Physical Android devices refuse with `UNSUPPORTED_OPERATION` and `reason: unsupported-device-kind`; iOS, macOS, tvOS, web, Linux, HarmonyOS, Vega, and Limrun and WebDriver (Appium) sessions refuse with `UNSUPPORTED_OPERATION` too: agent-device sends telephony only through adb and does not drive Appium's own call or SMS simulation. +- `` is digits with an optional leading `+`, up to 15 digits. Quote the SMS text, or pass it as the remaining words; the text is sent as one argument, with backslashes and line breaks (LF, or CRLF sent as LF) escaped for the emulator console. Tabs, lone carriage returns, and other C0 or C1 control characters are refused. - The emulator console answers some events with an error, such as `accept` or `cancel` for a number with no call. That fails with `COMMAND_FAILED`, `reason: telephony-console-refused`, and the console's own `consoleReason`. - The call screen or message notification can cover the app. Re-snapshot before using refs from before the event. From 648cb25ddb12f48eebf5da65e12644ee359d584a Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 17:36:51 +0200 Subject: [PATCH 6/9] docs(telephony): describe cancel and emoji behavior seen on a live emulator On an emulator, gsm cancel for a number with no call answers OK, so telephony cancel succeeds there. Only accept is refused. The docs and the console-refused hint now say so. The emulator also drops SMS characters outside the Basic Multilingual Plane, such as emoji, whether they are sent raw or as escaped surrogates. The docs, changelog and the text field description now say that too. The changelog also notes that fold's posture and hinge-angle console calls now share the 10 s console timeout and the shorter failure message. --- CHANGELOG.md | 3 ++- packages/platform-android/src/telephony/console-event.ts | 2 +- src/commands/system/index.ts | 4 +++- website/docs/docs/commands.md | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a181fce29..f002b145b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,5 +10,6 @@ - `settings` refuses, before touching the device, a state its setting does not accept (`Invalid state: …`), a permission action or mode its target does not take (such as `permission deny accessibility`), and a stray extra argument (such as `clear-app-state `). The CLI and the daemon accept the same state spellings, in any casing or surrounding whitespace. - `alert wait 0` on Android reports an alert that is already visible instead of timing out. - `settings --help` describes each setting on its own line, and its usage folds settings that share a form. -- Added (android): `telephony call|accept|cancel ` and `telephony sms `, plus `client.command.telephony({ action, number, text })`, simulate an incoming phone call or SMS on an Android emulator through the emulator console (`adb emu gsm …`, `adb emu sms send …`), so a test can check how an app behaves when a call or message interrupts it, such as video playback pausing and resuming. The number must be up to 15 digits with an optional leading `+`; SMS text is sent as one adb argument, with backslashes and line breaks escaped for the console. A console refusal (for example `accept` for a number with no call) fails with `COMMAND_FAILED` and `reason: telephony-console-refused`. Physical Android devices, iOS, every other target, and the WebDriver (Appium) and Limrun providers refuse with `UNSUPPORTED_OPERATION`. +- Added (android): `telephony call|accept|cancel ` and `telephony sms `, plus `client.command.telephony({ action, number, text })`, simulate an incoming phone call or SMS on an Android emulator through the emulator console (`adb emu gsm …`, `adb emu sms send …`), so a test can check how an app behaves when a call or message interrupts it, such as video playback pausing and resuming. The number must be up to 15 digits with an optional leading `+`; SMS text is sent as one adb argument, with backslashes and line breaks escaped for the console; the emulator drops characters outside the Basic Multilingual Plane, such as emoji. A console refusal (for example `accept` for a number with no call) fails with `COMMAND_FAILED` and `reason: telephony-console-refused`. Physical Android devices, iOS, every other target, and the WebDriver (Appium) and Limrun providers refuse with `UNSUPPORTED_OPERATION`. +- `fold` on Android emulators: the posture and hinge-angle console calls now time out after 10 s instead of waiting on a console that stopped answering, and a failed call names only its first two words (for example `adb emu sensor get`). - Carry provider keyboard facts through snapshots. Appium captures, including wait polls and fill pre-captures, add one sequential keyboard visibility probe with a 500 ms timeout. Probe results preserve tap-time docked-tree checks; unavailable probes leave visibility unknown. diff --git a/packages/platform-android/src/telephony/console-event.ts b/packages/platform-android/src/telephony/console-event.ts index 11ad9a0eea..b1e8631ebe 100644 --- a/packages/platform-android/src/telephony/console-event.ts +++ b/packages/platform-android/src/telephony/console-event.ts @@ -5,7 +5,7 @@ import { AppError } from '@agent-device/kernel/errors'; import { runEmulatorConsole } from '../emulator-console.ts'; const CONSOLE_REFUSED_HINT = - 'The emulator console refused the event. accept and cancel need a call from that same number (start one with telephony call ), and an emulator image without a modem has no telephony to simulate.'; + 'The emulator console refused the event. accept needs a call from that same number (start one with telephony call ), and an emulator image without a modem has no telephony to simulate.'; /** Simulates one telephony event through the emulator console (`gsm …` / `sms send …`). */ export async function sendAndroidTelephonyEvent( diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index f4077800b3..ea02c75b8d 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -406,7 +406,9 @@ const telephonyFields = { number: requiredField( stringField('Caller or sender phone number: digits with an optional leading +.'), ), - text: stringField('Message text for sms; required for sms and refused for the call actions.'), + text: stringField( + 'Message text for sms; required for sms and refused for the call actions. The emulator drops characters outside the Basic Multilingual Plane, such as emoji.', + ), }; const telephonyFieldMetadata = defineFieldCommandMetadata( diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 135c27daa2..461630a627 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -128,7 +128,8 @@ Simulate an incoming call or SMS: - `telephony call ` starts an incoming call from `` on an Android emulator; `telephony accept ` answers it and `telephony cancel ` ends it. `telephony sms ` delivers a text message from ``. Use it to check how an app reacts when a call or message arrives, for example whether video playback pauses and resumes. - The events go through the emulator console (`adb emu gsm call|accept|cancel`, `adb emu sms send`), so only Android emulators reached through adb support them, including an ADB provider whose executor forwards `adb emu`. Physical Android devices refuse with `UNSUPPORTED_OPERATION` and `reason: unsupported-device-kind`; iOS, macOS, tvOS, web, Linux, HarmonyOS, Vega, and Limrun and WebDriver (Appium) sessions refuse with `UNSUPPORTED_OPERATION` too: agent-device sends telephony only through adb and does not drive Appium's own call or SMS simulation. - `` is digits with an optional leading `+`, up to 15 digits. Quote the SMS text, or pass it as the remaining words; the text is sent as one argument, with backslashes and line breaks (LF, or CRLF sent as LF) escaped for the emulator console. Tabs, lone carriage returns, and other C0 or C1 control characters are refused. -- The emulator console answers some events with an error, such as `accept` or `cancel` for a number with no call. That fails with `COMMAND_FAILED`, `reason: telephony-console-refused`, and the console's own `consoleReason`. +- The emulator console answers some events with an error, such as `accept` for a number with no call. That fails with `COMMAND_FAILED`, `reason: telephony-console-refused`, and the console's own `consoleReason`. `cancel` for a number with no call succeeds, because the console answers it with `OK`. +- The emulator drops SMS characters outside the Basic Multilingual Plane, such as emoji, and delivers the rest of the text. `textLength` counts the text as sent. - The call screen or message notification can cover the app. Re-snapshot before using refs from before the event. Remote daemons and tenants: From 2060665adade67f2cd8c16be1ac4edf850a775a0 Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 19:51:19 +0200 Subject: [PATCH 7/9] refactor(contracts): move the telephony runtime types to a type-only module TelephonyRuntimeOperations and TelephonyCommandResult now live in contracts/src/telephony-runtime.ts, beside fold-runtime.ts and tv-remote-runtime.ts. They are types only, so the eager closures do not grow. The value parsers stay in device-rotation.ts, where the eager closure budget still requires them. --- packages/contracts/src/device-rotation.ts | 30 ++----------------- packages/contracts/src/facades/device.ts | 3 +- .../src/platform-runtime-operations.ts | 2 +- packages/contracts/src/telephony-runtime.ts | 26 ++++++++++++++++ 4 files changed, 31 insertions(+), 30 deletions(-) create mode 100644 packages/contracts/src/telephony-runtime.ts diff --git a/packages/contracts/src/device-rotation.ts b/packages/contracts/src/device-rotation.ts index 70dff8bbe6..b97040a8bd 100644 --- a/packages/contracts/src/device-rotation.ts +++ b/packages/contracts/src/device-rotation.ts @@ -208,8 +208,9 @@ export function parseFoldKeyframesJson(value: string): readonly FoldKeyframe[] { } // ---- Telephony ------------------------------------------------------------------------------- -// The CLI and session journal load this module eagerly; a separate telephony module would grow -// their eager closures past scripts/__tests__/eager-closure-budgets.ts. +// The CLI and session journal load this module eagerly; moving these values to a module of their +// own would grow their eager closures past scripts/__tests__/eager-closure-budgets.ts. The types +// live in telephony-runtime.ts. /** * The telephony events an emulator can be asked to simulate: an incoming voice call (`call`), @@ -241,31 +242,6 @@ export type TelephonyEventInput = | Readonly<{ action: TelephonyCallAction; number: string }> | Readonly<{ action: 'sms'; number: string; text: string }>; -/** - * One simulated telephony event. The owner returns nothing: the emulator console acknowledges the - * event, and what the app does with the call or message is observed through the next capture. - */ -export type TelephonyRuntimeOperations = Readonly<{ - sendTelephonyEvent(input: TelephonyEventInput): Promise; -}>; - -/** - * Closed result of the `telephony` command: a discriminated union on `action`. The call actions - * echo the number; `sms` also reports the length of the text it delivered. - */ -export type TelephonyCommandResult = - | { - action: TelephonyCallAction; - number: string; - message: string; - } - | { - action: 'sms'; - number: string; - textLength: number; - message: string; - }; - function parseTelephonyAction(input: unknown): TelephonyAction { if (input === undefined) { throw new AppError('INVALID_ARGS', `telephony requires an action. Use ${TELEPHONY_USAGE}.`); diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 083ed62627..0318a025a4 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -58,10 +58,9 @@ export type { FoldKeyframe, FoldScreenCoordinateSpace, SetFoldPoseInput, - TelephonyCommandResult, TelephonyEventInput, - TelephonyRuntimeOperations, } from '../device-rotation.ts'; +export type { TelephonyCommandResult, TelephonyRuntimeOperations } from '../telephony-runtime.ts'; export type { BootCommandResult, ShutdownCommandResult } from '../device.ts'; export type { ProviderDeviceInstallOptions, diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index fa0f21166c..a3c04d59c5 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -23,7 +23,7 @@ import type { ElementTextRuntimeOperations } from './element-text-runtime.ts'; import type { BackRuntimeOperations } from './back-runtime.ts'; import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; import type { FoldRuntimeOperations } from './fold-runtime.ts'; -import type { TelephonyRuntimeOperations } from './device-rotation.ts'; +import type { TelephonyRuntimeOperations } from './telephony-runtime.ts'; import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; diff --git a/packages/contracts/src/telephony-runtime.ts b/packages/contracts/src/telephony-runtime.ts new file mode 100644 index 0000000000..74ff59c5ec --- /dev/null +++ b/packages/contracts/src/telephony-runtime.ts @@ -0,0 +1,26 @@ +import type { TelephonyEventInput } from './device-rotation.ts'; + +/** + * One simulated telephony event. The owner returns nothing: the emulator console acknowledges the + * event, and what the app does with the call or message is observed through the next capture. + */ +export type TelephonyRuntimeOperations = Readonly<{ + sendTelephonyEvent(input: TelephonyEventInput): Promise; +}>; + +/** + * Closed result of the `telephony` command: a discriminated union on `action`. The call actions + * echo the number; `sms` also reports the length of the text it delivered. + */ +export type TelephonyCommandResult = + | { + action: Exclude; + number: string; + message: string; + } + | { + action: 'sms'; + number: string; + textLength: number; + message: string; + }; From b9fc0ffdf5fa5edf102d9a0fcceda37079a97abd Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Fri, 16 Oct 2026 19:51:19 +0200 Subject: [PATCH 8/9] docs(telephony): limit the backend claim to the local adb executor The live run covered only the local adb executor, so the commands reference and the help detail no longer claim that an ADB provider works. They say other adb executors have not been verified. --- src/commands/system/index.ts | 2 +- website/docs/docs/commands.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index ea02c75b8d..7f17fd3da9 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -676,7 +676,7 @@ const telephonyCommandFacet = defineCommandFacet({ text: { summary: 'Simulate an incoming call or SMS on an Android emulator over adb', cliDetail: - 'Android emulators reached through adb only, including an ADB provider that forwards adb emu; WebDriver (Appium) and Limrun sessions refuse it. Quote the SMS text, or pass it as the remaining words. Re-snapshot after the event: the call screen or notification can cover the app.', + 'Android emulators through the local adb executor; WebDriver (Appium) and Limrun sessions refuse it. Quote the SMS text, or pass it as the remaining words. Re-snapshot after the event: the call screen or notification can cover the app.', }, metadata: telephonyCommandMetadata, run: (client, input) => client.command.telephony(input), diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 461630a627..09a0137b6b 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -126,7 +126,7 @@ Fold a foldable device: Simulate an incoming call or SMS: - `telephony call ` starts an incoming call from `` on an Android emulator; `telephony accept ` answers it and `telephony cancel ` ends it. `telephony sms ` delivers a text message from ``. Use it to check how an app reacts when a call or message arrives, for example whether video playback pauses and resumes. -- The events go through the emulator console (`adb emu gsm call|accept|cancel`, `adb emu sms send`), so only Android emulators reached through adb support them, including an ADB provider whose executor forwards `adb emu`. Physical Android devices refuse with `UNSUPPORTED_OPERATION` and `reason: unsupported-device-kind`; iOS, macOS, tvOS, web, Linux, HarmonyOS, Vega, and Limrun and WebDriver (Appium) sessions refuse with `UNSUPPORTED_OPERATION` too: agent-device sends telephony only through adb and does not drive Appium's own call or SMS simulation. +- The events go through the emulator console (`adb emu gsm call|accept|cancel`, `adb emu sms send`), so they work on Android emulators reached through the local adb executor. Other adb executors, such as an ADB provider, have not been verified. Physical Android devices refuse with `UNSUPPORTED_OPERATION` and `reason: unsupported-device-kind`; iOS, macOS, tvOS, web, Linux, HarmonyOS, Vega, and Limrun and WebDriver (Appium) sessions refuse with `UNSUPPORTED_OPERATION` too: agent-device sends telephony only through adb and does not drive Appium's own call or SMS simulation. - `` is digits with an optional leading `+`, up to 15 digits. Quote the SMS text, or pass it as the remaining words; the text is sent as one argument, with backslashes and line breaks (LF, or CRLF sent as LF) escaped for the emulator console. Tabs, lone carriage returns, and other C0 or C1 control characters are refused. - The emulator console answers some events with an error, such as `accept` for a number with no call. That fails with `COMMAND_FAILED`, `reason: telephony-console-refused`, and the console's own `consoleReason`. `cancel` for a number with no call succeeds, because the console answers it with `OK`. - The emulator drops SMS characters outside the Basic Multilingual Plane, such as emoji, and delivers the rest of the text. `textLength` counts the text as sent. From ec046fd9ef86df27d858b37d50206b1b4ecac265 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:20:08 +0000 Subject: [PATCH 9/9] chore(gates): drop the contracts telephony-runtime subpath from the exports snapshot --- scripts/layering/contracts-exports.snapshot.json | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 4a8d202843..a4f6860006 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -125,7 +125,6 @@ "@agent-device/contracts/startup-recovery-fence", "@agent-device/contracts/system-button-runtime", "@agent-device/contracts/tap-keyboard-occlusion", - "@agent-device/contracts/telephony-runtime", "@agent-device/contracts/touch-runtime", "@agent-device/contracts/tv-remote", "@agent-device/contracts/tv-remote-runtime",