diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index af7241b13..b1a86c886 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -116,6 +116,13 @@ export type CliFlags = CloudProviderProfileFields & saveScript?: boolean | string; shutdown?: boolean; relaunch?: boolean; + /** + * RFC prototype (open --foreground, branch claude/observe-foreground): resolve the + * app target from the sole booted iOS simulator's sole running app instead of + * requiring an explicit app argument. Fails closed (AMBIGUOUS_MATCH) unless the + * environment is unambiguous. + */ + foreground?: boolean; surface?: SessionSurface; headless?: boolean; restart?: boolean; diff --git a/packages/contracts/src/client-app.ts b/packages/contracts/src/client-app.ts index 4a91b3266..ebfa889fa 100644 --- a/packages/contracts/src/client-app.ts +++ b/packages/contracts/src/client-app.ts @@ -45,6 +45,13 @@ export type AppOpenOptions = AgentDeviceRequestOverrides & launchConsole?: string; launchArgs?: string[]; relaunch?: boolean; + /** + * RFC prototype (branch claude/observe-foreground): resolve the app target from the + * sole booted iOS simulator's sole running app instead of an explicit app + * argument, and return an initial interactive snapshot alongside the open result. + * Fails closed (AMBIGUOUS_MATCH) unless the environment is unambiguous. + */ + foreground?: boolean; saveScript?: boolean | string; /** #1258: overwrite an existing --save-script target instead of refusing. Alias: --overwrite. */ force?: boolean; @@ -67,6 +74,16 @@ export type AppOpenResult = { startup?: StartupPerfSample; runtime?: SessionRuntimeHints; device?: AgentDeviceSessionDevice; + /** + * RFC prototype (open --foreground, branch claude/observe-foreground): the initial + * interactive snapshot captured immediately after open, composed from the same + * snapshot-runtime dispatch `agent-device snapshot -i` uses. Present only when + * `--foreground` triggered the auto-resolve path. Left loosely typed for this + * prototype rather than reusing `CaptureSnapshotResult` end to end (see PR + * follow-ups) since the daemon-side composition does not attach client-only + * fields like `identifiers`. + */ + snapshot?: Record; identifiers: AgentDeviceIdentifiers; }; diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 84c4f75ae..9ccaeea49 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -340,6 +340,11 @@ function summarizeProviderScenarioFlagExclusions() { owner: 'handler and Android platform unit tests', keys: ['headless', 'testIme'], }, + { + name: 'open foreground auto-resolution (RFC prototype)', + owner: 'daemon session-open-foreground handler unit tests', + keys: ['foreground'], + }, { name: 'Apple simulator screenshot rendering options', owner: 'iOS platform and screenshot-diff runtime tests', diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index ebb1b85e7..31153872f 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -279,6 +279,11 @@ export function createAgentDeviceClient( startup: normalizeStartupSample(data.startup), runtime: normalizeRuntimeHints(data.runtime), device, + // RFC prototype (open --foreground): only present when the daemon's + // foreground-attach composition captured an initial snapshot. + ...(data.snapshot && typeof data.snapshot === 'object' + ? { snapshot: data.snapshot as Record } + : {}), identifiers: { session, deviceId: device?.id, diff --git a/src/commands/cli-grammar/flag-definitions-action.ts b/src/commands/cli-grammar/flag-definitions-action.ts index c688c6444..9669c1b66 100644 --- a/src/commands/cli-grammar/flag-definitions-action.ts +++ b/src/commands/cli-grammar/flag-definitions-action.ts @@ -273,6 +273,14 @@ export const ACTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--relaunch', usageDescription: 'open: terminate app process before launching it', }, + { + key: 'foreground', + names: ['--foreground'], + type: 'boolean', + usageLabel: '--foreground', + usageDescription: + "[RFC] open: resolve app + return an initial snapshot from the sole booted iOS simulator's sole running app", + }, { key: 'restart', names: ['--restart'], diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index dbc560374..deac67d33 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -50,6 +50,9 @@ const openCommandMetadata = defineFieldCommandMetadata( 'Launch arguments forwarded verbatim to the platform launch command.', ), relaunch: booleanField('Force relaunch.'), + foreground: booleanField( + "[RFC prototype] On a fresh session with no app argument, resolve the target from the sole booted iOS simulator's sole running app and include an initial interactive snapshot in the response. Fails with AMBIGUOUS_MATCH when the environment is not unambiguous (0 or 2+ booted simulators, or no confidently-detectable running app) — never guesses.", + ), saveScript: jsonSchemaField({ oneOf: [booleanSchema(), stringSchema()], }), @@ -130,6 +133,7 @@ const openCliSchema = { 'force', 'noRecord', 'relaunch', + 'foreground', 'surface', ...METRO_RELOAD_FLAGS, 'launchUrl', @@ -155,6 +159,7 @@ const openCliReader: CliReader = (positionals, flags) => ({ launchConsole: flags.launchConsole, launchArgs: flags.launchArgs, relaunch: flags.relaunch, + foreground: flags.foreground, saveScript: flags.saveScript, force: flags.force, deviceHub: flags.deviceHub, diff --git a/src/daemon/handlers/__tests__/session-open-foreground.test.ts b/src/daemon/handlers/__tests__/session-open-foreground.test.ts new file mode 100644 index 000000000..6219c02bd --- /dev/null +++ b/src/daemon/handlers/__tests__/session-open-foreground.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +const resolveSoleForegroundIosApp = vi.hoisted(() => vi.fn()); +const dispatchSnapshotViaRuntime = vi.hoisted(() => vi.fn()); + +vi.mock('../../ios-app-session-hint.ts', () => ({ resolveSoleForegroundIosApp })); +vi.mock('../../snapshot-runtime.ts', () => ({ dispatchSnapshotViaRuntime })); + +import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts'; +import type { DaemonRequest, DaemonResponse } from '../../types.ts'; +import { + composeOpenWithInitialSnapshot, + resolveForegroundOpenRequest, +} from '../session-open-foreground.ts'; + +function baseRequest(overrides: Partial = {}): DaemonRequest { + return { + token: 't', + session: 'default', + command: 'open', + positionals: [], + flags: {}, + ...overrides, + }; +} + +beforeEach(() => { + resolveSoleForegroundIosApp.mockReset(); + dispatchSnapshotViaRuntime.mockReset(); +}); + +// --- resolveForegroundOpenRequest --- + +test('leaves the request untouched when --foreground was not requested', async () => { + const resolution = await resolveForegroundOpenRequest({ + req: baseRequest(), + hasExistingSession: false, + }); + + expect(resolution).toEqual({ type: 'not-requested' }); + expect(resolveSoleForegroundIosApp).not.toHaveBeenCalled(); +}); + +test('rejects --foreground against an existing session', async () => { + const resolution = await resolveForegroundOpenRequest({ + req: baseRequest({ flags: { foreground: true } }), + hasExistingSession: true, + }); + + expect(resolution.type).toBe('response'); + if (resolution.type === 'response') { + expect(resolution.response.ok).toBe(false); + if (!resolution.response.ok) { + expect(resolution.response.error.code).toBe('INVALID_ARGS'); + } + } + expect(resolveSoleForegroundIosApp).not.toHaveBeenCalled(); +}); + +test('rejects --foreground combined with an explicit app argument', async () => { + const resolution = await resolveForegroundOpenRequest({ + req: baseRequest({ flags: { foreground: true }, positionals: ['com.example.demo'] }), + hasExistingSession: false, + }); + + expect(resolution.type).toBe('response'); + if (resolution.type === 'response') { + expect(resolution.response.ok).toBe(false); + if (!resolution.response.ok) { + expect(resolution.response.error.code).toBe('INVALID_ARGS'); + } + } + expect(resolveSoleForegroundIosApp).not.toHaveBeenCalled(); +}); + +test('an unambiguous environment rewrites positionals and pins the resolved device', async () => { + const soleBootedDevice = { ...IOS_SIMULATOR, id: 'booted-1', name: 'iPhone 16' }; + resolveSoleForegroundIosApp.mockResolvedValue({ + device: soleBootedDevice, + app: { bundleId: 'xyz.blueskyweb.app', name: 'Bluesky' }, + }); + + const resolution = await resolveForegroundOpenRequest({ + req: baseRequest({ flags: { foreground: true, iosSimulatorDeviceSet: '/custom/set' } }), + hasExistingSession: false, + }); + + expect(resolution).toEqual({ + type: 'resolved', + req: baseRequest({ + flags: { + foreground: true, + iosSimulatorDeviceSet: '/custom/set', + udid: 'booted-1', + platform: 'ios', + }, + positionals: ['xyz.blueskyweb.app'], + }), + }); + expect(resolveSoleForegroundIosApp).toHaveBeenCalledWith({ simulatorSetPath: '/custom/set' }); +}); + +test('an ambiguous environment fails closed with AMBIGUOUS_MATCH instead of guessing', async () => { + resolveSoleForegroundIosApp.mockResolvedValue(undefined); + + const resolution = await resolveForegroundOpenRequest({ + req: baseRequest({ flags: { foreground: true } }), + hasExistingSession: false, + }); + + expect(resolution.type).toBe('response'); + if (resolution.type === 'response') { + expect(resolution.response.ok).toBe(false); + if (!resolution.response.ok) { + expect(resolution.response.error.code).toBe('AMBIGUOUS_MATCH'); + expect(resolution.response.error.details?.reason).toBe('foreground_app_ambiguous'); + expect(resolution.response.error.details?.hint).toMatch(/agent-device open /); + } + } +}); + +test('a probe failure fails closed like ambiguity, never silently succeeds', async () => { + // The resolver owns the catch-all (see resolveSoleForegroundIosApp): a + // rejecting simctl/launchctl probe surfaces as `undefined`, which this + // handler must treat exactly like ambiguity — a hard AMBIGUOUS_MATCH, not + // a guessed target. + resolveSoleForegroundIosApp.mockResolvedValue(undefined); + + const resolution = await resolveForegroundOpenRequest({ + req: baseRequest({ flags: { foreground: true } }), + hasExistingSession: false, + }); + + expect(resolution.type).toBe('response'); + if (resolution.type === 'response') { + expect(resolution.response.ok).toBe(false); + if (!resolution.response.ok) { + expect(resolution.response.error.code).toBe('AMBIGUOUS_MATCH'); + } + } +}); + +// --- composeOpenWithInitialSnapshot --- + +const okOpenResponse: DaemonResponse = { ok: true, data: { session: 'default' } }; +const failedOpenResponse: DaemonResponse = { + ok: false, + error: { code: 'AMBIGUOUS_MATCH', message: 'nope' }, +}; + +test('passes a failed open response through untouched', async () => { + const result = await composeOpenWithInitialSnapshot({ + req: baseRequest({ flags: { foreground: true } }), + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore: {} as never, + openResponse: failedOpenResponse, + }); + + expect(result).toBe(failedOpenResponse); + expect(dispatchSnapshotViaRuntime).not.toHaveBeenCalled(); +}); + +test('leaves a successful open response untouched when --foreground was not requested', async () => { + const result = await composeOpenWithInitialSnapshot({ + req: baseRequest(), + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore: {} as never, + openResponse: okOpenResponse, + }); + + expect(result).toBe(okOpenResponse); + expect(dispatchSnapshotViaRuntime).not.toHaveBeenCalled(); +}); + +test('attaches the initial snapshot by delegating to the existing snapshot runtime dispatch', async () => { + dispatchSnapshotViaRuntime.mockResolvedValue({ ok: true, data: { nodes: [], truncated: false } }); + + const req = baseRequest({ flags: { foreground: true }, positionals: ['xyz.blueskyweb.app'] }); + const result = await composeOpenWithInitialSnapshot({ + req, + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore: {} as never, + openResponse: okOpenResponse, + }); + + expect(dispatchSnapshotViaRuntime).toHaveBeenCalledWith({ + req: { ...req, command: 'snapshot', positionals: [] }, + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore: {}, + }); + expect(result).toEqual({ + ok: true, + data: { session: 'default', snapshot: { nodes: [], truncated: false } }, + }); +}); + +test('surfaces a snapshot-capture failure instead of the open success', async () => { + const snapshotFailure: DaemonResponse = { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'capture failed' }, + }; + dispatchSnapshotViaRuntime.mockResolvedValue(snapshotFailure); + + const result = await composeOpenWithInitialSnapshot({ + req: baseRequest({ flags: { foreground: true } }), + sessionName: 'default', + logPath: '/tmp/daemon.log', + sessionStore: {} as never, + openResponse: okOpenResponse, + }); + + expect(result).toBe(snapshotFailure); +}); diff --git a/src/daemon/handlers/session-open-foreground.ts b/src/daemon/handlers/session-open-foreground.ts new file mode 100644 index 000000000..4e3a3a053 --- /dev/null +++ b/src/daemon/handlers/session-open-foreground.ts @@ -0,0 +1,118 @@ +import { resolveSoleForegroundIosApp } from '../ios-app-session-hint.ts'; +import { dispatchSnapshotViaRuntime } from '../snapshot-runtime.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import { errorResponse } from './response.ts'; + +export type ForegroundOpenResolution = + | { type: 'not-requested' } + | { type: 'resolved'; req: DaemonRequest } + | { type: 'response'; response: DaemonResponse }; + +/** + * RFC prototype (branch claude/observe-foreground): `open --foreground` collapses the + * `snapshot -i` (fails) -> read hint -> `open ` -> `snapshot -i` dance into one + * call for the common case (one booted iOS simulator, one running app). + * + * When `--foreground` is set with no explicit app argument on a fresh session, this + * auto-resolves the target via `resolveSoleForegroundIosApp` — the exact same + * ambiguity-detection probe that enriches the SESSION_NOT_FOUND hint — and rewrites the + * request's positionals/flags so the rest of `handleOpenCommand`'s existing new-session + * flow (device resolution, surface validation, session creation) runs completely + * unmodified against the resolved device + bundle id. Only iOS simulators are handled; + * see the PR body for scoped-out platforms. + * + * Fails closed with AMBIGUOUS_MATCH — never guesses — when the environment is not + * unambiguous: zero or multiple booted simulators, zero or multiple running apps, or a + * probe failure (the resolver owns the catch-all and reports failure as `undefined`, + * never a rejection). This mirrors `buildIosOpenCommandHint`'s no-guessing contract + * exactly, because it is built from the same underlying probe. + */ +export async function resolveForegroundOpenRequest(params: { + req: DaemonRequest; + hasExistingSession: boolean; +}): Promise { + const { req, hasExistingSession } = params; + if (req.flags?.foreground !== true) return { type: 'not-requested' }; + + if (hasExistingSession) { + return { + type: 'response', + response: errorResponse( + 'INVALID_ARGS', + 'open --foreground only applies to a fresh session. Close the current session first, or omit --foreground.', + ), + }; + } + if (req.positionals?.[0]) { + return { + type: 'response', + response: errorResponse( + 'INVALID_ARGS', + 'open --foreground cannot be combined with an explicit app argument.', + ), + }; + } + + const resolved = await resolveSoleForegroundIosApp({ + simulatorSetPath: req.flags?.iosSimulatorDeviceSet, + }); + if (!resolved) { + return { + type: 'response', + response: errorResponse( + 'AMBIGUOUS_MATCH', + 'open --foreground requires an unambiguous environment: exactly one booted iOS simulator with exactly one app running.', + { + reason: 'foreground_app_ambiguous', + hint: 'Pass an explicit app instead: agent-device open --platform ios.', + }, + ), + }; + } + + return { + type: 'resolved', + req: { + ...req, + positionals: [resolved.app.bundleId], + flags: { ...req.flags, udid: resolved.device.id, platform: 'ios' }, + }, + }; +} + +/** + * The other half of the `open --foreground` composition: once `handleOpenCommand` has + * created the session (with `session.appBundleId` now set), attach an initial + * interactive snapshot by delegating to the EXISTING `snapshot` runtime dispatch + * (`dispatchSnapshotViaRuntime`) — the same ref-issuing, session-store-updating path + * `agent-device snapshot -i` uses. This is deliberately composition, not a new capture + * pipeline: refs, snapshot lineage, and ref-frame activation all come from the one + * capture path already trusted for `snapshot`. + * + * A no-op when `--foreground` was not requested, or when open itself failed (nothing to + * attach a snapshot to). + */ +export async function composeOpenWithInitialSnapshot(params: { + req: DaemonRequest; + sessionName: string; + logPath: string; + sessionStore: SessionStore; + openResponse: DaemonResponse; +}): Promise { + const { req, sessionName, logPath, sessionStore, openResponse } = params; + if (!openResponse.ok || req.flags?.foreground !== true) return openResponse; + + const snapshotResponse = await dispatchSnapshotViaRuntime({ + req: { ...req, command: 'snapshot', positionals: [] }, + sessionName, + logPath, + sessionStore, + }); + if (!snapshotResponse.ok) return snapshotResponse; + + return { + ok: true, + data: { ...openResponse.data, snapshot: snapshotResponse.data }, + }; +} diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 0731daa9b..4c5070de4 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -54,6 +54,7 @@ import { validatePreResolvedOpenRequest, validateResolvedOpenRequest, } from './session-open-prepare.ts'; +import { resolveForegroundOpenRequest } from './session-open-foreground.ts'; import { errorResponse } from './response.ts'; import { expireRefFrame } from '../ref-frame.ts'; import { buildSessionRecoveryHint } from '../session-recovery-hints.ts'; @@ -652,9 +653,16 @@ export async function handleOpenCommand(params: { logPath: string; sessionStore: SessionStore; }): Promise { - const { req, sessionName, logPath, sessionStore } = params; + const { sessionName, logPath, sessionStore } = params; const session = sessionStore.get(sessionName); + const foregroundResolution = await resolveForegroundOpenRequest({ + req: params.req, + hasExistingSession: Boolean(session), + }); + if (foregroundResolution.type === 'response') return foregroundResolution.response; + const req = foregroundResolution.type === 'resolved' ? foregroundResolution.req : params.req; + if (session) { if (req.flags?.saveScript) { return errorResponse( diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index d9b73885e..4f7059e3b 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -21,6 +21,7 @@ import { errorResponse, requireCommandSupported } from './response.ts'; import { recordSessionAction } from './handler-utils.ts'; import { handleRuntimeCommand } from './session-runtime-command.ts'; import { handleOpenCommand } from './session-open.ts'; +import { composeOpenWithInitialSnapshot } from './session-open-foreground.ts'; import { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget, @@ -450,7 +451,13 @@ const SESSION_COMMAND_HANDLER_IMPLS = { push: handlePushCommand, 'trigger-app-event': handleTriggerAppEventCommand, open: async ({ req, sessionName, logPath, sessionStore }) => - await handleOpenCommand({ req, sessionName, logPath, sessionStore }), + await composeOpenWithInitialSnapshot({ + req, + sessionName, + logPath, + sessionStore, + openResponse: await handleOpenCommand({ req, sessionName, logPath, sessionStore }), + }), replay: handleSessionReplayCommandGroup, test: handleSessionReplayCommandGroup, batch: async ({ req, sessionName, invoke }) => await runBatchCommands(req, sessionName, invoke), diff --git a/src/daemon/ios-app-session-hint.test.ts b/src/daemon/ios-app-session-hint.test.ts index dae4f52b6..b5110c6e2 100644 --- a/src/daemon/ios-app-session-hint.test.ts +++ b/src/daemon/ios-app-session-hint.test.ts @@ -7,7 +7,7 @@ vi.mock('../platforms/apple/core/devices.ts', () => ({ listBootedIosSimulators } vi.mock('../platforms/apple/core/app-resolution.ts', () => ({ detectSoleRunningIosSimulatorApp })); import { IOS_DEVICE, IOS_SIMULATOR } from '../__tests__/test-utils/index.ts'; -import { buildIosOpenCommandHint } from './ios-app-session-hint.ts'; +import { buildIosOpenCommandHint, resolveSoleForegroundIosApp } from './ios-app-session-hint.ts'; beforeEach(() => { listBootedIosSimulators.mockReset(); @@ -122,3 +122,53 @@ test('a physical iOS device never probes for a hint', async () => { expect(hint).toBeUndefined(); expect(listBootedIosSimulators).not.toHaveBeenCalled(); }); + +test('resolveSoleForegroundIosApp resolves the device and app when unambiguous', async () => { + const soleBootedDevice = { ...IOS_SIMULATOR, id: 'booted-1', name: 'iPhone 16' }; + const app = { bundleId: 'xyz.blueskyweb.app', name: 'Bluesky' }; + listBootedIosSimulators.mockResolvedValue([soleBootedDevice]); + detectSoleRunningIosSimulatorApp.mockResolvedValue(app); + + const resolved = await resolveSoleForegroundIosApp({ simulatorSetPath: '/custom/set' }); + + expect(resolved).toEqual({ device: soleBootedDevice, app }); + expect(listBootedIosSimulators).toHaveBeenCalledWith({ simulatorSetPath: '/custom/set' }); + expect(detectSoleRunningIosSimulatorApp).toHaveBeenCalledWith(soleBootedDevice); +}); + +test('resolveSoleForegroundIosApp returns undefined for zero booted simulators', async () => { + listBootedIosSimulators.mockResolvedValue([]); + + expect(await resolveSoleForegroundIosApp()).toBeUndefined(); + expect(detectSoleRunningIosSimulatorApp).not.toHaveBeenCalled(); +}); + +test('resolveSoleForegroundIosApp returns undefined for more than one booted simulator', async () => { + listBootedIosSimulators.mockResolvedValue([ + { ...IOS_SIMULATOR, id: 'booted-1' }, + { ...IOS_SIMULATOR, id: 'booted-2' }, + ]); + + expect(await resolveSoleForegroundIosApp()).toBeUndefined(); + expect(detectSoleRunningIosSimulatorApp).not.toHaveBeenCalled(); +}); + +test('resolveSoleForegroundIosApp returns undefined when the running-app probe is inconclusive', async () => { + listBootedIosSimulators.mockResolvedValue([{ ...IOS_SIMULATOR, id: 'booted-1' }]); + detectSoleRunningIosSimulatorApp.mockResolvedValue(undefined); + + expect(await resolveSoleForegroundIosApp()).toBeUndefined(); +}); + +test('resolveSoleForegroundIosApp catches a rejecting probe instead of propagating it', async () => { + // The catch-all lives in the resolver, so both consumers (the hint and + // open --foreground) inherit the never-propagate contract from one place. + listBootedIosSimulators.mockRejectedValue(new Error('simctl timed out')); + + await expect(resolveSoleForegroundIosApp()).resolves.toBeUndefined(); + + listBootedIosSimulators.mockResolvedValue([{ ...IOS_SIMULATOR, id: 'booted-1' }]); + detectSoleRunningIosSimulatorApp.mockRejectedValue(new Error('launchctl timed out')); + + await expect(resolveSoleForegroundIosApp()).resolves.toBeUndefined(); +}); diff --git a/src/daemon/ios-app-session-hint.ts b/src/daemon/ios-app-session-hint.ts index 1a03ad20f..54b288cd6 100644 --- a/src/daemon/ios-app-session-hint.ts +++ b/src/daemon/ios-app-session-hint.ts @@ -1,22 +1,55 @@ import { isIosFamily, type DeviceInfo } from '@agent-device/kernel/device'; import { detectSoleRunningIosSimulatorApp } from '../platforms/apple/core/app-resolution.ts'; +import type { IosAppInfo } from '../platforms/apple/core/app-info.ts'; import { listBootedIosSimulators } from '../platforms/apple/core/devices.ts'; import { shellQuoteIfNeeded } from '../utils/shell-quote.ts'; +export type ResolvedForegroundIosApp = { + device: DeviceInfo; + app: IosAppInfo; +}; + /** - * Enriches the generic "Run open first" SESSION_NOT_FOUND hint with the exact - * runnable command, but only when the environment is unambiguous: exactly one - * booted iOS simulator with exactly one app running on it. + * The shared ambiguity-detection probe: exactly one booted iOS simulator with + * exactly one app running on it, or `undefined`. * * Any ambiguity — no booted simulator, more than one, no running app, more - * than one running app, or a probe failure — returns `undefined` so the - * caller keeps the generic hint. This must never guess: a wrong-but-confident - * command is worse than the default guidance. + * than one running app, or a probe failure — returns `undefined`. This must + * never guess: a wrong-but-confident answer is worse than failing closed. * - * The whole probe is strictly best-effort: it runs on an error path, so a - * throw here (a bounded probe still rejects on timeout or a spawn failure — - * see app-resolution.ts) must fall back to `undefined`, never replace the - * caller's deterministic error with a probe failure. + * Strictly best-effort: a throw here (a bounded probe still rejects on + * timeout or a spawn failure — see app-resolution.ts) is caught and treated + * as inconclusive, never propagated — callers sit on error/decision paths + * where a probe failure must not replace their deterministic outcome. + * + * `buildIosOpenCommandHint` (error-hint enrichment) and the `open --foreground` + * convenience (RFC, #observe-foreground) both compose this single probe + * instead of re-deriving the ambiguity rules. + */ +export async function resolveSoleForegroundIosApp( + options: { simulatorSetPath?: string } = {}, +): Promise { + try { + const booted = await listBootedIosSimulators({ simulatorSetPath: options.simulatorSetPath }); + if (booted.length !== 1) return undefined; + const [soleBootedDevice] = booted; + if (!soleBootedDevice) return undefined; + + const app = await detectSoleRunningIosSimulatorApp(soleBootedDevice); + if (!app) return undefined; + + return { device: soleBootedDevice, app }; + } catch { + return undefined; + } +} + +/** + * Enriches the generic "Run open first" SESSION_NOT_FOUND hint with the exact + * runnable command, but only when the environment is unambiguous (see + * `resolveSoleForegroundIosApp`, which also owns the never-guess and + * never-propagate contract). Ambiguity or a probe failure returns `undefined` + * so the caller keeps the generic hint. */ // Wire-level details are redacted before send (packages/kernel/src/redaction.ts), // which silently truncates any string field over 400 chars — a truncated @@ -29,23 +62,14 @@ const MAX_HINT_LENGTH = 350; export async function buildIosOpenCommandHint(device: DeviceInfo): Promise { if (!isIosFamily(device) || device.kind !== 'simulator') return undefined; - try { - const booted = await listBootedIosSimulators({ simulatorSetPath: device.simulatorSetPath }); - if (booted.length !== 1) return undefined; - const [soleBootedDevice] = booted; - if (!soleBootedDevice) return undefined; - - const app = await detectSoleRunningIosSimulatorApp(soleBootedDevice); - if (!app) return undefined; + const resolved = await resolveSoleForegroundIosApp({ simulatorSetPath: device.simulatorSetPath }); + if (!resolved) return undefined; - const command = buildOpenCommand(soleBootedDevice, app.bundleId); - const hint = - `One booted device found ("${soleBootedDevice.name}", udid ${soleBootedDevice.id}) with ` + - `${app.bundleId} running. Run: ${command}`; - return hint.length <= MAX_HINT_LENGTH ? hint : undefined; - } catch { - return undefined; - } + const command = buildOpenCommand(resolved.device, resolved.app.bundleId); + const hint = + `One booted device found ("${resolved.device.name}", udid ${resolved.device.id}) with ` + + `${resolved.app.bundleId} running. Run: ${command}`; + return hint.length <= MAX_HINT_LENGTH ? hint : undefined; } // Always pins --udid: the sole-booted-device check only proves there is one diff --git a/src/utils/result-serialization.ts b/src/utils/result-serialization.ts index 92abef77b..c2867aaf9 100644 --- a/src/utils/result-serialization.ts +++ b/src/utils/result-serialization.ts @@ -164,6 +164,7 @@ export function serializeOpenResult(result: AppOpenResult): Record