Skip to content

Commit 7b41dad

Browse files
authored
feat: add React getComponent agent tool (#306)
## What is this? This PR adds a `getComponent` tool to the built-in React agent domain. The tool gives agents one call for a component summary plus inspected props, state, and hooks, instead of requiring separate calls for each section. ## How does it work? The React tree store reuses the existing full inspection request path and returns the requested sections from the inspected snapshot. Callers can choose which sections to include and can bound nested serialization depth. The response includes node details, selected inspection data, and partial-result metadata when React DevTools does not provide every requested section. ## Why is this useful? Agents can inspect a component faster and with less orchestration. The existing paginated props, state, and hooks tools remain available for larger data, while `getComponent` covers the common case where a compact component-level snapshot is enough to decide the next debugging step.
1 parent ec6224f commit 7b41dad

7 files changed

Lines changed: 362 additions & 1 deletion

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rozenite/middleware': patch
3+
'rozenite': patch
4+
---
5+
6+
Add `getComponent` to the React agent so inspected component data can be fetched from a live session.

packages/agent-sdk/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export const STATIC_DOMAIN_TOOL_NAMES: Record<string, string[]> = {
4343
react: [
4444
'getTree',
4545
'searchNodes',
46+
'getComponent',
4647
'getNode',
4748
'getChildren',
4849
'getProps',

packages/cli/skills/rozenite-agent/domains/react.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Search and traverse the React component tree, read props, state, and hooks for a
44

55
- `searchNodes` -> `{"query":"<query>"}` | `{"query":"<query>","cursor":"<cursor>"}` | `{"query":"<query>","limit":20}`
66
- `getTree` -> `{}` | `{"depth":2}` | `{"root":123}` | `{"cursor":"<cursor>"}`
7+
- `getComponent` -> `{"id":123}` | `{"nodeId":123}` | `{"id":123,"include":["props"]}`
78
- `getNode` -> `{"nodeId":123}`
89
- `getChildren` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":"<cursor>"}` | `{"nodeId":123,"limit":20}`
910
- `getProps` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":"<cursor>"}` | `{"nodeId":123,"limit":20}`
@@ -17,7 +18,7 @@ Search and traverse the React component tree, read props, state, and hooks for a
1718
## Flow
1819

1920
Search and inspect:
20-
`getTree` / `searchNodes` -> `getNode` / `getChildren` -> `getProps` / `getState` / `getHooks`.
21+
`getTree` / `searchNodes` -> `getComponent` / `getNode` / `getChildren` -> `getProps` / `getState` / `getHooks`.
2122

2223
Profile:
2324
`startProfiling` -> reproduce interaction -> `stopProfiling` -> `getRenderData`.

packages/middleware/src/agent/local-domains.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,38 @@ export const createReactDomainService = (deps: {
681681
},
682682
},
683683
},
684+
{
685+
name: 'getComponent',
686+
description:
687+
'Get a React node summary plus inspected props, state, and hooks in one response.',
688+
inputSchema: {
689+
type: 'object',
690+
properties: {
691+
id: {
692+
...nodeIdentifierSchema,
693+
description: 'React DevTools node ID or component label.',
694+
},
695+
nodeId: {
696+
...nodeIdentifierSchema,
697+
description: 'React DevTools node ID or component label.',
698+
},
699+
include: {
700+
type: 'array',
701+
items: {
702+
type: 'string',
703+
enum: ['props', 'state', 'hooks'],
704+
},
705+
description:
706+
'Optional sections to include. Defaults to props, state, and hooks.',
707+
},
708+
valueDepth: {
709+
type: 'integer',
710+
description: 'Max nested serialization depth. Default 4, max 8.',
711+
},
712+
},
713+
anyOf: [{ required: ['id'] }, { required: ['nodeId'] }],
714+
},
715+
},
684716
{
685717
name: 'getNode',
686718
description: 'Get a single React node summary by node ID or label.',
@@ -941,6 +973,8 @@ export const createReactDomainService = (deps: {
941973
switch (toolName) {
942974
case 'getTree':
943975
return store.getTree(sessionDeviceId, args);
976+
case 'getComponent':
977+
return store.getComponent(sessionDeviceId, args);
944978
case 'getNode':
945979
return store.getNode(sessionDeviceId, args);
946980
case 'getChildren':

packages/middleware/src/agent/runtime/react/__tests__/store.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,42 @@ const createStoreWithBridgeStub = (
4747
});
4848
};
4949

50+
const createStoreWithComponent = () => {
51+
const sent: Array<{ event: string; payload: unknown }> = [];
52+
const store = createStoreWithBridgeStub(sent);
53+
store.registerDevice(DEVICE_ID, {
54+
sendMessage: () => undefined,
55+
});
56+
store.syncTree(DEVICE_ID, {
57+
roots: [1],
58+
nodes: [
59+
{
60+
nodeId: 1,
61+
displayName: 'Root',
62+
elementType: 'root',
63+
childIds: [2],
64+
},
65+
{
66+
nodeId: 2,
67+
displayName: 'App',
68+
elementType: 'function',
69+
parentId: 1,
70+
rendererId: 7,
71+
childIds: [3],
72+
},
73+
{
74+
nodeId: 3,
75+
displayName: 'Button',
76+
elementType: 'host',
77+
parentId: 2,
78+
childIds: [],
79+
},
80+
],
81+
});
82+
83+
return { store, sent };
84+
};
85+
5086
const createStoreWithTree = () => {
5187
const store = createReactTreeStore();
5288
store.registerDevice(DEVICE_ID);
@@ -322,3 +358,157 @@ describe('React tree store labels', () => {
322358
);
323359
});
324360
});
361+
362+
describe('React tree store getComponent', () => {
363+
it('returns a node summary with inspected props, state, and hooks', async () => {
364+
const { store, sent } = createStoreWithComponent();
365+
366+
const resultPromise = store.getComponent(DEVICE_ID, { id: 2 });
367+
await waitFor(() => sent.length > 0);
368+
369+
expect(sent.at(-1)).toEqual({
370+
event: 'inspectElement',
371+
payload: {
372+
forceFullData: true,
373+
id: 2,
374+
path: null,
375+
rendererID: 7,
376+
requestID: 1,
377+
},
378+
});
379+
380+
await store.ingestReactDevToolsMessage(DEVICE_ID, {
381+
event: 'inspectedElement',
382+
payload: {
383+
id: 2,
384+
type: 'full-data',
385+
value: {
386+
props: { title: 'Hello' },
387+
state: { count: 1 },
388+
hooks: [{ name: 'State', value: 'ready' }],
389+
},
390+
},
391+
});
392+
393+
await expect(resultPromise).resolves.toMatchObject({
394+
node: {
395+
nodeId: 2,
396+
label: '@c2',
397+
parentLabel: '@c1',
398+
displayName: 'App',
399+
elementType: 'function',
400+
childIds: [3],
401+
rendererId: 7,
402+
},
403+
props: { title: 'Hello' },
404+
state: { count: 1 },
405+
hooks: [{ name: 'State', value: 'ready' }],
406+
});
407+
});
408+
409+
it('returns only requested sections', async () => {
410+
const { store, sent } = createStoreWithComponent();
411+
412+
const resultPromise = store.getComponent(DEVICE_ID, {
413+
nodeId: 2,
414+
include: ['props'],
415+
});
416+
await waitFor(() => sent.length > 0);
417+
await store.ingestReactDevToolsMessage(DEVICE_ID, {
418+
event: 'inspectedElement',
419+
payload: {
420+
id: 2,
421+
type: 'full-data',
422+
value: {
423+
props: { title: 'Hello' },
424+
state: { count: 1 },
425+
hooks: [{ name: 'State', value: 'ready' }],
426+
},
427+
},
428+
});
429+
430+
await expect(resultPromise).resolves.toEqual(
431+
expect.not.objectContaining({
432+
state: expect.anything(),
433+
hooks: expect.anything(),
434+
}),
435+
);
436+
await expect(resultPromise).resolves.toMatchObject({
437+
props: { title: 'Hello' },
438+
});
439+
});
440+
441+
it('marks the response partial when requested sections are unavailable', async () => {
442+
const { store, sent } = createStoreWithComponent();
443+
444+
const resultPromise = store.getComponent(DEVICE_ID, { id: 2 });
445+
await waitFor(() => sent.length > 0);
446+
await store.ingestReactDevToolsMessage(DEVICE_ID, {
447+
event: 'inspectedElement',
448+
payload: {
449+
id: 2,
450+
type: 'full-data',
451+
value: {
452+
props: { title: 'Hello' },
453+
},
454+
},
455+
});
456+
457+
await expect(resultPromise).resolves.toMatchObject({
458+
props: { title: 'Hello' },
459+
partial: true,
460+
unavailable: ['state', 'hooks'],
461+
});
462+
});
463+
464+
it('bounds serialized nested values', async () => {
465+
const { store, sent } = createStoreWithComponent();
466+
467+
const resultPromise = store.getComponent(DEVICE_ID, {
468+
id: 2,
469+
include: ['props'],
470+
valueDepth: 1,
471+
});
472+
await waitFor(() => sent.length > 0);
473+
await store.ingestReactDevToolsMessage(DEVICE_ID, {
474+
event: 'inspectedElement',
475+
payload: {
476+
id: 2,
477+
type: 'full-data',
478+
value: {
479+
props: {
480+
nested: {
481+
value: 'hidden',
482+
},
483+
onPress: () => undefined,
484+
},
485+
},
486+
},
487+
});
488+
489+
await expect(resultPromise).resolves.toMatchObject({
490+
props: {
491+
nested: '[object]',
492+
onPress: '[function]',
493+
},
494+
});
495+
});
496+
497+
it('throws when React DevTools returns no inspected data', async () => {
498+
const { store, sent } = createStoreWithComponent();
499+
500+
const resultPromise = store.getComponent(DEVICE_ID, { id: 2 });
501+
await waitFor(() => sent.length > 0);
502+
await store.ingestReactDevToolsMessage(DEVICE_ID, {
503+
event: 'inspectedElement',
504+
payload: {
505+
id: 2,
506+
type: 'not-found',
507+
},
508+
});
509+
510+
await expect(resultPromise).rejects.toThrow(
511+
'No inspected snapshot available for node "2".',
512+
);
513+
});
514+
});

0 commit comments

Comments
 (0)