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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ Sessions:
- `close` stops the session and releases device resources. Pass an app to close it explicitly, or omit to just close the session.
- Use `--session <name>` to manage multiple sessions.
- Session scripts are written to `~/.agent-device/sessions/<session>-<timestamp>.ad` when recording is enabled with `--save-script`.
- `--save-script` accepts an optional path: `--save-script ./workflows/my-flow.ad`.
- For ambiguous bare values, use an explicit form: `--save-script=workflow.ad` or a path-like value such as `./workflow.ad`.
- Deterministic replay is `.ad`-based; use `replay --update` (`-u`) to update selector drift and rewrite the replay file in place.

Navigation helpers:
Expand Down
5 changes: 4 additions & 1 deletion skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,16 @@ agent-device screenshot out.png

```bash
agent-device open App --relaunch # Fresh app process restart in the current session
agent-device open App --save-script # Save session script (.ad) on close
agent-device open App --save-script # Save session script (.ad) on close (default path)
agent-device open App --save-script ./workflows/app-flow.ad # Save to custom file path
agent-device replay ./session.ad # Run deterministic replay from .ad script
agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place
```

`replay` reads `.ad` recordings.
`--relaunch` controls launch semantics; `--save-script` controls recording. Combine only when both are needed.
`--save-script` path is a file path; parent directories are created automatically.
For ambiguous bare values, use `--save-script=workflow.ad` or `./workflow.ad`.

### Trace logs (AX/XCTest)

