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
6 changes: 6 additions & 0 deletions .changeset/get-tree-react-agent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rozenite/middleware': patch
'rozenite': patch
---

Add `getTree` to the React agent so the current tree can be fetched and paged from the live app.
1 change: 1 addition & 0 deletions packages/agent-sdk/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const STATIC_DOMAIN_TOOL_PREFIXES: Record<string, string> = {};
export const STATIC_DOMAIN_TOOL_NAMES: Record<string, string[]> = {
console: ['clearMessages', 'getMessages'],
react: [
'getTree',
'searchNodes',
'getNode',
'getChildren',
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/skills/rozenite-agent/domains/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ Search and traverse the React component tree, read props, state, and hooks for a
## Tools

- `searchNodes` -> `{"query":"<query>"}` | `{"query":"<query>","cursor":"<cursor>"}` | `{"query":"<query>","limit":20}`
- `getTree` -> `{}` | `{"depth":2}` | `{"root":123}` | `{"cursor":"<cursor>"}`
- `getNode` -> `{"nodeId":123}`
- `getChildren` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":"<cursor>"}` | `{"nodeId":123,"limit":20}`
- `getProps` -> `{"nodeId":123}` | `{"nodeId":123,"cursor":"<cursor>"}` | `{"nodeId":123,"limit":20}`
Expand All @@ -16,7 +17,7 @@ Search and traverse the React component tree, read props, state, and hooks for a
## Flow

Search and inspect:
`searchNodes` -> `getNode` / `getChildren` -> `getProps` / `getState` / `getHooks`.
`getTree` / `searchNodes` -> `getNode` / `getChildren` -> `getProps` / `getState` / `getHooks`.

Profile:
`startProfiling` -> reproduce interaction -> `stopProfiling` -> `getRenderData`.
29 changes: 29 additions & 0 deletions packages/middleware/src/agent/local-domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,33 @@ export const createReactDomainService = (deps: {
}) => void;
}): LocalAgentToolService => {
const tools: AgentTool[] = [
{
name: 'getTree',
description:
'Get the current React component tree with optional depth limiting and cursor-based pagination.',
inputSchema: {
type: 'object',
properties: {
root: {
type: 'integer',
description: 'Optional root node ID to scope the tree to a subtree.',
},
depth: {
type: 'integer',
description:
'Optional max descendant depth relative to the selected root. 0 returns only roots.',
},
limit: {
type: 'integer',
description: 'Page size. Default 20, max 100.',
},
cursor: {
type: 'string',
description: 'Opaque cursor returned by the previous page.',
},
},
},
},
{
name: 'getNode',
description: 'Get a single React node summary by ID.',
Expand Down Expand Up @@ -884,6 +911,8 @@ export const createReactDomainService = (deps: {
getTools: () => tools,
callTool: async (toolName, args) => {
switch (toolName) {
case 'getTree':
return store.getTree(sessionDeviceId, args);
case 'getNode':
return store.getNode(sessionDeviceId, args);
case 'getChildren':
Expand Down
147 changes: 147 additions & 0 deletions packages/middleware/src/agent/runtime/react/__tests__/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import { createReactTreeStore } from '../store.js';

const DEVICE_ID = 'device-1';

const createStoreWithTree = () => {
const store = createReactTreeStore();
store.registerDevice(DEVICE_ID);
store.syncTree(DEVICE_ID, {
roots: [1],
nodes: [
{
nodeId: 1,
displayName: 'Root',
elementType: 'root',
childIds: [2, 3],
},
{
nodeId: 2,
displayName: 'App',
elementType: 'function',
parentId: 1,
childIds: [4],
},
{
nodeId: 3,
displayName: 'Sidebar',
elementType: 'function',
parentId: 1,
childIds: [],
},
{
nodeId: 4,
displayName: 'Button',
elementType: 'host',
parentId: 2,
childIds: [],
},
],
});

return store;
};

describe('React tree store', () => {
it('returns the current React tree in traversal order', () => {
const store = createStoreWithTree();

expect(store.getTree(DEVICE_ID, {})).toMatchObject({
roots: [1],
totalCount: 4,
items: [
{
nodeId: 1,
displayName: 'Root',
elementType: 'root',
childIds: [2, 3],
childCount: 2,
depth: 0,
},
{
nodeId: 2,
displayName: 'App',
elementType: 'function',
parentId: 1,
childIds: [4],
childCount: 1,
depth: 1,
},
{
nodeId: 4,
displayName: 'Button',
elementType: 'host',
parentId: 2,
childIds: [],
childCount: 0,
depth: 2,
},
{
nodeId: 3,
displayName: 'Sidebar',
elementType: 'function',
parentId: 1,
childIds: [],
childCount: 0,
depth: 1,
},
],
page: {
limit: 20,
hasMore: false,
},
});
});

it('limits tree traversal by depth', () => {
const store = createStoreWithTree();

const result = store.getTree(DEVICE_ID, { depth: 1 });

expect(result.totalCount).toBe(3);
expect(result.items.map((item) => item.nodeId)).toEqual([1, 2, 3]);
expect(result.items.map((item) => item.depth)).toEqual([0, 1, 1]);
});

it('returns a subtree scoped to a root node', () => {
const store = createStoreWithTree();

const result = store.getTree(DEVICE_ID, { root: 2 });

expect(result.roots).toEqual([2]);
expect(result.totalCount).toBe(2);
expect(result.items.map((item) => item.nodeId)).toEqual([2, 4]);
expect(result.items.map((item) => item.depth)).toEqual([0, 1]);
});

it('paginates tree results with stable cursors', () => {
const store = createStoreWithTree();

const firstPage = store.getTree(DEVICE_ID, { limit: 2 });

expect(firstPage.items.map((item) => item.nodeId)).toEqual([1, 2]);
expect(firstPage.page.hasMore).toBe(true);
expect(firstPage.page.nextCursor).toEqual(expect.any(String));

const secondPage = store.getTree(DEVICE_ID, {
limit: 2,
cursor: firstPage.page.nextCursor,
});

expect(secondPage.items.map((item) => item.nodeId)).toEqual([4, 3]);
expect(secondPage.page.hasMore).toBe(false);
});

it('rejects cursors from a different tree request', () => {
const store = createStoreWithTree();
const firstPage = store.getTree(DEVICE_ID, { limit: 1 });

expect(() =>
store.getTree(DEVICE_ID, {
depth: 1,
cursor: firstPage.page.nextCursor,
}),
).toThrow('Cursor does not match this request context');
});
});

Loading
Loading