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
2 changes: 1 addition & 1 deletion docs/adr/0016-active-session-script-publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Status

Proposed
Accepted

## Context

Expand Down
1 change: 1 addition & 0 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,7 @@ function createStubClient(params: {
list: async () => [],
stateDir: async () => '/tmp/agent-device-state',
close: async () => ({ session: 'default', identifiers: { session: 'default' } }),
saveScript: unexpectedCommandCall,
artifacts: unexpectedCommandCall,
},
apps: {
Expand Down
4 changes: 4 additions & 0 deletions src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ test('apps.open resolves session device identifiers from open response', async (
kind: 'simulator',
device_udid: 'SIM-001',
ios_simulator_device_set: '/tmp/sim-set',
warnings: ['Script publication was aborted by a second successful open.', 42],
startup: {
durationMs: 1234,
measuredAt: '2026-03-13T10:00:00.000Z',
Expand Down Expand Up @@ -244,6 +245,9 @@ test('apps.open resolves session device identifiers from open response', async (
assert.equal(result.identifiers.appId, 'com.apple.Preferences');
assert.equal(result.device?.name, 'iPhone 16');
assert.equal(result.device?.ios?.simulatorSetPath, '/tmp/sim-set');
assert.deepEqual(result.warnings, [
'Script publication was aborted by a second successful open.',
]);
});

test('apps.open forwards explicit runtime hints through the daemon request', async () => {
Expand Down
30 changes: 30 additions & 0 deletions src/agent-device-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ import type {
SwipeGestureOptions,
PinchOptions,
RotateGestureOptions,
SessionSaveScriptOptions,
SessionSaveScriptResult,
TransformGestureOptions,
} from './client/client-types.ts';
import type { CommandResult } from './core/command-descriptor/command-result.ts';
Expand Down Expand Up @@ -195,6 +197,30 @@ export function createAgentDeviceClient(
clearMetroSessionHintsQuietly(config, options);
}
},
saveScript: async (
options: SessionSaveScriptOptions = {},
): Promise<SessionSaveScriptResult> => {
const data = await execute(
INTERNAL_COMMANDS.sessionSaveScript,
options.path !== undefined ? [options.path] : [],
options,
{ force: options.force },
);
const actionCount = data.actionCount;
if (typeof actionCount !== 'number' || !Number.isInteger(actionCount) || actionCount < 0) {
throw new AppError(
'COMMAND_FAILED',
'Daemon returned an invalid saved-script action count.',
);
}
const publishedSession = readRequiredString(data, 'session');
return {
session: publishedSession,
savedScript: readRequiredString(data, 'savedScript'),
actionCount,
identifiers: { session: publishedSession },
};
},
artifacts: async (options = {}) =>
await executeCommand<AgentArtifactsResult>('artifacts', options),
},
Expand Down Expand Up @@ -232,8 +258,12 @@ export function createAgentDeviceClient(
const device = normalizeOpenDevice(data);
const appBundleId = readOptionalString(data, 'appBundleId');
const appId = appBundleId;
const warnings = Array.isArray(data.warnings)
? data.warnings.filter((warning): warning is string => typeof warning === 'string')
: [];
return {
session,
...(warnings.length > 0 ? { warnings } : {}),
sessionStateDir: readOptionalString(data, 'sessionStateDir'),
eventLogPath: readOptionalString(data, 'eventLogPath'),
appName: readOptionalString(data, 'appName'),
Expand Down
2 changes: 1 addition & 1 deletion src/cli/parser/__tests__/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ test('usage renders concise commands inline with descriptions', async () => {
);
assert.match(
help,
/ session\s{2,}List active sessions or print the effective daemon state directory/,
/ session\s{2,}List active sessions, print the effective daemon state directory, or publish/,
);
assert.doesNotMatch(help, / metro prepare[^\n]*--project-root/);
assert.doesNotMatch(help, /\n batch\s{2,}Run multiple commands/);
Expand Down
9 changes: 9 additions & 0 deletions src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@ Bootstrap:
CI may cache ~/.agent-device/apple-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. Runner build/start output is written to the session's runner.log; daemon.log is for daemon lifecycle/startup issues.
Do not open artifact paths or invent package ids. If apps lookup misses the target and no URL/artifact is provided, ask or stop.

Reusable open-to-destination scripts:
Arm recording on the first open, perform the full journey, verify the ready destination with a selective selector-targeted wait, then publish without closing:
agent-device open com.example.app --relaunch --save-script=screen-x.ad
agent-device press 'id="continue"' --settle
agent-device wait 'role="heading" label="Screen X"'
agent-device session save-script
session save-script [path] [--force] publishes the sole recorded open through the destination guard, omits close, and leaves the session active. A duration wait, wait stable, or wait @ref is not a destination guard. A second successful open aborts publication; start a fresh session to author again.
Recorded fill/type inputs are written literally to the .ad file. Do not record passwords, tokens, or other secrets; use pre-authenticated test state or non-secret fixture credentials until parameterized input authoring is available.

Snapshots and refs:
snapshot reads visible state. snapshot -i gets current interactive refs only; it is the fast path when the next step is an interaction.
Default snapshot text is an agent-facing, token-efficient view for planning and targeting actions; use --raw or --json only when you need the full provider tree.
Expand Down
15 changes: 15 additions & 0 deletions src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,19 @@ export type SessionCloseResult = {
identifiers: AgentDeviceIdentifiers;
};

export type SessionSaveScriptOptions = AgentDeviceRequestOverrides & {
path?: string;
/** Atomically replace an existing target instead of refusing publication. */
force?: boolean;
};

export type SessionSaveScriptResult = {
session: string;
savedScript: string;
actionCount: number;
identifiers: AgentDeviceIdentifiers;
};

export type CloudArtifactsOptions = AgentDeviceRequestOverrides & {
provider?: string;
providerSessionId?: string;
Expand Down Expand Up @@ -308,6 +321,7 @@ export type AppOpenOptions = AgentDeviceRequestOverrides &

export type AppOpenResult = {
session: string;
warnings?: string[];
sessionStateDir?: string;
runnerLogPath?: string;
requestLogPath?: string;
Expand Down Expand Up @@ -1162,6 +1176,7 @@ export type AgentDeviceClient = {
force?: boolean;
},
) => Promise<SessionCloseResult>;
saveScript: (options?: SessionSaveScriptOptions) => Promise<SessionSaveScriptResult>;
artifacts: (options?: CloudArtifactsOptions) => Promise<AgentArtifactsResult>;
};
apps: {
Expand Down
13 changes: 13 additions & 0 deletions src/commands/management/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ describe('openCliOutput', () => {

expect(output.data).not.toHaveProperty('timing');
});

test('preserves open warnings in JSON data and renders them immediately', () => {
const warning =
'Script publication was aborted by a second successful open; start a fresh session.';
const output = openCliOutput({
session: 'authoring',
warnings: [warning],
identifiers: { session: 'authoring' },
});

expect(output.data).toMatchObject({ warnings: [warning] });
expect(output.text).toBe(`Opened: authoring\nWarning: ${warning}`);
});
});

describe('artifactsCliOutput', () => {
Expand Down
12 changes: 11 additions & 1 deletion src/commands/management/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
AppOpenResult,
CommandRequestResult,
SessionCloseResult,
SessionSaveScriptResult,
} from '../../client/client-types.ts';
import type {
AgentArtifactsResult,
Expand Down Expand Up @@ -76,8 +77,14 @@ function appsCliOutput(params: {
}

function sessionCliOutput(
result: { sessions: AgentDeviceSession[] } | { stateDir: string },
result: { sessions: AgentDeviceSession[] } | { stateDir: string } | SessionSaveScriptResult,
): CliOutput {
if ('savedScript' in result) {
return {
data: result,
text: `Published script: ${result.savedScript}\nSession remains active: ${result.session}\nActions: ${result.actionCount}`,
};
}
if ('stateDir' in result) {
return { data: result, text: result.stateDir };
}
Expand All @@ -91,6 +98,9 @@ export function openCliOutput(result: AppOpenResult): CliOutput {
if (typeof data.sessionStateDir === 'string') {
lines.push(`Session state: ${data.sessionStateDir}`);
}
for (const warning of result.warnings ?? []) {
lines.push(`Warning: ${warning}`);
}
return { data, text: lines.join('\n') || null };
}

Expand Down
108 changes: 108 additions & 0 deletions src/commands/management/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { expect, test } from 'vitest';
import { createAgentDeviceClient } from '../../agent-device-client.ts';
import { parseArgs } from '../../cli/parser/args.ts';
import { buildCommandUsageText } from '../../cli/parser/cli-help.ts';
import type { DaemonRequest, DaemonResponse } from '../../kernel/contracts.ts';
import type { CliFlags } from '../cli-grammar/flag-types.ts';
import { sessionCommandFacet } from './session.ts';

function flags(overrides: Partial<CliFlags> = {}): CliFlags {
return overrides as CliFlags;
}

test('session save-script reads path/force and invokes the typed client surface', async () => {
const calls: Array<Omit<DaemonRequest, 'token'>> = [];
const client = createAgentDeviceClient(
{ session: 'authoring' },
{
transport: async (req) => {
calls.push(req);
return {
ok: true,
data: { session: 'authoring', savedScript: '/tmp/screen-x.ad', actionCount: 3 },
} satisfies DaemonResponse;
},
},
);

const input = sessionCommandFacet.cliReader(
['save-script', '/tmp/screen-x.ad'],
flags({ force: true, session: 'authoring' }),
);
const result = await sessionCommandFacet.definition.invoke(client, input);

expect(calls).toEqual([
expect.objectContaining({
command: 'session_save_script',
session: 'authoring',
positionals: ['/tmp/screen-x.ad'],
flags: expect.objectContaining({ force: true }),
}),
]);
expect(result).toMatchObject({
session: 'authoring',
savedScript: '/tmp/screen-x.ad',
actionCount: 3,
});
});

test('strict CLI parsing accepts session save-script path and --force', () => {
const parsed = parseArgs(['session', 'save-script', './screen-x.ad', '--force'], {
strictFlags: true,
});
expect(parsed.positionals).toEqual(['save-script', './screen-x.ad']);
expect(parsed.flags.force).toBe(true);
});

test('typed save-script preserves an explicitly empty path for daemon validation', async () => {
const calls: Array<Omit<DaemonRequest, 'token'>> = [];
const client = createAgentDeviceClient(
{ session: 'authoring' },
{
transport: async (req) => {
calls.push(req);
return {
ok: true,
data: { session: 'authoring', savedScript: '/tmp/default.ad', actionCount: 1 },
} satisfies DaemonResponse;
},
},
);

await client.sessions.saveScript({ path: '' });

expect(calls[0]?.positionals).toEqual(['']);
});

test.each([
{ positionals: ['list', './unexpected.ad'], flags: {} },
{ positionals: ['state-dir'], flags: { force: true } },
])(
'rejects save-script-only options on session siblings',
async ({ positionals, flags: inputFlags }) => {
const calls: Array<Omit<DaemonRequest, 'token'>> = [];
const client = createAgentDeviceClient(
{},
{
transport: async (req) => {
calls.push(req);
return { ok: true, data: {} } satisfies DaemonResponse;
},
},
);
const input = sessionCommandFacet.cliReader(positionals, flags(inputFlags));

await expect(sessionCommandFacet.definition.invoke(client, input)).rejects.toThrow(
/does not accept a path or --force/,
);
expect(calls).toHaveLength(0);
},
);

test('session and workflow help expose active publication and literal-secret warning', () => {
expect(buildCommandUsageText('session')).toMatch(/session save-script \[path\] \[--force\]/);
const workflow = buildCommandUsageText('workflow');
expect(workflow).toMatch(/open-to-destination scripts/);
expect(workflow).toMatch(/session save-script/);
expect(workflow).toMatch(/Do not record passwords, tokens, or other secrets/);
});
51 changes: 39 additions & 12 deletions src/commands/management/session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AppError } from '../../kernel/errors.ts';
import type { CommandSchemaOverride } from '../../cli-schema/types.ts';
import { enumField } from '../command-input.ts';
import { booleanField, enumField, stringField } from '../command-input.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import { commonInputFromFlags } from '../cli-grammar/common.ts';
import type { CliReader } from '../cli-grammar/types.ts';
Expand All @@ -13,30 +13,43 @@ const sessionCommandMetadata = defineFieldCommandMetadata(
'List active sessions or print daemon state directory.',
{
action: enumField(
['list', 'state-dir'],
'list shows active sessions; state-dir prints the resolved daemon state directory without contacting the daemon.',
['list', 'state-dir', 'save-script'],
'list shows active sessions; state-dir prints the daemon state directory; save-script publishes an armed recording without teardown.',
),
path: stringField('Optional .ad output path for save-script.'),
force: booleanField('Atomically replace an existing save-script target.'),
},
);

const sessionCommandDefinition = defineExecutableCommand(
sessionCommandMetadata,
async (client, { action, ...input }) =>
action === 'state-dir'
? { stateDir: await client.sessions.stateDir(input) }
: { sessions: await client.sessions.list(input) },
async (client, { action, path, force, ...input }) => {
const effectiveAction = action ?? 'list';
assertSessionActionOptions(effectiveAction, path, force);
if (effectiveAction === 'state-dir') {
return { stateDir: await client.sessions.stateDir(input) };
}
if (effectiveAction === 'save-script') {
return await client.sessions.saveScript({ ...input, path, force });
}
return { sessions: await client.sessions.list(input) };
},
);

const sessionCliSchema = {
usageOverride: 'session list | session state-dir',
usageOverride: 'session list | session state-dir | session save-script [path] [--force]',
listUsageOverride: 'session',
helpDescription: 'List active sessions or print the effective daemon state directory',
positionalArgs: ['list|state-dir?'],
helpDescription:
'List active sessions, print the effective daemon state directory, or publish an armed open-to-destination script without closing its session',
positionalArgs: ['list|state-dir|save-script?', 'path?'],
allowedFlags: ['force'],
} as const satisfies CommandSchemaOverride;

const sessionCliReader: CliReader = (positionals, flags) => ({
...commonInputFromFlags(flags),
action: readSessionAction(positionals[0]),
path: positionals[1],
force: flags.force,
});

export const sessionCommandFacet = defineCommandFacet({
Expand All @@ -48,9 +61,23 @@ export const sessionCommandFacet = defineCommandFacet({
cliOutputFormatter: managementCliOutputFormatters.session,
});

function readSessionAction(value: string | undefined): 'list' | 'state-dir' {
function readSessionAction(value: string | undefined): 'list' | 'state-dir' | 'save-script' {
const action = value ?? 'list';
if (action === 'list') return action;
if (action === 'state-dir') return action;
throw new AppError('INVALID_ARGS', 'session only supports list or state-dir');
if (action === 'save-script') return action;
throw new AppError('INVALID_ARGS', 'session only supports list, state-dir, or save-script');
}

function assertSessionActionOptions(
action: 'list' | 'state-dir' | 'save-script',
path: string | undefined,
force: boolean | undefined,
): void {
if (action === 'save-script') return;
if (path === undefined && force !== true) return;
throw new AppError(
'INVALID_ARGS',
`session ${action} does not accept a path or --force; use session save-script [path] [--force] to publish a recording.`,
);
}
Loading
Loading