Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/command-registry/src/__tests__/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,31 @@ function batchRequest(commands: string[], responseLevel?: ResponseLevel): BatchR
};
}

// #2997: `testIme` joined INHERITED_PARENT_FLAG_KEYS for the flow command's session
// opens; batch shares that list, so its steps inherit the parent's opt-in the same way.
// Pinning it here keeps the shared-list addition deliberate for both consumers.
test('batch steps inherit the parent testIme session-open opt-in', async () => {
const seen: Array<Record<string, unknown> | undefined> = [];
const request: BatchRequest = {
token: 't',
command: 'batch',
positionals: [],
flags: {
batchSteps: [{ command: 'open' }, { command: 'open', flags: { testIme: false } }],
testIme: true,
},
};
const response = await runBatch(request, 'session', async (req) => {
seen.push(req.flags as Record<string, unknown> | undefined);
return { ok: true, data: {} };
});

assert.equal(response.ok, true);
assert.equal(seen[0]?.testIme, true);
// A step's own flag wins; the merge only fills gaps.
assert.equal(seen[1]?.testIme, false);
});

test('batch preserves typed error recovery signals from a failing step', async () => {
const response = await runBatch(batchRequest(['open']), 'session', async () => ({
ok: false,
Expand Down
3 changes: 3 additions & 0 deletions packages/command-registry/src/batch-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export const INHERITED_PARENT_FLAG_KEYS = [
'serial',
'verbose',
'out',
// A session-open opt-in the parent flow request carries down to its dispatched opens
// (replay steps and batch steps alike); a step's own recorded flag wins.
'testIme',
] as const;

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/command-registry/src/flag-definitions-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
type: 'boolean',
usageLabel: '--test-ime',
usageDescription:
'open: activate the headless Android test IME for deterministic Unicode text entry (default on for emulators; opt-in on real devices)',
'open/test/replay: activate the headless Android test IME for deterministic Unicode text entry (default on for emulators; opt-in on real devices; on test/replay it applies to the sessions the flow opens)',
projectConfig: true,
recorded: false,
},
Expand All @@ -326,7 +326,7 @@ export const TARGET_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
setValue: false,
usageLabel: '--no-test-ime',
usageDescription:
'open: keep the real Android keyboard even on emulators (opt out of the headless test IME)',
'open/test/replay: keep the real Android keyboard even on emulators (opt out of the headless test IME; on test/replay it applies to the sessions the flow opens)',
projectConfig: true,
recorded: false,
},
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,10 @@
"types": "./src/interaction.ts",
"default": "./src/interaction.ts"
},
"./input-validation": {
"types": "./src/input-validation.ts",
"default": "./src/input-validation.ts"
},
"./is-predicate": {
"types": "./src/is-predicate.ts",
"default": "./src/is-predicate.ts"
Expand Down
12 changes: 12 additions & 0 deletions packages/contracts/src/client-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export type ReplayRunOptions = AgentDeviceRequestOverrides &
saveScript?: boolean | string;
/** #1258: overwrite an existing --save-script target instead of refusing. Alias: --overwrite. */
force?: boolean;
/**
* Activate the headless Android test IME for the sessions this replay opens
* (default on for emulators; opt-in on real devices). `false` keeps the real
* keyboard even on emulators.
*/
testIme?: boolean;
};

export type ReplayTestOptions = AgentDeviceRequestOverrides &
Expand All @@ -61,6 +67,12 @@ export type ReplayTestOptions = AgentDeviceRequestOverrides &
reportJunit?: string;
shardAll?: number;
shardSplit?: number;
/**
* Activate the headless Android test IME for the sessions each suite attempt
* opens (default on for emulators; opt-in on real devices). `false` keeps the
* real keyboard even on emulators.
*/
testIme?: boolean;
};

export type BatchStep = {
Expand Down
6 changes: 5 additions & 1 deletion packages/contracts/src/facades/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export type { CommandExecutionOptions, InternalRequestOptions } from '../request
export type { CommandFlags, MaestroRuntimeFlags } from '../command-flags.ts';
export type { DaemonWireRequest, DaemonWireRequestMeta } from '../daemon-wire-request.ts';
export type { DispatchedCommand } from '../dispatched-command.ts';
export { readOptionalInteger, readOptionalNumber } from '../input-validation.ts';
export {
ANDROID_SHELL_TEXT_UNSUPPORTED_REASON,
readOptionalInteger,
readOptionalNumber,
} from '../input-validation.ts';
export {
IOS_SAFARI_BUNDLE_ID,
isDeepLinkTarget,
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/src/input-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,11 @@ export function readOptionalNumber(
}
return value;
}

/**
* The typed reason the Android adb-shell text channel reports when it cannot carry the
* requested text. Recovery routing keys on this constant, never on the message: the message
* states the channel limit; the reason names which recovery surfaces apply. It lives here
* because every producer and consumer of the reason already evaluates this module.
*/
export const ANDROID_SHELL_TEXT_UNSUPPORTED_REASON = 'android_shell_text_unsupported' as const;
7 changes: 6 additions & 1 deletion packages/platform-android/src/__tests__/text-input.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { fillAndroid, typeAndroid } from '../text-input.ts';
import { ANDROID_SHELL_TEXT_UNSUPPORTED_REASON } from '@agent-device/contracts/input-validation';
import { ANDROID_TEST_IME_OPEN_HINT, fillAndroid, typeAndroid } from '../text-input.ts';
import { assertRejectsAppError } from './test-utils/app-error.ts';
import {
ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT,
Expand Down Expand Up @@ -226,9 +227,13 @@ test('typeAndroid reports clear error when unicode input is unsupported', async
return { stderr: `unexpected args: ${args.join(' ')}`, exitCode: 1 };
},
async ({ device }) => {
// #2997: consumers route recovery on the typed reason; the hint stays the
// direct-interaction `open --test-ime` route and the replay boundary rewrites it.
await assertRejectsAppError(() => typeAndroid(device, '很'), {
code: 'COMMAND_FAILED',
message: /provider-native text injection/i,
reason: ANDROID_SHELL_TEXT_UNSUPPORTED_REASON,
hint: ANDROID_TEST_IME_OPEN_HINT,
});
},
);
Expand Down
16 changes: 15 additions & 1 deletion packages/platform-android/src/text-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* `fill-verification.ts`.
*/
import type { FillUnconfirmedVerification } from '@agent-device/contracts/fill-evidence';
import { ANDROID_SHELL_TEXT_UNSUPPORTED_REASON } from '@agent-device/contracts/command';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError, discloseDispatchAfterSteps } from '@agent-device/kernel/errors';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
Expand Down Expand Up @@ -463,14 +464,27 @@ function isAndroidInputTextUnsupported(error: unknown): boolean {
return false;
}

/**
* The direct-interaction route's recovery (`open`, then a failing `fill`/`press`). The replay
* failure boundary replaces it for flow runs off the typed reason below, so a flow caller is
* never sent to a flag only `open` accepts.
*/
export const ANDROID_TEST_IME_OPEN_HINT =
'On emulators the test IME activates automatically; on real devices pass `open --test-ime` to enable it (see `agent-device doctor` for the current IME state).';

function unsupportedAndroidShellTextError(text: string, cause?: unknown): AppError {
return new AppError(
'COMMAND_FAILED',
'Android text input requires provider-native text injection or the bundled test IME helper for non-ASCII/control characters; the adb-shell fallback supports ASCII text only. On emulators the test IME activates automatically; on real devices pass `open --test-ime` to enable it (see `agent-device doctor` for the current IME state).',
'Android text input requires provider-native text injection or the bundled test IME helper for non-ASCII/control characters; the adb-shell fallback supports ASCII text only.',
{
backend: 'adb-shell',
reason: ANDROID_SHELL_TEXT_UNSUPPORTED_REASON,
textLength: Array.from(text).length,
textPreview: text.slice(0, 32),
// The direct-interaction route's recovery. The replay failure boundary rewrites it
// off the typed reason (`ANDROID_TEST_IME_FLOW_HINT`), so a flow caller is never
// sent to a flag only `open` accepts.
hint: ANDROID_TEST_IME_OPEN_HINT,
},
cause instanceof Error ? cause : undefined,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { test, expect } from 'vitest';
import { buildReplayDivergenceFailureResponseFromDescriptor } from '../session-replay-runtime-failure-response.ts';
import { ANDROID_SHELL_TEXT_UNSUPPORTED_REASON } from '@agent-device/contracts/input-validation';
import {
ANDROID_TEST_IME_FLOW_HINT,
buildReplayDivergenceFailureResponseFromDescriptor,
hoistReplayFailureCauseDiagnosticMeta,
} from '../session-replay-runtime-failure-response.ts';

test('native replay failure metadata keeps machine fields and daemon-owned paths intact', () => {
const replayPath = '/tmp/flows/ios-login.ad';
Expand Down Expand Up @@ -70,3 +75,36 @@ test('a replay divergence carries the readiness evidence of an exhausted target
expect(response.error.code).toBe('REPLAY_DIVERGENCE');
expect(response.error.details).toMatchObject({ reason: 'selector_not_found', readiness });
});

// #2997: the Android platform states `open --test-ime` because it sees one dispatched
// session-open. On this surface that advice is unactionable — a flow caller never runs
// `open` — so the cause's hint is replaced off the typed reason, never off the message.
// The fixture carries the production shape: the cause arrives WITH the open-route hint
// already hoisted, so a rewrite that only fills a missing hint would still fail here.
test('an Android shell-text cause gets the flow-owned --test-ime recovery', () => {
// The platform's own hint travels over the wire, so the fixture states it literally the way
// a consumer sees it: replay-port never imports the Android package.
const openRouteHint =
'On emulators the test IME activates automatically; on real devices pass `open --test-ime` to enable it (see `agent-device doctor` for the current IME state).';
const cause = hoistReplayFailureCauseDiagnosticMeta({
code: 'COMMAND_FAILED',
message:
'Android text input requires provider-native text injection or the bundled test IME helper for non-ASCII/control characters; the adb-shell fallback supports ASCII text only.',
hint: openRouteHint,
details: { reason: ANDROID_SHELL_TEXT_UNSUPPORTED_REASON, hint: openRouteHint },
});

expect(cause.hint).toBe(ANDROID_TEST_IME_FLOW_HINT);
expect(cause.hint).toContain('--test-ime');
expect(cause.hint).not.toContain('open --test-ime');
});

test('a cause without the Android shell-text reason keeps its own hoisted hint', () => {
const cause = hoistReplayFailureCauseDiagnosticMeta({
code: 'COMMAND_FAILED',
message: 'Selector did not match',
details: { reason: 'selector_not_found', hint: 'Inspect the latest snapshot.' },
});

expect(cause.hint).toBe('Inspect the latest snapshot.');
});
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,7 @@ function maestroRuntimeDeviceFlags(
platform,
target: device.target,
noRecord: true,
...(requestedFlags?.testIme === undefined ? {} : { testIme: requestedFlags.testIme }),
};
if (platform === 'android') return { ...flags, serial: device.id };
return {
Expand All @@ -367,6 +368,7 @@ function unresolvedMaestroRuntimeDeviceFlags(
platform,
target: requestedFlags?.target ?? 'mobile',
noRecord: true,
...(requestedFlags?.testIme === undefined ? {} : { testIme: requestedFlags.testIme }),
};
if (requestedFlags?.device) flags.device = requestedFlags.device;
return platform === 'android'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SessionAction } from '@agent-device/contracts/session';
import { ANDROID_SHELL_TEXT_UNSUPPORTED_REASON } from '@agent-device/contracts/input-validation';
import { scrubReplayVarValues, type ReplayVarScrubEntry } from '@agent-device/ad-replay/divergence';
import { formatDivergenceActionLabel } from '@agent-device/ad-script';
import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture';
Expand All @@ -7,15 +8,33 @@ import { type DaemonResponse } from '@agent-device/kernel/contracts';

export type ReplayFailureCause = Extract<DaemonResponse, { ok: false }>['error'];

/**
* Recovery hint for flow-owned session opens: `replay`/`test` accept `--test-ime` themselves
* and pass the opt-in to the sessions their flow opens.
*/
export const ANDROID_TEST_IME_FLOW_HINT =
'On emulators the test IME activates automatically; on real devices pass `--test-ime` to this test/replay run to enable it for the sessions the flow opens (see `agent-device doctor` for the current IME state).';

export function hoistReplayFailureCauseDiagnosticMeta(
error: ReplayFailureCause,
): ReplayFailureCause {
return {
const cause: ReplayFailureCause = {
...error,
hint: error.hint ?? readStringDetail(error.details, 'hint'),
diagnosticId: error.diagnosticId ?? readStringDetail(error.details, 'diagnosticId'),
logPath: error.logPath ?? readStringDetail(error.details, 'logPath'),
};
return rewriteAndroidTestImeFlowHint(cause);
}

/**
* The Android platform states the `open --test-ime` recovery because it sees one
* dispatched session-open; a flow caller cannot run `open`, so on this surface the
* recovery is the `test`/`replay` flag itself. Keyed on the typed reason only.
*/
function rewriteAndroidTestImeFlowHint(error: ReplayFailureCause): ReplayFailureCause {
if (error.details?.reason !== ANDROID_SHELL_TEXT_UNSUPPORTED_REASON) return error;
return { ...error, hint: ANDROID_TEST_IME_FLOW_HINT };
}

export function buildReplayDivergenceFailureResponse(params: {
Expand Down
1 change: 1 addition & 0 deletions scripts/layering/contracts-exports.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@agent-device/contracts/gesture-plan-types",
"@agent-device/contracts/gesture-runtime",
"@agent-device/contracts/host-diagnostics",
"@agent-device/contracts/input-validation",
"@agent-device/contracts/interaction",
"@agent-device/contracts/interaction-guarantees",
"@agent-device/contracts/interactor-operation-catalog",
Expand Down
18 changes: 0 additions & 18 deletions src/cli/parser/__tests__/args-parse-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,6 @@ test('parseArgs recognizes command-specific flag combinations', async () => {
assert.equal(parsed.flags.relaunch, true);
},
},
{
label: 'open --test-ime forces the Android test IME on',
argv: ['open', 'settings', '--platform', 'android', '--test-ime'],
strictFlags: true,
assertParsed: (parsed) => {
assert.equal(parsed.command, 'open');
assert.equal(parsed.flags.testIme, true);
},
},
{
label: 'open --no-test-ime forces the Android test IME off',
argv: ['open', 'settings', '--platform', 'android', '--no-test-ime'],
strictFlags: true,
assertParsed: (parsed) => {
assert.equal(parsed.command, 'open');
assert.equal(parsed.flags.testIme, false);
},
},
{
label: 'open --platform ios --target tv',
argv: ['open', 'Settings', '--platform', 'ios', '--target', 'tv'],
Expand Down
54 changes: 54 additions & 0 deletions src/cli/parser/__tests__/args-parse-test-ime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* `--test-ime` / `--no-test-ime` parsing across the three surfaces that accept it (#2997).
* Split out of `args-parse-session.test.ts`, which sits at the 1,000-line test-size
* tripwire (AGENTS.md "Module and test topology").
*/
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { parseArgs } from '../args.ts';

const scenarios: Array<{
label: string;
argv: string[];
assertParsed: (parsed: ReturnType<typeof parseArgs>) => void;
}> = [
{
label: 'open --test-ime forces the Android test IME on',
argv: ['open', 'settings', '--platform', 'android', '--test-ime'],
assertParsed: (parsed) => {
assert.equal(parsed.command, 'open');
assert.equal(parsed.flags.testIme, true);
},
},
{
label: 'open --no-test-ime forces the Android test IME off',
argv: ['open', 'settings', '--platform', 'android', '--no-test-ime'],
assertParsed: (parsed) => {
assert.equal(parsed.command, 'open');
assert.equal(parsed.flags.testIme, false);
},
},
{
label: 'test --test-ime opts the suite session opens into the Android test IME',
argv: ['test', './suite.ad', '--test-ime'],
assertParsed: (parsed) => {
assert.equal(parsed.command, 'test');
assert.equal(parsed.flags.testIme, true);
},
},
{
label: 'replay --no-test-ime forces the real keyboard for the replay sessions',
argv: ['replay', './flow.ad', '--no-test-ime'],
assertParsed: (parsed) => {
assert.equal(parsed.command, 'replay');
assert.equal(parsed.flags.testIme, false);
},
},
];

test('parseArgs admits --test-ime on every surface that accepts it', async () => {
for (const scenario of scenarios) {
const parsed = parseArgs(scenario.argv, { strictFlags: true });
scenario.assertParsed(parsed);
}
});
Loading
Loading