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
9 changes: 9 additions & 0 deletions packages/contracts/src/client-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { JsonObject } from './json.ts';
import type { SessionSurface } from './session-surface.ts';
import type { TargetShutdownResult } from './target-shutdown-contract.ts';
import type { DaemonInstallSource, SessionRuntimeHints } from '@agent-device/kernel/contracts';
import type { DaemonError } from '@agent-device/kernel/errors';
import type { PublicPlatform } from '@agent-device/kernel/device';
import type {
AgentDeviceIdentifiers,
Expand Down Expand Up @@ -84,6 +85,14 @@ export type AppOpenResult = {
* fields like `identifiers`.
*/
snapshot?: Record<string, unknown>;
/**
* open --foreground: present when the session opened successfully but the
* composed initial snapshot capture failed. Carries the FULL daemon error
* shape (code, message, hint, details, diagnosticId, logPath) so recovery
* guidance survives to the caller; the session itself is open and usable —
* a `warnings` entry says so and points at `snapshot -i`.
*/
initialSnapshotError?: DaemonError;
identifiers: AgentDeviceIdentifiers;
};

Expand Down
65 changes: 65 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,71 @@ test('apps.open resolves session device identifiers from open response', async (
]);
});

test('apps.open preserves the full initialSnapshotError shape through client normalization', async () => {
// open --foreground: open succeeded, composed snapshot did not. The public
// client result must carry the FULL daemon error — dropping the boundary
// normalization (or truncating to code+message) must fail here.
const initialSnapshotError = {
code: 'COMMAND_FAILED',
message: 'capture failed',
hint: 'Run: agent-device snapshot -i',
details: { reason: 'runner_capture_failed' },
diagnosticId: 'ms-diag-1234',
logPath: '/tmp/agent-device/sessions/qa/requests/snap.ndjson',
retriable: true,
};
const setup = createTransport(async (req) => {
if (req.command === 'open') {
return {
ok: true,
data: {
session: 'qa',
appName: 'Settings',
appBundleId: 'com.apple.Preferences',
platform: 'ios',
target: 'mobile',
device: 'iPhone 16',
id: 'SIM-001',
kind: 'simulator',
device_udid: 'SIM-001',
warnings: ['The session is open, but the initial interactive snapshot failed.'],
initialSnapshotError,
},
};
}
throw new Error(`Unexpected command: ${req.command}`);
});
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

const result = await client.apps.open({ app: 'Settings', platform: 'ios', foreground: true });

assert.deepEqual(result.initialSnapshotError, initialSnapshotError);
assert.equal(result.snapshot, undefined);
});

test('apps.open drops a malformed initialSnapshotError instead of projecting garbage', async () => {
const setup = createTransport(async () => ({
ok: true,
data: {
session: 'qa',
appName: 'Settings',
appBundleId: 'com.apple.Preferences',
platform: 'ios',
target: 'mobile',
device: 'iPhone 16',
id: 'SIM-001',
kind: 'simulator',
device_udid: 'SIM-001',
initialSnapshotError: { code: 'COMMAND_FAILED' },
},
}));
const client = createAgentDeviceClient(setup.config, { transport: setup.transport });

const result = await client.apps.open({ app: 'Settings', platform: 'ios' });

assert.equal(result.initialSnapshotError, undefined);
});