Expand Down
2 changes: 2 additions & 0 deletions skills/agent-device/references/session-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Sessions isolate device context. A device can only be held by one session at a t
- Use separate sessions for parallel work.
- In iOS sessions, use `open <app>` for simulator/device. `open <url>` is simulator-only.
- For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
- For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`.
- For deterministic replay scripts, prefer selector-based actions and assertions.
- Use `replay -u` to update selector drift during maintenance.

Expand Down
23 changes: 23 additions & 0 deletions src/daemon/__tests__/session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,29 @@ test('saveScript flag enables .ad session log writing', () => {
assert.equal(files.filter((file) => file.endsWith('.ad')).length, 1);
});

test('saveScript path writes session log to custom location', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-custom-path-'));
const store = new SessionStore(path.join(root, 'sessions'));
const session = makeSession('default');
const customPath = path.join(root, 'workflows', 'my-flow.ad');
store.recordAction(session, {
command: 'open',
positionals: ['Settings'],
flags: { platform: 'ios', saveScript: customPath },
result: {},
});
store.recordAction(session, {
command: 'close',
positionals: [],
flags: { platform: 'ios' },
result: {},
});

store.writeSessionLog(session);
assert.equal(fs.existsSync(customPath), true);
assert.equal(fs.existsSync(path.join(root, 'sessions')), false);
});

test('writeSessionLog persists open --relaunch in script output', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-relaunch-'));
const store = new SessionStore(root);
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ export async function handleSessionCommands(params: {
...session,
appBundleId,
appName: openTarget,
recordSession: session.recordSession || req.flags?.saveScript === true,
recordSession: session.recordSession || Boolean(req.flags?.saveScript),
snapshot: undefined,
};
sessionStore.recordAction(nextSession, {
Expand Down Expand Up @@ -405,7 +405,7 @@ export async function handleSessionCommands(params: {
createdAt: Date.now(),
appBundleId,
appName: openTarget,
recordSession: req.flags?.saveScript === true,
recordSession: Boolean(req.flags?.saveScript),
actions: [],
};
sessionStore.recordAction(session, {
Expand Down
20 changes: 16 additions & 4 deletions src/daemon/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ export class SessionStore {
if (entry.flags?.noRecord) return;
if (entry.flags?.saveScript) {
session.recordSession = true;
if (typeof entry.flags.saveScript === 'string') {
session.saveScriptPath = SessionStore.expandHome(entry.flags.saveScript);
}
}
session.actions.push({
ts: Date.now(),
Expand All @@ -62,10 +65,9 @@ export class SessionStore {
writeSessionLog(session: SessionState): void {
try {
if (!session.recordSession) return;
if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true });
const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
const scriptPath = path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`);
const scriptPath = this.resolveScriptPath(session);
const scriptDir = path.dirname(scriptPath);
if (!fs.existsSync(scriptDir)) fs.mkdirSync(scriptDir, { recursive: true });
const script = formatScript(session, this.buildOptimizedActions(session));
fs.writeFileSync(scriptPath, script);
} catch {
Expand All @@ -86,6 +88,16 @@ export class SessionStore {
return path.resolve(filePath);
}

private resolveScriptPath(session: SessionState): string {
if (session.saveScriptPath) {
return SessionStore.expandHome(session.saveScriptPath);
}
if (!fs.existsSync(this.sessionsDir)) fs.mkdirSync(this.sessionsDir, { recursive: true });
const safeName = session.name.replace(/[^a-zA-Z0-9._-]/g, '_');
const timestamp = new Date(session.createdAt).toISOString().replace(/[:.]/g, '-');
return path.join(this.sessionsDir, `${safeName}-${timestamp}.ad`);
}

private buildOptimizedActions(session: SessionState): SessionAction[] {
const optimized: SessionAction[] = [];
for (const action of session.actions) {
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type SessionState = {
startedAt: number;
};
recordSession?: boolean;
saveScriptPath?: string;
actions: SessionAction[];
recording?: {
platform: 'ios' | 'android';
Expand All @@ -48,7 +49,7 @@ export type SessionAction = {
snapshotScope?: string;
snapshotRaw?: boolean;
snapshotBackend?: 'ax' | 'xctest';
saveScript?: boolean;
saveScript?: boolean | string;
noRecord?: boolean;
};
result?: Record<string, unknown>;
Expand Down
28 changes: 28 additions & 0 deletions src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,33 @@ test('parseArgs recognizes --relaunch', () => {
assert.equal(parsed.flags.relaunch, true);
});

test('parseArgs accepts --save-script with optional path value', () => {
const withoutPath = parseArgs(['open', 'settings', '--save-script']);
assert.equal(withoutPath.command, 'open');
assert.deepEqual(withoutPath.positionals, ['settings']);
assert.equal(withoutPath.flags.saveScript, true);

const withPath = parseArgs(['open', 'settings', '--save-script', './workflows/my-flow.ad']);
assert.equal(withPath.command, 'open');
assert.deepEqual(withPath.positionals, ['settings']);
assert.equal(withPath.flags.saveScript, './workflows/my-flow.ad');

const nonPathPositional = parseArgs(['open', '--save-script', 'settings']);
assert.equal(nonPathPositional.command, 'open');
assert.deepEqual(nonPathPositional.positionals, ['settings']);
assert.equal(nonPathPositional.flags.saveScript, true);

const inlineValue = parseArgs(['open', 'settings', '--save-script=my-flow.ad']);
assert.equal(inlineValue.command, 'open');
assert.deepEqual(inlineValue.positionals, ['settings']);
assert.equal(inlineValue.flags.saveScript, 'my-flow.ad');

const ambiguousBareValue = parseArgs(['open', '--save-script', 'my-flow.ad']);
assert.equal(ambiguousBareValue.command, 'open');
assert.deepEqual(ambiguousBareValue.positionals, ['my-flow.ad']);
assert.equal(ambiguousBareValue.flags.saveScript, true);
});

test('parseArgs recognizes press series flags', () => {
const parsed = parseArgs([
'press',
Expand Down Expand Up @@ -64,6 +91,7 @@ test('parseArgs rejects invalid swipe pattern', () => {

test('usage includes --relaunch flag', () => {
assert.match(usage(), /--relaunch/);
assert.match(usage(), /--save-script \[path\]/);
assert.match(usage(), /pinch <scale> \[x\] \[y\]/);
assert.match(usage(), /--metadata/);
});
Expand Down
26 changes: 26 additions & 0 deletions src/utils/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,21 @@ function parseFlagValue(
}
return { value: true, consumeNext: false };
}
if (definition.type === 'booleanOrString') {
if (inlineValue !== undefined) {
if (inlineValue.trim().length === 0) {
throw new AppError('INVALID_ARGS', `Flag ${token} requires a non-empty value when provided.`);
}
return { value: inlineValue, consumeNext: false };
}
if (nextArg === undefined || looksLikeFlagToken(nextArg)) {
return { value: true, consumeNext: false };
}
if (shouldConsumeOptionalPathValue(nextArg)) {
return { value: nextArg, consumeNext: true };
}
return { value: true, consumeNext: false };
}

const value = inlineValue ?? nextArg;
if (value === undefined) {
Expand Down Expand Up @@ -163,6 +178,17 @@ function looksLikeFlagToken(value: string): boolean {
return getFlagDefinition(token) !== undefined;
}

function shouldConsumeOptionalPathValue(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return false;
if (trimmed.startsWith('./') || trimmed.startsWith('../') || trimmed.startsWith('~/') || trimmed.startsWith('/')) {
return true;
}
if (trimmed.includes('/') || trimmed.includes('\\')) return true;
return false;
}

function shouldTreatUnknownDashTokenAsPositional(
command: string | null,
positionals: string[],
Expand Down
10 changes: 5 additions & 5 deletions src/utils/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type CliFlags = {
pauseMs?: number;
pattern?: 'one-way' | 'ping-pong';
activity?: string;
saveScript?: boolean;
saveScript?: boolean | string;
relaunch?: boolean;
noRecord?: boolean;
replayUpdate?: boolean;
Expand All @@ -31,7 +31,7 @@ export type CliFlags = {
};

export type FlagKey = keyof CliFlags;
export type FlagType = 'boolean' | 'int' | 'enum' | 'string';
export type FlagType = 'boolean' | 'int' | 'enum' | 'string' | 'booleanOrString';

export type FlagDefinition = {
key: FlagKey;
Expand Down Expand Up @@ -201,9 +201,9 @@ export const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
{
key: 'saveScript',
names: ['--save-script'],
type: 'boolean',
usageLabel: '--save-script',
usageDescription: 'Save session script (.ad) on close',
type: 'booleanOrString',
usageLabel: '--save-script [path]',
usageDescription: 'Save session script (.ad) on close; optional custom output path',
},
{
key: 'relaunch',
Expand Down
2 changes: 2 additions & 0 deletions website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ agent-device find role button click
## Replay

```bash
agent-device open Settings --platform ios --session e2e --save-script [path]
agent-device replay ./session.ad # Run deterministic replay from .ad script
agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place
```

- `replay` runs deterministic `.ad` scripts.
- `replay -u` updates stale recorded actions and rewrites the same script.
- `--save-script` records a replay script on `close`; optional path is a file path and parent directories are created.

See [Replay & E2E (Experimental)](/docs/replay-e2e) for recording and CI workflow details.

Expand Down
12 changes: 11 additions & 1 deletion website/docs/docs/replay-e2e.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@ agent-device click @e13 --session e2e
agent-device close --session e2e
```

On `close`, a replay script is written to:
By default, on `close`, a replay script is written to:

```text
~/.agent-device/sessions/<session>-<timestamp>.ad
```

You can also provide a custom output file path:

```bash
agent-device open Settings --platform ios --session e2e --save-script ./workflows/e2e-settings.ad
```

- `--save-script` value is treated as a file path.
- Parent directories are created automatically when they do not exist.
- For ambiguous bare values, use `--save-script=workflow.ad` or a path-like value such as `./workflow.ad`.

## Run replay

```bash
Expand Down
Loading