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
7 changes: 7 additions & 0 deletions packages/contracts/src/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, unknown>;
identifiers: AgentDeviceIdentifiers;
};

Expand Down
5 changes: 5 additions & 0 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions src/agent-device-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }
: {}),
identifiers: {
session,
deviceId: device?.id,
Expand Down
8 changes: 8 additions & 0 deletions src/commands/cli-grammar/flag-definitions-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
5 changes: 5 additions & 0 deletions src/commands/management/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | string>({
oneOf: [booleanSchema(), stringSchema()],
}),
Expand Down Expand Up @@ -130,6 +133,7 @@ const openCliSchema = {
'force',
'noRecord',
'relaunch',
'foreground',
'surface',
...METRO_RELOAD_FLAGS,
'launchUrl',
Expand All @@ -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,
Expand Down
217 changes: 217 additions & 0 deletions src/daemon/handlers/__tests__/session-open-foreground.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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 <app>/);
}
}
});

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);
});
Loading
Loading