test('apps.open forwards explicit runtime hints through the daemon request', async () => {
const setup = createTransport(async () => ({
ok: true,
Expand Down
7 changes: 2 additions & 5 deletions src/agent-device-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
buildMeta,
normalizeDeployResult,
normalizeDevice,
normalizeOpenForegroundComposition,
normalizeInstallFromSourceResult,
normalizeMaterializationReleaseResult,
normalizeOpenDevice,
Expand Down Expand Up @@ -279,11 +280,7 @@ 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> }
: {}),
...normalizeOpenForegroundComposition(data),
identifiers: {
session,
deviceId: device?.id,
Expand Down
46 changes: 37 additions & 9 deletions src/client/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
isSerialAddressablePlatform,
type AppleOS,
} from '@agent-device/kernel/device';
import { AppError, type NormalizedError } from '@agent-device/kernel/errors';
import { AppError, type DaemonError } from '@agent-device/kernel/errors';
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import { leaseScopeFromOptions, leaseScopeToRequestMeta } from '../core/lease-scope.ts';
import type { DaemonRequest, SessionRuntimeHints } from '../daemon/types.ts';
Expand Down Expand Up @@ -253,7 +253,7 @@ export function normalizeTargetShutdownResult(value: unknown): TargetShutdownRes
) {
return undefined;
}
const error = normalizeTargetShutdownError(value.error);
const error = normalizeDaemonError(value.error);
return {
success: value.success,
exitCode: value.exitCode,
Expand All @@ -263,16 +263,44 @@ export function normalizeTargetShutdownResult(value: unknown): TargetShutdownRes
};
}

function normalizeTargetShutdownError(value: unknown): NormalizedError | undefined {
/**
* The one normalizer for daemon errors riding inside otherwise-ok results
* (target-shutdown reports, the open --foreground initial-snapshot failure).
* Preserves the FULL shape — hint/details/diagnosticId/logPath plus the
* additive retriable/supportedOn signals — never a code+message truncation,
* so recovery guidance survives to Node/CLI JSON callers.
*/
const DAEMON_ERROR_STRING_FIELDS = ['hint', 'diagnosticId', 'logPath', 'supportedOn'] as const;

function normalizeDaemonError(value: unknown): DaemonError | undefined {
if (!isRecord(value)) return undefined;
if (typeof value.code !== 'string' || typeof value.message !== 'string') return undefined;
const error: DaemonError = { code: value.code, message: value.message };
for (const field of DAEMON_ERROR_STRING_FIELDS) {
const candidate = value[field];
if (typeof candidate === 'string') error[field] = candidate;
}
if (isRecord(value.details)) error.details = value.details;
if (typeof value.retriable === 'boolean') error.retriable = value.retriable;
return error;
}

/**
* open --foreground composition extras on an ok open response: the initial
* snapshot when the foreground-attach capture succeeded, or the FULL capture
* error (never a code+message truncation) when open succeeded and the
* composed snapshot did not — the session is open and usable either way.
*/
export function normalizeOpenForegroundComposition(data: Record<string, unknown>): {
snapshot?: Record<string, unknown>;
initialSnapshotError?: DaemonError;
} {
const initialSnapshotError = normalizeDaemonError(data.initialSnapshotError);
return {
code: value.code,
message: value.message,
...(typeof value.hint === 'string' ? { hint: value.hint } : {}),
...(typeof value.diagnosticId === 'string' ? { diagnosticId: value.diagnosticId } : {}),
...(typeof value.logPath === 'string' ? { logPath: value.logPath } : {}),
...(isRecord(value.details) ? { details: value.details } : {}),
...(data.snapshot && typeof data.snapshot === 'object'
? { snapshot: data.snapshot as Record<string, unknown> }
: {}),
...(initialSnapshotError ? { initialSnapshotError } : {}),
};
}

Expand Down
80 changes: 80 additions & 0 deletions src/commands/management/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,86 @@ describe('openCliOutput', () => {
expect(output.data).toMatchObject({ warnings: [warning] });
expect(output.text).toBe(`Opened: authoring\nWarning: ${warning}`);
});

// #1671 P1: the composed open --foreground snapshot must render on default
// stdout through the PRODUCTION formatter route (the family-registry entry
// the CLI resolves, not a helper mock), printing the same interactive tree
// lines `snapshot -i` prints after the open confirmation.
test('production formatter route renders the composed --foreground snapshot tree on default output', async () => {
const { formatCliOutput } = await import('../cli-output.ts');
const { attachRefs } = await import('@agent-device/kernel/snapshot');
const nodes = attachRefs([
{ index: 0, type: 'Button', label: 'Sign In', depth: 0, hittable: true },
{ index: 1, type: 'TextField', label: 'Email', depth: 0, hittable: true },
]);
const openResult: AppOpenResult = {
session: 'default',
sessionStateDir: '/tmp/agent-device/sessions/cwd_123_default',
appBundleId: 'com.apple.Preferences',
snapshot: { nodes, truncated: false, backend: 'xctest', refsGeneration: 1 },
identifiers: { session: 'default' },
};

const openOutput = withNoColor(() =>
formatCliOutput({ name: 'open', input: {}, result: openResult }),
);
const snapshotOutput = withNoColor(() =>
formatCliOutput({
name: 'snapshot',
input: { interactiveOnly: true },
result: { nodes, truncated: false, backend: 'xctest', refsGeneration: 1 },
}),
);

if (!openOutput?.text || !snapshotOutput?.text) {
throw new Error('production formatter route returned no text output');
}
// The tree lines are exactly what `snapshot -i` would print, appended
// after the open confirmation lines.
expect(openOutput.text).toBe(
[
'Opened: com.apple.Preferences',
'Session state: /tmp/agent-device/sessions/cwd_123_default',
snapshotOutput.text,
].join('\n'),
);
expect(openOutput.text).toContain('Sign In');
expect(openOutput.text).toContain('Email');
// --json carries the same presented snapshot payload snapshot -i emits.
const openJsonSnapshot = (openOutput.data as { snapshot?: unknown }).snapshot;
expect(openJsonSnapshot).toEqual(snapshotOutput.jsonData ?? snapshotOutput.data);
});

test('renders the initialSnapshotError warning without a tree when the composed capture failed', () => {
const warning =
'The session is open, but the initial interactive snapshot failed (COMMAND_FAILED: capture failed). Run: agent-device snapshot -i';
const output = openCliOutput({
session: 'default',
warnings: [warning],
initialSnapshotError: {
code: 'COMMAND_FAILED',
message: 'capture failed',
hint: 'Retry with --debug and inspect diagnostics log for details.',
diagnosticId: 'diag-1',
logPath: '/tmp/req.ndjson',
details: { reason: 'runner_unavailable' },
},
identifiers: { session: 'default' },
});

expect(output.text).toBe(`Opened: default\nWarning: ${warning}`);
// The FULL error shape survives into public JSON output.
expect(output.data).toMatchObject({
initialSnapshotError: {
code: 'COMMAND_FAILED',
message: 'capture failed',
hint: 'Retry with --debug and inspect diagnostics log for details.',
diagnosticId: 'diag-1',
logPath: '/tmp/req.ndjson',
details: { reason: 'runner_unavailable' },
},
});
});
});

