Skip to content

Commit 801c2ae

Browse files
authored
fix: improve rn agent-device stability guidance (#519)
1 parent 4666d58 commit 801c2ae

25 files changed

Lines changed: 517 additions & 98 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ If you install skills separately, keep the CLI on `agent-device >= 0.14.0`. Olde
5454
### AI Agent Entry Points
5555

5656
- **Agent + terminal**: in Cursor, Codex, Claude Code, Windsurf, and similar clients, run `agent-device` in the integrated terminal. Start planning with `agent-device help workflow`; CLI help is authoritative.
57-
- **Skills or rules**: install the skill with `npx skills add callstackincubator/agent-device`, use the bundled [agent-device skill](skills/agent-device/SKILL.md), or mirror it as a thin project rule, so the agent checks the installed version and reads `agent-device help workflow` before acting.
57+
- **Skills or rules**: install the skill with `npx skills add callstackincubator/agent-device`, use the bundled [agent-device skill](skills/agent-device/SKILL.md), or mirror it as a thin project rule, so the agent checks the installed version and reads `agent-device help workflow` before acting. Use `agent-device help react-native` for React Native apps, overlays, Metro/Fast Refresh blockers, and routing to React DevTools or debugging evidence.
5858
- **MCP router**: use `agent-device mcp` when an MCP-aware client needs install, status, and version-matched help discovery. MCP is intentionally a thin router; device automation still runs through CLI commands.
5959

6060
For client-specific setup, see [AI Agent Setup](https://incubator.callstack.com/agent-device/docs/agent-setup). For agent-readable docs, use [llms-full.txt](https://incubator.callstack.com/agent-device/llms-full.txt).

skills/agent-device/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Escalate only when relevant:
2525

2626
```bash
2727
agent-device help debugging
28+
agent-device help react-native
2829
agent-device help react-devtools
2930
agent-device help remote
3031
agent-device help macos

src/__tests__/client.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,7 @@ test('client capture.snapshot preserves visibility metadata from daemon response
506506
data: {
507507
nodes: [],
508508
truncated: false,
509-
appBundleId: 'com.expensify.chat.dev',
509+
appBundleId: 'com.agentdevice.tester',
510510
visibility: {
511511
partial: true,
512512
visibleNodeCount: 64,

src/__tests__/runtime-snapshot.test.ts

Lines changed: 117 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,14 @@ test('runtime diff snapshot initializes baseline when no previous snapshot exist
8686
});
8787

8888
test('runtime snapshot emits filtered Android guidance from backend analysis', async () => {
89-
const device = createAgentDevice({
90-
backend: createSnapshotBackend(() => ({
91-
nodes: [],
92-
truncated: false,
93-
backend: 'android',
94-
analysis: {
95-
rawNodeCount: 42,
96-
maxDepth: 6,
97-
},
98-
})),
99-
artifacts: createLocalArtifactAdapter(),
100-
sessions: {
101-
get: () => undefined,
102-
set: () => {},
89+
const device = createSnapshotOnlyDevice({
90+
nodes: [],
91+
truncated: false,
92+
backend: 'android',
93+
analysis: {
94+
rawNodeCount: 42,
95+
maxDepth: 6,
10396
},
104-
policy: localCommandPolicy(),
10597
});
10698

10799
const result = await device.capture.snapshot({
@@ -116,6 +108,99 @@ test('runtime snapshot emits filtered Android guidance from backend analysis', a
116108
]);
117109
});
118110

111+
test('runtime snapshot warns when Android hierarchy looks like a React Native overlay', async () => {
112+
const device = createSnapshotOnlyDevice({
113+
nodes: [
114+
{ ref: 'e1', index: 0, depth: 0, type: 'Text', label: 'LogBox' },
115+
{ ref: 'e2', index: 1, depth: 1, type: 'Text', label: 'Warnings' },
116+
{ ref: 'e3', index: 2, depth: 1, type: 'Button', label: 'Dismiss' },
117+
],
118+
truncated: false,
119+
backend: 'android',
120+
});
121+
122+
const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true });
123+
124+
assertReactNativeOverlayWarning(result.warnings);
125+
});
126+
127+
test('runtime snapshot warns on collapsed Android React Native warning banners', async () => {
128+
const device = createSnapshotOnlyDevice({
129+
nodes: [
130+
{
131+
ref: 'e1',
132+
index: 0,
133+
depth: 0,
134+
type: 'android.view.ViewGroup',
135+
label: '!, Open debugger to view warnings.',
136+
},
137+
{
138+
ref: 'e2',
139+
index: 1,
140+
depth: 1,
141+
type: 'android.widget.TextView',
142+
label: 'Open debugger to view warnings.',
143+
},
144+
],
145+
truncated: false,
146+
backend: 'android',
147+
});
148+
149+
const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true });
150+
151+
assertReactNativeOverlayWarning(result.warnings);
152+
});
153+
154+
test('runtime snapshot warns when iOS hierarchy looks like a React Native overlay', async () => {
155+
const device = createSnapshotOnlyDevice({
156+
nodes: [
157+
{
158+
ref: 'e1',
159+
index: 0,
160+
depth: 0,
161+
type: 'XCUIElementTypeOther',
162+
label: 'React Native RedBox',
163+
},
164+
{
165+
ref: 'e2',
166+
index: 1,
167+
depth: 1,
168+
type: 'XCUIElementTypeStaticText',
169+
value: 'Runtime Error',
170+
},
171+
{
172+
ref: 'e3',
173+
index: 2,
174+
depth: 1,
175+
type: 'XCUIElementTypeButton',
176+
identifier: 'Reload JS',
177+
},
178+
],
179+
truncated: false,
180+
backend: 'xctest',
181+
});
182+
183+
const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true });
184+
185+
assertReactNativeOverlayWarning(result.warnings);
186+
});
187+
188+
test('runtime snapshot does not warn for ordinary Android validation errors', async () => {
189+
const device = createSnapshotOnlyDevice({
190+
nodes: [
191+
{ ref: 'e1', index: 0, depth: 0, type: 'Text', label: 'Validation errors' },
192+
{ ref: 'e2', index: 1, depth: 1, type: 'Text', label: 'Required' },
193+
{ ref: 'e3', index: 2, depth: 1, type: 'Button', label: 'Submit order' },
194+
],
195+
truncated: false,
196+
backend: 'android',
197+
});
198+
199+
const result = await device.capture.snapshot({ session: 'default', interactiveOnly: true });
200+
201+
assert.equal(result.warnings, undefined);
202+
});
203+
119204
test('runtime snapshot stale-drop warning uses the runtime clock', async () => {
120205
const session = {
121206
name: 'default',
@@ -257,3 +342,20 @@ function createSnapshotBackend(
257342
captureSnapshot: async () => await captureSnapshot(),
258343
};
259344
}
345+
346+
function createSnapshotOnlyDevice(result: BackendSnapshotResult) {
347+
return createAgentDevice({
348+
backend: createSnapshotBackend(() => result),
349+
artifacts: createLocalArtifactAdapter(),
350+
sessions: {
351+
get: () => undefined,
352+
set: () => {},
353+
},
354+
policy: localCommandPolicy(),
355+
});
356+
}
357+
358+
function assertReactNativeOverlayWarning(warnings: string[] | undefined) {
359+
assert.equal(warnings?.length, 1);
360+
assert.match(warnings[0] ?? '', /Possible React Native warning\/error overlay/);
361+
}

src/commands/capture-snapshot.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,12 @@ function buildSnapshotWarnings(params: {
216216
}
217217
}
218218

219+
if (hasReactNativeOverlay(params.snapshot.nodes)) {
220+
warnings.push(
221+
'Possible React Native warning/error overlay detected. Capture screenshot --overlay-refs, check react-devtools errors if connected, dismiss Dismiss/Close only if unrelated, re-snapshot, and report it.',
222+
);
223+
}
224+
219225
const previousSnapshot = params.session?.snapshot;
220226
const isRecentSnapshot = previousSnapshot
221227
? [params.capturedAt, params.runtimeNow].some((timestamp) => {
@@ -254,3 +260,16 @@ function isLikelyStaleSnapshotDrop(previousCount: number, currentCount: number):
254260
if (previousCount < 12) return false;
255261
return currentCount <= Math.floor(previousCount * 0.2);
256262
}
263+
264+
function hasReactNativeOverlay(nodes: SnapshotNode[]): boolean {
265+
const text = nodes
266+
.map((node) =>
267+
[node.label, node.value, node.identifier, node.type, node.role].filter(Boolean).join(' '),
268+
)
269+
.join('\n')
270+
.toLowerCase();
271+
272+
return /\b(logbox|redbox|reload js|copy stack|component stack|call stack|runtime error|open debugger to view warnings)\b/.test(
273+
text,
274+
);
275+
}

src/core/__tests__/dispatch-type.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ANDROID_EMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'
66

77
test('dispatch type rejects ref-shaped first positional with a repair hint', async () => {
88
await assert.rejects(
9-
() => dispatchCommand(ANDROID_EMULATOR, 'type', ['@ref42', 'filed', 'the', 'expense']),
9+
() => dispatchCommand(ANDROID_EMULATOR, 'type', ['@ref42', 'sent', 'the', 'update']),
1010
(error: unknown) =>
1111
error instanceof AppError &&
1212
error.code === 'INVALID_ARGS' &&

src/daemon/__tests__/network-log.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ test('readRecentNetworkTraffic enriches Android GIBSDK URL lines with timing met
4848
[
4949
'03-31 17:43:32.564 V/GIBSDK (17434): [NetworkAgent]: packet id 23911610 added, queue size: 1',
5050
'03-31 17:43:33.031 D/GIBSDK (17434): [NetworkAgent] packet id 23911610 total elapsed request/response time, ms: 377; response code: 200;',
51-
'03-31 17:43:33.031 D/GIBSDK (17434): URL: https://www.expensify.com/api/fl?as=2.0.2816300925',
51+
'03-31 17:43:33.031 D/GIBSDK (17434): URL: https://api.example.com/v1/fixture?as=2.0.2816300925',
5252
'03-31 17:43:33.032 V/GIBSDK (17434): [NetworkAgent]: packet id 23911610 sent successfully, 0 left in queue',
5353
].join('\n'),
5454
'utf8',
@@ -64,7 +64,7 @@ test('readRecentNetworkTraffic enriches Android GIBSDK URL lines with timing met
6464

6565
assert.equal(dump.exists, true);
6666
assert.equal(dump.entries.length, 1);
67-
assert.equal(dump.entries[0]?.url, 'https://www.expensify.com/api/fl?as=2.0.2816300925');
67+
assert.equal(dump.entries[0]?.url, 'https://api.example.com/v1/fixture?as=2.0.2816300925');
6868
assert.equal(dump.entries[0]?.timestamp, '03-31 17:43:33.031');
6969
assert.equal(dump.entries[0]?.status, 200);
7070
assert.equal(dump.entries[0]?.durationMs, 377);
@@ -83,7 +83,7 @@ test('readRecentNetworkTraffic tolerates interleaved Android lines within the pa
8383
'03-31 17:43:32.900 V/OtherTag (17434): unrelated line 3',
8484
'03-31 17:43:33.000 V/OtherTag (17434): unrelated line 4',
8585
'03-31 17:43:33.031 D/GIBSDK (17434): [NetworkAgent] packet id 23911610 total elapsed request/response time, ms: 377; response code: 200;',
86-
'03-31 17:43:33.032 D/GIBSDK (17434): URL: https://www.expensify.com/api/fl?as=2.0.2816300925',
86+
'03-31 17:43:33.032 D/GIBSDK (17434): URL: https://api.example.com/v1/fixture?as=2.0.2816300925',
8787
].join('\n'),
8888
'utf8',
8989
);
@@ -109,7 +109,7 @@ test('readRecentNetworkTraffic keeps Android packet enrichment disabled for Appl
109109
logPath,
110110
[
111111
'2026-03-31 17:43:33.031 response code: 200',
112-
'2026-03-31 17:43:33.032 URL: https://www.expensify.com/api/fl?as=2.0.2816300925',
112+
'2026-03-31 17:43:33.032 URL: https://api.example.com/v1/fixture?as=2.0.2816300925',
113113
].join('\n'),
114114
'utf8',
115115
);
@@ -123,7 +123,7 @@ test('readRecentNetworkTraffic keeps Android packet enrichment disabled for Appl
123123
});
124124

125125
assert.equal(dump.entries.length, 1);
126-
assert.equal(dump.entries[0]?.url, 'https://www.expensify.com/api/fl?as=2.0.2816300925');
126+
assert.equal(dump.entries[0]?.url, 'https://api.example.com/v1/fixture?as=2.0.2816300925');
127127
assert.equal(dump.entries[0]?.timestamp, '2026-03-31 17:43:33.032');
128128
assert.equal(dump.entries[0]?.status, undefined);
129129
assert.equal(dump.entries[0]?.durationMs, undefined);
@@ -134,7 +134,7 @@ test('readRecentNetworkTraffic ignores plain documentation URLs in non-network l
134134
const logPath = path.join(tempDir, 'app.log');
135135
fs.writeFileSync(
136136
logPath,
137-
'2026-04-02 08:14:44.371 E New Expensify Dev[32193:8c7e18d] Airship config warning. See https://docs.airship.com/platform/mobile/setup/sdk/ios/#url-allow-list for more information.\n',
137+
'2026-04-02 08:14:44.371 E Agent Device Tester[32193:8c7e18d] Airship config warning. See https://docs.airship.com/platform/mobile/setup/sdk/ios/#url-allow-list for more information.\n',
138138
'utf8',
139139
);
140140

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ test('handleFindCommands wait bypasses snapshot cache while Android freshness re
315315
})
316316
.mockResolvedValueOnce({
317317
nodes: [
318-
{ index: 0, depth: 0, type: 'android.widget.TextView', label: 'Create expense' },
318+
{ index: 0, depth: 0, type: 'android.widget.TextView', label: 'Create document' },
319319
{ index: 1, depth: 0, type: 'android.widget.Button', label: 'Submit', hittable: true },
320320
],
321321
truncated: false,
@@ -324,7 +324,7 @@ test('handleFindCommands wait bypasses snapshot cache while Android freshness re
324324
});
325325

326326
const { response } = await runFindClickScenario({
327-
positionals: ['text', 'Create expense', 'wait', '700'],
327+
positionals: ['text', 'Create document', 'wait', '700'],
328328
session,
329329
});
330330

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1024,7 +1024,7 @@ test('press @ref fails when Android tap escapes to Settings', async () => {
10241024
const sessionStore = makeSessionStore();
10251025
const sessionName = 'android-settings-escape';
10261026
const session = makeAndroidSession(sessionName);
1027-
session.appBundleId = 'com.expensify.chat.dev';
1027+
session.appBundleId = 'com.agentdevice.tester';
10281028
session.snapshot = {
10291029
nodes: attachRefs([
10301030
{

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3743,6 +3743,8 @@ test('open on in-use device returns DEVICE_IN_USE before readiness checks', asyn
37433743
expect(response?.ok).toBe(false);
37443744
if (response && !response.ok) {
37453745
expect(response.error.code).toBe('DEVICE_IN_USE');
3746+
expect(response.error.details?.hint).toContain('agent-device session list');
3747+
expect(response.error.details?.hint).toContain('--session busy-session');
37463748
}
37473749
expect(mockEnsureDeviceReady).not.toHaveBeenCalled();
37483750
});
@@ -4733,7 +4735,7 @@ test('network dump recovers Android entries from adb logcat when the session str
47334735
return {
47344736
stdout:
47354737
'04-01 10:00:14.500 I/ActivityManager( 9999): Start proc 4321:com.example.app/u0a123 for top-activity\n' +
4736-
'04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/expenses status=200 duration=15032\n',
4738+
'04-01 10:00:15.000 D/GIBSDK (4321): POST https://api.example.com/v1/documents status=200 duration=15032\n',
47374739
stderr: '',
47384740
exitCode: 0,
47394741
};
@@ -4763,7 +4765,7 @@ test('network dump recovers Android entries from adb logcat when the session str
47634765
expect(entries.length).toBe(1);
47644766
const latest = entries[0] as Record<string, unknown>;
47654767
expect(latest.method).toBe('POST');
4766-
expect(latest.url).toBe('https://api.example.com/v1/expenses');
4768+
expect(latest.url).toBe('https://api.example.com/v1/documents');
47674769
expect(latest.status).toBe(200);
47684770
expect(response.data?.notes).toContain(
47694771
'Session app log stream was inactive. Recovered recent Android HTTP entries from adb logcat for PID set 4321.',
@@ -4996,7 +4998,7 @@ test('network dump recovers iOS simulator entries from simctl log show when the
49964998
fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
49974999
fs.writeFileSync(
49985000
appLogPath,
4999-
'Filtering the log data using "subsystem == \\"com.expensify.chat.dev\\""\n',
5001+
'Filtering the log data using "subsystem == \\"com.agentdevice.tester\\""\n',
50005002
'utf8',
50015003
);
50025004
sessionStore.set(sessionName, {
@@ -5007,7 +5009,7 @@ test('network dump recovers iOS simulator entries from simctl log show when the
50075009
kind: 'simulator',
50085010
booted: true,
50095011
}),
5010-
appBundleId: 'com.expensify.chat.dev',
5012+
appBundleId: 'com.agentdevice.tester',
50115013
appLog: {
50125014
platform: 'ios',
50135015
backend: 'ios-simulator',
@@ -5030,7 +5032,7 @@ test('network dump recovers iOS simulator entries from simctl log show when the
50305032
return {
50315033
stdout:
50325034
'Timestamp Ty Process[PID:TID]\n' +
5033-
'2026-04-02 08:08:50.665 I New Expensify Dev[32193:8c7411e] POST https://api.example.com/v1/search statusCode=200 duration=42\n',
5035+
'2026-04-02 08:08:50.665 I Agent Device Tester[32193:8c7411e] POST https://api.example.com/v1/search statusCode=200 duration=42\n',
50345036
stderr: '',
50355037
exitCode: 0,
50365038
};
@@ -5073,7 +5075,7 @@ test('network dump explains when iOS simulator recovery found app logs but no HT
50735075
fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
50745076
fs.writeFileSync(
50755077
appLogPath,
5076-
'Filtering the log data using "subsystem == \\"com.expensify.chat.dev\\""\n',
5078+
'Filtering the log data using "subsystem == \\"com.agentdevice.tester\\""\n',
50775079
'utf8',
50785080
);
50795081
sessionStore.set(sessionName, {
@@ -5084,7 +5086,7 @@ test('network dump explains when iOS simulator recovery found app logs but no HT
50845086
kind: 'simulator',
50855087
booted: true,
50865088
}),
5087-
appBundleId: 'com.expensify.chat.dev',
5089+
appBundleId: 'com.agentdevice.tester',
50885090
appLog: {
50895091
platform: 'ios',
50905092
backend: 'ios-simulator',
@@ -5107,7 +5109,7 @@ test('network dump explains when iOS simulator recovery found app logs but no HT
51075109
return {
51085110
stdout:
51095111
'Timestamp Ty Process[PID:TID]\n' +
5110-
'2026-04-02 08:08:50.665 E New Expensify Dev[32193:8c7411e] Airship config warning\n',
5112+
'2026-04-02 08:08:50.665 E Agent Device Tester[32193:8c7411e] Airship config warning\n',
51115113
stderr: '',
51125114
exitCode: 0,
51135115
};

0 commit comments

Comments
 (0)