From a832a1bf474dc348e25f802e0c32959c53bb0067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 11:24:19 +0200 Subject: [PATCH 1/4] fix(android): actionable error when the snapshot helper is unavailable (#1284) #1284 kept the hard-fail from #1217 (a silent stock-UIAutomator fallback produced a materially different, app-window-only capture) but asked for actionable hints on both failure modes: - Artifact missing on disk: hint now names the exact `pnpm build:android` command, the full dist file set it produces, and notes packaged installs ship it via prepack. - Artifact present but the device rejects the install (adb/OEM policy): the underlying adb error already surfaces in the message; the hint now explicitly frames this as a device-side failure distinct from a missing build artifact, tagged via a new androidSnapshotHelperInstallFailure detail set at the adb install call site. --- .../android/__tests__/snapshot-helper.test.ts | 32 ++++++++++++ .../android/__tests__/snapshot.test.ts | 50 +++++++++++++++++++ .../android/snapshot-helper-install.ts | 3 ++ src/platforms/android/snapshot.ts | 9 ++-- 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts index df2b1b1fc..24be2b9c4 100644 --- a/src/platforms/android/__tests__/snapshot-helper.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper.test.ts @@ -204,6 +204,38 @@ test('ensureAndroidSnapshotHelper installs when missing and skips a newer versio assert.equal(skipped.reason, 'current'); }); +test('ensureAndroidSnapshotHelper tags a device-side install rejection distinctly from artifact resolution', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-reject-')); + const apkPath = path.join(tmpDir, 'helper.apk'); + await fs.writeFile(apkPath, 'helper-apk'); + const adb: AndroidAdbExecutor = async (args) => { + if (args.includes('--show-versioncode')) { + return { exitCode: 1, stdout: '', stderr: 'not found' }; + } + if (args[0] === 'install') { + return { exitCode: 1, stdout: '', stderr: 'Failure [INSTALL_FAILED_TEST_ONLY]' }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + + await assert.rejects( + () => + ensureAndroidSnapshotHelper({ + adb, + artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } }, + }), + (error) => { + assert.match((error as Error).message, /Failed to install Android snapshot helper/); + assert.equal( + (error as { details?: Record }).details + ?.androidSnapshotHelperInstallFailure, + true, + ); + return true; + }, + ); +}); + test('ensureAndroidSnapshotHelper replaces same-version helper when APK bytes differ', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-identity-')); const apkPath = path.join(tmpDir, 'helper.apk'); diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index 3e6752a15..f4c2345e5 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -892,6 +892,56 @@ test('snapshotAndroid emits unavailable diagnostics when helper artifact is miss } }); +test('snapshotAndroid gives an actionable hint when the helper artifact is missing on disk', async () => { + const accessSpy = vi.spyOn(fs, 'access').mockRejectedValueOnce(new Error('helper missing')); + + try { + await assert.rejects( + () => snapshotAndroid(device), + (error) => { + assert.match((error as Error).message, /the bundled helper artifact was not found/); + const hint = String((error as { details?: Record }).details?.hint); + assert.match(hint, /pnpm build:android/); + assert.match(hint, /prepack/); + assert.match(hint, /\.apk\.idsig/); + assert.match(hint, /\.apk\.sha256/); + assert.match(hint, /\.manifest\.json/); + return true; + }, + ); + } finally { + accessSpy.mockRestore(); + } +}); + +test('snapshotAndroid distinguishes a device-side install rejection from a missing build artifact', async () => { + const helperAdb: AndroidAdbExecutor = async (args) => { + if (args.includes('--show-versioncode')) { + // No installed package reported, so ensureAndroidSnapshotHelper treats the + // helper as missing and attempts a real install. + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (args[0] === 'install') { + return { exitCode: 1, stdout: '', stderr: 'Failure [INSTALL_FAILED_TEST_ONLY]' }; + } + throw new Error(`unexpected adb args: ${args.join(' ')}`); + }; + + await assert.rejects( + () => snapshotAndroidWithHelper(helperAdb), + (error) => { + const message = (error as Error).message; + assert.match(message, /Android snapshot helper failed/); + assert.match(message, /Failed to install Android snapshot helper/); + assert.match(message, /INSTALL_FAILED_TEST_ONLY/); + const hint = String((error as { details?: Record }).details?.hint); + assert.match(hint, /device-side install failure/); + assert.doesNotMatch(hint, /pnpm build:android/); + return true; + }, + ); +}); + test('snapshotAndroid emits timeout diagnostics when helper capture times out', async () => { const helperAdb = createHelperAdb({ instrument: async () => { diff --git a/src/platforms/android/snapshot-helper-install.ts b/src/platforms/android/snapshot-helper-install.ts index f6a171cf5..48945e1c3 100644 --- a/src/platforms/android/snapshot-helper-install.ts +++ b/src/platforms/android/snapshot-helper-install.ts @@ -151,6 +151,9 @@ export async function ensureAndroidSnapshotHelper(options: { throw androidAdbResultError('Failed to install Android snapshot helper', result, { packageName, versionCode, + // Distinguishes a device-side install rejection (adb/OEM policy) from an + // artifact-resolution failure so the capture-layer error can hint accordingly. + androidSnapshotHelperInstallFailure: true, }); } diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 1f25979f3..7bbc77ab2 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -467,10 +467,13 @@ function androidSnapshotHelperCaptureError(error: unknown, reason: string): AppE const busy = isStructuredHelperTimeout(normalized.details?.helper, normalized.message) || isKilledHelperInstrumentationFailure(normalized); + const installFailure = normalized.details?.androidSnapshotHelperInstallFailure === true; const hint = busy ? 'Android accessibility snapshots can be blocked by busy or continuously changing app UI. Use screenshot as visual truth after this timeout and report the busy UI if it persists.' - : (normalized.hint ?? - 'Retry once. If the helper still fails, run agent-device doctor and report the diagnostic log; agent-device does not substitute a second snapshot engine.'); + : installFailure + ? `${normalized.hint ? `${normalized.hint} ` : ''}This is a device-side install failure (adb rejection or OEM policy) — the helper artifact itself is present, this is not a missing/unbuilt build.` + : (normalized.hint ?? + 'Retry once. If the helper still fails, run agent-device doctor and report the diagnostic log; agent-device does not substitute a second snapshot engine.'); return new AppError( toAppErrorCode(normalized.code), `Android snapshot helper failed: ${reason}`, @@ -545,7 +548,7 @@ function androidSnapshotHelperUnavailableError(errorReason: string | undefined): const reason = errorReason ?? 'the bundled helper artifact was not found'; return new AppError('COMMAND_FAILED', `Android snapshot helper is unavailable: ${reason}`, { androidSnapshotHelperFailureReason: reason, - hint: 'For a source checkout, run pnpm build:android. For a packaged install, reinstall agent-device; the Android snapshot helper must ship with the package.', + hint: 'Run `pnpm build:android` to build the full helper dist (android/snapshot-helper/dist: the .apk, .apk.idsig, .apk.sha256, and .manifest.json) for a source checkout. Packaged installs ship this dist automatically via the prepack script — if it is missing from a packaged install, reinstall agent-device.', }); } From 661d2769a39020998eecc76e0a6b7d897e459edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 11:36:11 +0200 Subject: [PATCH 2/4] fix(android): correct helper-missing hint to the two runtime-required files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review correction on #1285: resolveAndroidSnapshotHelperArtifact only fs.access'es the versioned .manifest.json and the .apk it references — the sha256 is a manifest field, and *.idsig is excluded from the npm package by design. The hint no longer claims the full sidecar set is required, and the test now rejects any future idsig claim. --- src/platforms/android/__tests__/snapshot.test.ts | 5 +++-- src/platforms/android/snapshot.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index f4c2345e5..ba45e7620 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -903,9 +903,10 @@ test('snapshotAndroid gives an actionable hint when the helper artifact is missi const hint = String((error as { details?: Record }).details?.hint); assert.match(hint, /pnpm build:android/); assert.match(hint, /prepack/); - assert.match(hint, /\.apk\.idsig/); - assert.match(hint, /\.apk\.sha256/); assert.match(hint, /\.manifest\.json/); + assert.match(hint, /\.apk/); + // The npm package excludes *.idsig by design — the hint must never claim it is required. + assert.doesNotMatch(hint, /idsig/); return true; }, ); diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 7bbc77ab2..a9761c703 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -548,7 +548,7 @@ function androidSnapshotHelperUnavailableError(errorReason: string | undefined): const reason = errorReason ?? 'the bundled helper artifact was not found'; return new AppError('COMMAND_FAILED', `Android snapshot helper is unavailable: ${reason}`, { androidSnapshotHelperFailureReason: reason, - hint: 'Run `pnpm build:android` to build the full helper dist (android/snapshot-helper/dist: the .apk, .apk.idsig, .apk.sha256, and .manifest.json) for a source checkout. Packaged installs ship this dist automatically via the prepack script — if it is missing from a packaged install, reinstall agent-device.', + hint: 'Run `pnpm build:android` to build the helper dist for a source checkout — the runtime needs android/snapshot-helper/dist/agent-device-android-snapshot-helper-.manifest.json and the .apk it references. Packaged installs ship these via the prepack script; if they are missing from a packaged install, reinstall agent-device.', }); } From dba7393b3bd2324ea3fc5521a4050508e9d4c651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 17:47:45 +0200 Subject: [PATCH 3/4] fix(android): cover install rejections and preserve diagnostic identity (#1285 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: the device-side install marker now covers the whole install phase. ensureAndroidSnapshotHelper previously tagged only a resolved nonzero install result; an AndroidAdbProvider.install rejection (enriched INSTALL_FAILED_* AppError from the provider funnel) bypassed the marker and fell back to the generic retry/doctor hint. Both paths now flow through markAndroidSnapshotHelperInstallFailure, which mutates details in place so the original code, message, hint, details, and cause all survive. Regression covers the public daemon snapshot route with a real request handler and an injected provider whose install rejects. P2: androidSnapshotHelperCaptureError rewrapped through normalizeError, which lifts diagnosticId/logPath out of details — the rewrap dropped them (ADR 0010 violation). They are now reinstated into the rewrapped error's details. Hint selection moved to a helper to keep the function under the complexity gate. --- ...est-router-android-snapshot-helper.test.ts | 84 +++++++++++++++++++ .../android/__tests__/snapshot-helper.test.ts | 43 ++++++++++ .../android/__tests__/snapshot.test.ts | 22 +++++ .../android/snapshot-helper-install.ts | 57 +++++++++---- src/platforms/android/snapshot.ts | 50 ++++++++--- 5 files changed, 227 insertions(+), 29 deletions(-) create mode 100644 src/daemon/__tests__/request-router-android-snapshot-helper.test.ts diff --git a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts new file mode 100644 index 000000000..e960b49e8 --- /dev/null +++ b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, expect, test } from 'vitest'; +import { createRequestHandler } from '../request-router.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; +import { SessionStore } from '../session-store.ts'; +import { AppError } from '../../kernel/errors.ts'; +import { resetAndroidSnapshotHelperInstallCache } from '../../platforms/android/snapshot-helper-install.ts'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from '../../__tests__/test-utils/index.ts'; +import type { AndroidAdbProvider } from '../../platforms/android/adb-executor.ts'; + +function makeAndroidSessionStore(name: string): SessionStore { + const sessionStore = new SessionStore(`/tmp/${name}`); + sessionStore.set('default', { + name: 'default', + createdAt: Date.now(), + device: { + platform: 'android', + id: 'remote-android-1', + name: 'Remote Android', + kind: 'device', + booted: true, + }, + appBundleId: 'com.example.app', + actions: [], + }); + return sessionStore; +} + +function makeHandler(sessionStore: SessionStore, androidAdbProvider: () => AndroidAdbProvider) { + return createRequestHandler({ + logPath: '/tmp/daemon.log', + token: 'token', + sessionStore, + leaseRegistry: new LeaseRegistry(), + androidAdbProvider, + trackDownloadableArtifact: () => 'artifact-id', + }); +} + +beforeEach(() => { + resetAndroidSnapshotHelperInstallCache(); +}); + +// Regression for #1284/#1285 P1: a provider whose `install` REJECTS with an +// enriched INSTALL_FAILED_* error (instead of resolving with a nonzero result) +// must still surface the device-side install classification on the public +// daemon snapshot error, not the generic retry/doctor hint. +test('snapshot reports a device-side install failure when the provider install rejects', async () => { + const sessionStore = makeAndroidSessionStore( + 'agent-device-request-router-snapshot-helper-install-reject-test', + ); + const provider: AndroidAdbProvider = { + // Version probe reports no installed helper; every other device query is inert. + exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + install: async () => { + throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { + stderr: 'adb: failed to install helper.apk: Failure [INSTALL_FAILED_TEST_ONLY]', + stdout: '', + exitCode: 1, + processExitError: true, + }); + }, + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + }; + const handler = makeHandler(sessionStore, () => provider); + + const response = await handler({ + token: 'token', + session: 'default', + command: 'snapshot', + positionals: [], + flags: {}, + }); + + expect(response.ok).toBe(false); + if (response.ok) throw new Error('Expected snapshot to fail'); + expect(response.error.message).toMatch(/Android snapshot helper failed/); + expect(response.error.message).toMatch(/INSTALL_FAILED_TEST_ONLY/); + // The provider funnel's classified adb hint is preserved, with the + // device-side framing appended rather than replacing it. + expect(response.error.hint).toMatch(/package installer rejected the APK/); + expect(response.error.hint).toMatch(/device-side install failure/); + expect(response.error.hint).not.toMatch(/pnpm build:android/); + expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true); +}); diff --git a/src/platforms/android/__tests__/snapshot-helper.test.ts b/src/platforms/android/__tests__/snapshot-helper.test.ts index 24be2b9c4..a3f28fade 100644 --- a/src/platforms/android/__tests__/snapshot-helper.test.ts +++ b/src/platforms/android/__tests__/snapshot-helper.test.ts @@ -13,6 +13,7 @@ import { forgetAndroidSnapshotHelperInstall, resetAndroidSnapshotHelperInstallCache, } from '../snapshot-helper-install.ts'; +import { AppError } from '../../../kernel/errors.ts'; import { parseAndroidSnapshotHelperManifest } from '../snapshot-helper-artifact.ts'; import { verifyAndroidHelperApkChecksum } from '../helper-package-install.ts'; import type { @@ -236,6 +237,48 @@ test('ensureAndroidSnapshotHelper tags a device-side install rejection distinctl ); }); +test('ensureAndroidSnapshotHelper tags a rejected provider install without losing the enriched error', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-throw-')); + const apkPath = path.join(tmpDir, 'helper.apk'); + await fs.writeFile(apkPath, 'helper-apk'); + const installError = new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { + stderr: 'adb: failed to install helper.apk: Failure [INSTALL_FAILED_TEST_ONLY]', + stdout: '', + exitCode: 1, + processExitError: true, + hint: 'The Android package installer rejected the APK — see the INSTALL_FAILED code in the error output for the exact cause.', + }); + const adb: AndroidAdbExecutor = async (args) => { + if (args.includes('--show-versioncode')) { + return { exitCode: 1, stdout: '', stderr: 'not found' }; + } + throw new Error(`unexpected adb call: ${args.join(' ')}`); + }; + const adbProvider: AndroidAdbProvider = { + exec: adb, + install: async () => { + throw installError; + }, + }; + + await assert.rejects( + () => + ensureAndroidSnapshotHelper({ + adb, + adbProvider, + artifact: { apkPath, manifest: { ...manifest, sha256: sha256Text('helper-apk') } }, + }), + (error) => { + assert.equal(error, installError); + const details = (error as AppError).details; + assert.equal(details?.androidSnapshotHelperInstallFailure, true); + assert.match(String(details?.stderr), /INSTALL_FAILED_TEST_ONLY/); + assert.match(String(details?.hint), /package installer rejected the APK/); + return true; + }, + ); +}); + test('ensureAndroidSnapshotHelper replaces same-version helper when APK bytes differ', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-identity-')); const apkPath = path.join(tmpDir, 'helper.apk'); diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index ba45e7620..8b203b250 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -943,6 +943,28 @@ test('snapshotAndroid distinguishes a device-side install rejection from a missi ); }); +test('snapshotAndroid preserves upstream diagnosticId and logPath through the capture rewrap', async () => { + const helperAdb = createHelperAdb({ + instrument: async () => { + throw new AppError('COMMAND_FAILED', 'helper capture exploded', { + diagnosticId: 'diag-upstream', + logPath: '/tmp/upstream.ndjson', + }); + }, + }); + + await assert.rejects( + () => snapshotAndroidWithHelper(helperAdb), + (error) => { + assert.match((error as Error).message, /Android snapshot helper failed/); + const details = (error as { details?: Record }).details; + assert.equal(details?.diagnosticId, 'diag-upstream'); + assert.equal(details?.logPath, '/tmp/upstream.ndjson'); + return true; + }, + ); +}); + test('snapshotAndroid emits timeout diagnostics when helper capture times out', async () => { const helperAdb = createHelperAdb({ instrument: async () => { diff --git a/src/platforms/android/snapshot-helper-install.ts b/src/platforms/android/snapshot-helper-install.ts index 48945e1c3..ada996675 100644 --- a/src/platforms/android/snapshot-helper-install.ts +++ b/src/platforms/android/snapshot-helper-install.ts @@ -1,3 +1,4 @@ +import { asAppError, type AppError } from '../../kernel/errors.ts'; import { readAndroidSnapshotHelperInstallOptions } from './snapshot-helper-artifact.ts'; import { inspectInstalledAndroidHelper, @@ -136,25 +137,30 @@ export async function ensureAndroidSnapshotHelper(options: { }; } - const result = await installAndroidSnapshotHelper( - adb, - options.adbProvider ?? adb, - artifact.apkPath, - readAndroidSnapshotHelperInstallOptions(artifact.manifest), - { - packageName, - timeoutMs: options.timeoutMs, - }, - ); + let result: Awaited>; + try { + result = await installAndroidSnapshotHelper( + adb, + options.adbProvider ?? adb, + artifact.apkPath, + readAndroidSnapshotHelperInstallOptions(artifact.manifest), + { + packageName, + timeoutMs: options.timeoutMs, + }, + ); + } catch (error) { + forgetInstalledSnapshotHelper(installCacheKey); + throw markAndroidSnapshotHelperInstallFailure(error, { packageName, versionCode }); + } if (result.exitCode !== 0) { forgetInstalledSnapshotHelper(installCacheKey); - throw androidAdbResultError('Failed to install Android snapshot helper', result, { - packageName, - versionCode, - // Distinguishes a device-side install rejection (adb/OEM policy) from an - // artifact-resolution failure so the capture-layer error can hint accordingly. - androidSnapshotHelperInstallFailure: true, - }); + throw markAndroidSnapshotHelperInstallFailure( + androidAdbResultError('Failed to install Android snapshot helper', result, { + packageName, + versionCode, + }), + ); } rememberInstalledSnapshotHelper(installCacheKey, versionCode); @@ -168,6 +174,23 @@ export async function ensureAndroidSnapshotHelper(options: { }; } +// Tags every failure of the helper install phase — nonzero results and provider +// rejections alike — so the capture layer can distinguish a device-side install +// rejection (adb/OEM policy) from a missing build artifact. Mutates details in +// place, preserving the original code, message, hint, and cause. +function markAndroidSnapshotHelperInstallFailure( + error: unknown, + context?: { packageName: string; versionCode: number }, +): AppError { + const appError = asAppError(error, 'COMMAND_FAILED'); + appError.details = { + ...context, + ...appError.details, + androidSnapshotHelperInstallFailure: true, + }; + return appError; +} + function normalizeAdbProvider( provider: AndroidAdbProvider | AndroidAdbExecutor | undefined, adb: AndroidAdbExecutor, diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index a9761c703..216446a67 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -1,6 +1,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { AppError, normalizeError, toAppErrorCode } from '../../kernel/errors.ts'; +import { + AppError, + normalizeError, + toAppErrorCode, + type NormalizedError, +} from '../../kernel/errors.ts'; import { emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import { findProjectRoot, readVersion } from '../../utils/version.ts'; @@ -464,28 +469,49 @@ function formatAndroidSnapshotHelperFailureReason(error: unknown): string { function androidSnapshotHelperCaptureError(error: unknown, reason: string): AppError { const normalized = normalizeError(error); - const busy = - isStructuredHelperTimeout(normalized.details?.helper, normalized.message) || - isKilledHelperInstrumentationFailure(normalized); - const installFailure = normalized.details?.androidSnapshotHelperInstallFailure === true; - const hint = busy - ? 'Android accessibility snapshots can be blocked by busy or continuously changing app UI. Use screenshot as visual truth after this timeout and report the busy UI if it persists.' - : installFailure - ? `${normalized.hint ? `${normalized.hint} ` : ''}This is a device-side install failure (adb rejection or OEM policy) — the helper artifact itself is present, this is not a missing/unbuilt build.` - : (normalized.hint ?? - 'Retry once. If the helper still fails, run agent-device doctor and report the diagnostic log; agent-device does not substitute a second snapshot engine.'); return new AppError( toAppErrorCode(normalized.code), `Android snapshot helper failed: ${reason}`, { ...normalized.details, + ...liftedDiagnosticIdentity(normalized), androidSnapshotHelperFailureReason: reason, - hint, + hint: androidSnapshotHelperCaptureHint(normalized), }, error, ); } +function androidSnapshotHelperCaptureHint(normalized: NormalizedError): string { + const busy = + isStructuredHelperTimeout(normalized.details?.helper, normalized.message) || + isKilledHelperInstrumentationFailure(normalized); + if (busy) { + return 'Android accessibility snapshots can be blocked by busy or continuously changing app UI. Use screenshot as visual truth after this timeout and report the busy UI if it persists.'; + } + if (normalized.details?.androidSnapshotHelperInstallFailure === true) { + return [ + normalized.hint, + 'This is a device-side install failure (adb rejection or OEM policy) — the helper artifact itself is present, this is not a missing/unbuilt build.', + ] + .filter(Boolean) + .join(' '); + } + return ( + normalized.hint ?? + 'Retry once. If the helper still fails, run agent-device doctor and report the diagnostic log; agent-device does not substitute a second snapshot engine.' + ); +} + +// normalizeError lifts these out of details (ADR 0010); a rewrap must put them +// back so the upstream diagnostic identity survives. +function liftedDiagnosticIdentity(normalized: NormalizedError): Record { + const identity: Record = {}; + if (normalized.diagnosticId !== undefined) identity.diagnosticId = normalized.diagnosticId; + if (normalized.logPath !== undefined) identity.logPath = normalized.logPath; + return identity; +} + function isKilledHelperInstrumentationFailure(error: { message: string; details?: Record; From 1089e7c061e1b8f8b121a063f728042acf1ffc19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 18:14:40 +0200 Subject: [PATCH 4/4] fix(android): restore all lifted wire fields through the capture rewrap (#1285 review) normalizeError hoists hint, diagnosticId, logPath, retriable, and supportedOn out of details (ADR 0010); the capture rewrap restored only diagnosticId/logPath, so a transient-classified install rejection (e.g. connection_dropped) lost its structured retriable signal on the public daemon error. liftedDiagnosticIdentity is generalized to liftedWireFields covering the complete hoisted set (hint stays owned by the capture hint selector). Public route regression: a retriable provider install rejection keeps error.retriable === true on the daemon snapshot response. --- ...est-router-android-snapshot-helper.test.ts | 38 +++++++++++++++++++ src/platforms/android/snapshot.ts | 20 ++++++---- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts index e960b49e8..84f3c7d7d 100644 --- a/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts +++ b/src/daemon/__tests__/request-router-android-snapshot-helper.test.ts @@ -82,3 +82,41 @@ test('snapshot reports a device-side install failure when the provider install r expect(response.error.hint).not.toMatch(/pnpm build:android/); expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true); }); + +// ADR 0010 wire contract: a transient-classified install rejection (here the +// funnel's `connection_dropped` family) must keep its structured `retriable` +// signal through the capture rewrap onto the public daemon error. +test('snapshot keeps the transient retry signal when the provider install rejection is retriable', async () => { + const sessionStore = makeAndroidSessionStore( + 'agent-device-request-router-snapshot-helper-install-transient-test', + ); + const provider: AndroidAdbProvider = { + exec: async () => ({ exitCode: 0, stdout: '', stderr: '' }), + install: async () => { + throw new AppError('COMMAND_FAILED', 'Failed to install Android snapshot helper', { + stderr: 'adb: transport error while pushing helper.apk', + stdout: '', + exitCode: 1, + processExitError: true, + }); + }, + snapshotHelperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + }; + const handler = makeHandler(sessionStore, () => provider); + + const response = await handler({ + token: 'token', + session: 'default', + command: 'snapshot', + positionals: [], + flags: {}, + }); + + expect(response.ok).toBe(false); + if (response.ok) throw new Error('Expected snapshot to fail'); + expect(response.error.message).toMatch(/Android snapshot helper failed/); + expect(response.error.hint).toMatch(/connection dropped/); + expect(response.error.hint).toMatch(/device-side install failure/); + expect(response.error.retriable).toBe(true); + expect(response.error.details?.androidSnapshotHelperInstallFailure).toBe(true); +}); diff --git a/src/platforms/android/snapshot.ts b/src/platforms/android/snapshot.ts index 216446a67..8377e8414 100644 --- a/src/platforms/android/snapshot.ts +++ b/src/platforms/android/snapshot.ts @@ -474,7 +474,7 @@ function androidSnapshotHelperCaptureError(error: unknown, reason: string): AppE `Android snapshot helper failed: ${reason}`, { ...normalized.details, - ...liftedDiagnosticIdentity(normalized), + ...liftedWireFields(normalized), androidSnapshotHelperFailureReason: reason, hint: androidSnapshotHelperCaptureHint(normalized), }, @@ -503,13 +503,17 @@ function androidSnapshotHelperCaptureHint(normalized: NormalizedError): string { ); } -// normalizeError lifts these out of details (ADR 0010); a rewrap must put them -// back so the upstream diagnostic identity survives. -function liftedDiagnosticIdentity(normalized: NormalizedError): Record { - const identity: Record = {}; - if (normalized.diagnosticId !== undefined) identity.diagnosticId = normalized.diagnosticId; - if (normalized.logPath !== undefined) identity.logPath = normalized.logPath; - return identity; +// normalizeError hoists these wire-contract fields out of details (ADR 0010; +// the complete set stripDiagnosticMeta removes, minus hint, which +// androidSnapshotHelperCaptureHint owns). A rewrap must put every one back so +// upstream diagnostics and typed signals like `retriable` survive. +function liftedWireFields(normalized: NormalizedError): Record { + const fields: Record = {}; + if (normalized.diagnosticId !== undefined) fields.diagnosticId = normalized.diagnosticId; + if (normalized.logPath !== undefined) fields.logPath = normalized.logPath; + if (normalized.retriable !== undefined) fields.retriable = normalized.retriable; + if (normalized.supportedOn !== undefined) fields.supportedOn = normalized.supportedOn; + return fields; } function isKilledHelperInstrumentationFailure(error: {