describe('artifactsCliOutput', () => {
Expand Down
24 changes: 23 additions & 1 deletion src/commands/management/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
serializeSessionListEntry,
} from '../../utils/result-serialization.ts';
import { readCommandMessage } from '../../utils/success-text.ts';
import { snapshotCliOutput } from '../capture/output.ts';
import type { CliOutput } from '../command-contract.ts';
import {
messageCliOutput,
Expand Down Expand Up @@ -101,7 +102,28 @@ export function openCliOutput(result: AppOpenResult): CliOutput {
for (const warning of result.warnings ?? []) {
lines.push(`Warning: ${warning}`);
}
return { data, text: lines.join('\n') || null };
// open --foreground: the composed initial snapshot renders through the SAME
// path `snapshot -i` uses (label dedupe + interactive tree text), after the
// open confirmation — the one-call promise holds on default stdout, not just
// --json. The composed capture is interactive-only by construction.
const snapshotOutput = buildOpenInitialSnapshotOutput(result.snapshot);
if (snapshotOutput) {
data.snapshot = snapshotOutput.jsonData ?? snapshotOutput.data;
if (snapshotOutput.text) lines.push(snapshotOutput.text);
}
return {
data,
...(snapshotOutput?.stderr ? { stderr: snapshotOutput.stderr } : {}),
text: lines.join('\n') || null,
};
}

function buildOpenInitialSnapshotOutput(snapshot: AppOpenResult['snapshot']): CliOutput | null {
if (!snapshot || !Array.isArray(snapshot.nodes)) return null;
return snapshotCliOutput({
result: snapshot as unknown as Parameters<typeof snapshotCliOutput>[0]['result'],
interactiveOnly: true,
});
}

function closeCliOutput(result: AppCloseResult | SessionCloseResult): CliOutput {
Expand Down
Loading
Loading