Skip to content

Commit 0b5586f

Browse files
authored
Add open --relaunch and persist replay semantics (#52)
* Add open --relaunch and persist replay semantics * Update agent-device skill docs for open --relaunch * Disambiguate relaunch and save-script examples * Support non-hierarchical deep links in open target detection
1 parent 8cef2a6 commit 0b5586f

11 files changed

Lines changed: 292 additions & 26 deletions

File tree

skills/agent-device/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ agent-device boot # Ensure target is booted/ready without openin
4242
agent-device boot --platform ios # Boot iOS simulator
4343
agent-device boot --platform android # Boot Android emulator/device target
4444
agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
45+
agent-device open [app] --relaunch # Terminate app process first, then launch (fresh runtime)
4546
agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
4647
agent-device open "myapp://home" --platform android # Android deep link
4748
agent-device open "https://example.com" --platform ios # iOS simulator deep link
@@ -136,12 +137,14 @@ agent-device screenshot out.png
136137
### Deterministic replay and updating
137138

138139
```bash
140+
agent-device open App --relaunch # Fresh app process restart in the current session
139141
agent-device open App --save-script # Save session script (.ad) on close
140142
agent-device replay ./session.ad # Run deterministic replay from .ad script
141143
agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place
142144
```
143145

144146
`replay` reads `.ad` recordings.
147+
`--relaunch` controls launch semantics; `--save-script` controls recording. Combine only when both are needed.
145148

146149
### Trace logs (AX/XCTest)
147150

@@ -172,6 +175,7 @@ agent-device apps --platform android --user-installed
172175
- If XCTest returns 0 nodes (foreground app changed), agent-device falls back to AX when available.
173176
- `open <app|url>` can be used within an existing session to switch apps or open deep links.
174177
- `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
178+
- Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
175179
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
176180
- Use `--session <name>` for parallel sessions; avoid device contention.
177181
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.

skills/agent-device/references/session-management.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Sessions isolate device context. A device can only be held by one session at a t
1414
- Name sessions semantically.
1515
- Close sessions when done.
1616
- Use separate sessions for parallel work.
17+
- 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.
1718
- For deterministic replay scripts, prefer selector-based actions and assertions.
1819
- Use `replay -u` to update selector drift during maintenance.
1920

src/core/__tests__/open-target.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { isDeepLinkTarget } from '../open-target.ts';
55
test('isDeepLinkTarget accepts URL-style deep links', () => {
66
assert.equal(isDeepLinkTarget('myapp://home'), true);
77
assert.equal(isDeepLinkTarget('https://example.com'), true);
8+
assert.equal(isDeepLinkTarget('tel:123456789'), true);
9+
assert.equal(isDeepLinkTarget('mailto:test@example.com'), true);
810
});
911

1012
test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {

src/core/dispatch.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type CommandFlags = {
3434
snapshotRaw?: boolean;
3535
snapshotBackend?: 'ax' | 'xctest';
3636
saveScript?: boolean;
37+
relaunch?: boolean;
3738
noRecord?: boolean;
3839
appsFilter?: 'launchable' | 'user-installed' | 'all';
3940
appsMetadata?: boolean;

src/core/open-target.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
export function isDeepLinkTarget(input: string): boolean {
22
const value = input.trim();
33
if (!value) return false;
4-
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\/.+/.test(value);
4+
if (/\s/.test(value)) return false;
5+
const match = /^([A-Za-z][A-Za-z0-9+.-]*):(.+)$/.exec(value);
6+
if (!match) return false;
7+
const scheme = match[1]?.toLowerCase();
8+
const rest = match[2] ?? '';
9+
if (scheme === 'http' || scheme === 'https' || scheme === 'ws' || scheme === 'wss' || scheme === 'ftp' || scheme === 'ftps') {
10+
return rest.startsWith('//');
11+
}
12+
return true;
513
}

src/daemon/__tests__/session-store.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,27 @@ test('saveScript flag enables .ad session log writing', () => {
9393
const files = fs.readdirSync(root);
9494
assert.equal(files.filter((file) => file.endsWith('.ad')).length, 1);
9595
});
96+
97+
test('writeSessionLog persists open --relaunch in script output', () => {
98+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-relaunch-'));
99+
const store = new SessionStore(root);
100+
const session = makeSession('default');
101+
store.recordAction(session, {
102+
command: 'open',
103+
positionals: ['Settings'],
104+
flags: { platform: 'ios', saveScript: true, relaunch: true },
105+
result: {},
106+
});
107+
store.recordAction(session, {
108+
command: 'close',
109+
positionals: [],
110+
flags: { platform: 'ios' },
111+
result: {},
112+
});
113+
114+
store.writeSessionLog(session);
115+
const scriptFile = fs.readdirSync(root).find((file) => file.endsWith('.ad'));
116+
assert.ok(scriptFile);
117+
const script = fs.readFileSync(path.join(root, scriptFile!), 'utf8');
118+
assert.match(script, /open "Settings" --relaunch/);
119+
});

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,129 @@ test('open app on existing iOS session resolves and stores bundle id', async ()
212212
assert.equal(updated?.appName, 'settings');
213213
assert.equal(dispatchedContext?.appBundleId, 'com.apple.Preferences');
214214
});
215+
216+
test('open --relaunch closes and reopens active session app', async () => {
217+
const sessionStore = makeSessionStore();
218+
const sessionName = 'android-session';
219+
sessionStore.set(
220+
sessionName,
221+
{
222+
...makeSession(sessionName, {
223+
platform: 'android',
224+
id: 'emulator-5554',
225+
name: 'Pixel Emulator',
226+
kind: 'emulator',
227+
booted: true,
228+
}),
229+
appName: 'com.example.app',
230+
},
231+
);
232+
233+
const calls: Array<{ command: string; positionals: string[] }> = [];
234+
const response = await handleSessionCommands({
235+
req: {
236+
token: 't',
237+
session: sessionName,
238+
command: 'open',
239+
positionals: [],
240+
flags: { relaunch: true },
241+
},
242+
sessionName,
243+
logPath: path.join(os.tmpdir(), 'daemon.log'),
244+
sessionStore,
245+
invoke: noopInvoke,
246+
dispatch: async (_device, command, positionals) => {
247+
calls.push({ command, positionals });
248+
return {};
249+
},
250+
});
251+
252+
assert.ok(response);
253+
assert.equal(response?.ok, true);
254+
assert.equal(calls.length, 2);
255+
assert.deepEqual(calls[0], { command: 'close', positionals: ['com.example.app'] });
256+
assert.deepEqual(calls[1], { command: 'open', positionals: ['com.example.app'] });
257+
});
258+
259+
test('open --relaunch rejects URL targets', async () => {
260+
const sessionStore = makeSessionStore();
261+
const response = await handleSessionCommands({
262+
req: {
263+
token: 't',
264+
session: 'default',
265+
command: 'open',
266+
positionals: ['https://example.com/path'],
267+
flags: { relaunch: true },
268+
},
269+
sessionName: 'default',
270+
logPath: path.join(os.tmpdir(), 'daemon.log'),
271+
sessionStore,
272+
invoke: noopInvoke,
273+
});
274+
275+
assert.ok(response);
276+
assert.equal(response?.ok, false);
277+
if (response && !response.ok) {
278+
assert.equal(response.error.code, 'INVALID_ARGS');
279+
assert.match(response.error.message, /does not support URL targets/i);
280+
}
281+
});
282+
283+
test('open --relaunch fails without app when no session exists', async () => {
284+
const sessionStore = makeSessionStore();
285+
const response = await handleSessionCommands({
286+
req: {
287+
token: 't',
288+
session: 'default',
289+
command: 'open',
290+
positionals: [],
291+
flags: { relaunch: true },
292+
},
293+
sessionName: 'default',
294+
logPath: path.join(os.tmpdir(), 'daemon.log'),
295+
sessionStore,
296+
invoke: noopInvoke,
297+
});
298+
299+
assert.ok(response);
300+
assert.equal(response?.ok, false);
301+
if (response && !response.ok) {
302+
assert.equal(response.error.code, 'INVALID_ARGS');
303+
assert.match(response.error.message, /requires an app argument/i);
304+
}
305+
});
306+
307+
test('replay parses open --relaunch flag and replays open with relaunch semantics', async () => {
308+
const sessionStore = makeSessionStore();
309+
const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-relaunch-'));
310+
const replayPath = path.join(replayRoot, 'relaunch.ad');
311+
fs.writeFileSync(replayPath, 'open "Settings" --relaunch\n');
312+
313+
const invoked: DaemonRequest[] = [];
314+
const response = await handleSessionCommands({
315+
req: {
316+
token: 't',
317+
session: 'default',
318+
command: 'replay',
319+
positionals: [replayPath],
320+
flags: {},
321+
},
322+
sessionName: 'default',
323+
logPath: path.join(os.tmpdir(), 'daemon.log'),
324+
sessionStore,
325+
invoke: async (req) => {
326+
invoked.push(req);
327+
return { ok: true, data: {} };
328+
},
329+
});
330+
331+
assert.ok(response);
332+
assert.equal(response?.ok, true);
333+
if (response && response.ok) {
334+
assert.equal(response.data?.replayed, 1);
335+
}
336+
assert.equal(invoked.length, 1);
337+
assert.equal(invoked[0]?.command, 'open');
338+
assert.deepEqual(invoked[0]?.positionals, ['Settings']);
339+
assert.equal(invoked[0]?.flags?.relaunch, true);
340+
});

0 commit comments

Comments
 (0)