diff --git a/.changeset/columnar-agent-cli-output.md b/.changeset/columnar-agent-cli-output.md new file mode 100644 index 00000000..a1e559b1 --- /dev/null +++ b/.changeset/columnar-agent-cli-output.md @@ -0,0 +1,12 @@ +--- +"rozenite": major +"@rozenite/agent-bridge": minor +"@rozenite/agent-shared": minor +--- + +Change agent CLI row-shaped output to the stable columnar `cols` / `rows` +contract for two or more rows. Terminal pagination envelopes are removed, and +additional pages now provide a runnable `next` command instead of a bare +cursor. Paginated tools now declare their stable row fields through a reusable +shared contract, re-exported from `@rozenite/agent-bridge`, so built-in and +third-party plugins receive the same output behavior without CLI allowlists. diff --git a/packages/agent-bridge/src/index.ts b/packages/agent-bridge/src/index.ts index cb13b664..1c953191 100644 --- a/packages/agent-bridge/src/index.ts +++ b/packages/agent-bridge/src/index.ts @@ -2,6 +2,10 @@ export { useRozenitePluginAgentTool, useRozeniteInAppAgentTool, } from './useRozeniteAgentTool.js'; +export { + definePaginatedAgentToolContract, + isAgentToolPagination, +} from '@rozenite/agent-shared'; export type { UseRozeniteAgentToolOptions, UseRozenitePluginAgentToolOptions, @@ -10,6 +14,9 @@ export type { export type { JSONSchema7, AgentTool, + AgentToolPagination, + PageEnvelope, + PageResult, AgentMessage, AgentSessionReadyPayload, AgentSessionReadyMessage, diff --git a/packages/agent-bridge/src/types.ts b/packages/agent-bridge/src/types.ts index 49ac12ff..efdbf182 100644 --- a/packages/agent-bridge/src/types.ts +++ b/packages/agent-bridge/src/types.ts @@ -3,6 +3,9 @@ export { AGENT_PLUGIN_ID } from '@rozenite/agent-shared'; export type { JSONSchema7, AgentTool, + AgentToolPagination, + PageEnvelope, + PageResult, DevToolsPluginMessage, AgentSessionReadyPayload, RegisterToolPayload, diff --git a/packages/agent-bridge/src/useRozeniteAgentTool.test.tsx b/packages/agent-bridge/src/useRozeniteAgentTool.test.tsx index aa6f8091..15a88f51 100644 --- a/packages/agent-bridge/src/useRozeniteAgentTool.test.tsx +++ b/packages/agent-bridge/src/useRozeniteAgentTool.test.tsx @@ -12,6 +12,7 @@ import { vi, } from 'vitest'; import { defineAgentToolContract } from '@rozenite/agent-shared'; +import { definePaginatedAgentToolContract, type PageResult } from './index.js'; import type { AgentTool } from './types.js'; import { type UseRozeniteInAppAgentToolOptions, @@ -151,6 +152,28 @@ describe('useRozeniteAgentTool', () => { }>(); }); + it('exports paginated tool contracts from the bridge package', () => { + type Result = PageResult<{ id: string; label?: string }>; + + const tool = definePaginatedAgentToolContract< + Record, + Result + >({ + name: 'list', + description: 'List rows', + inputSchema: { type: 'object', properties: {} }, + pagination: { + kind: 'cursor', + fields: ['id', 'label'], + }, + }); + + expect(tool.pagination).toEqual({ + kind: 'cursor', + fields: ['id', 'label'], + }); + }); + it('registers initially after listeners are attached', async () => { const { root, container } = await renderTool(); diff --git a/packages/agent-sdk/src/__tests__/domain-utils.test.ts b/packages/agent-sdk/src/__tests__/domain-utils.test.ts index e21e6531..4df09332 100644 --- a/packages/agent-sdk/src/__tests__/domain-utils.test.ts +++ b/packages/agent-sdk/src/__tests__/domain-utils.test.ts @@ -108,9 +108,9 @@ describe('agent domain utils', () => { }); it('falls back to the scope when the reduced name is empty or generic residue', () => { - expect(deriveDomainName('@react-native-nitro-geolocation/rozenite-plugin')).toBe( - 'react-native-nitro-geolocation', - ); + expect( + deriveDomainName('@react-native-nitro-geolocation/rozenite-plugin'), + ).toBe('react-native-nitro-geolocation'); expect(deriveDomainName('@scope/plugin')).toBe('scope'); expect(deriveDomainName('@scope/devtools')).toBe('scope'); }); @@ -157,9 +157,9 @@ describe('agent domain utils', () => { }); it('errors instead of silently shadowing a built-in domain with an unscoped package literally named after it', () => { - expect(() => - buildRuntimePluginDomains([tool('console.list')]), - ).toThrow(/Plugin "console" derives the domain name "console"/); + expect(() => buildRuntimePluginDomains([tool('console.list')])).toThrow( + /Plugin "console" derives the domain name "console"/, + ); }); it('resolves plugin domains by id or pluginId token', () => { @@ -185,9 +185,9 @@ describe('agent domain utils', () => { expect(resolveDomainToken('rozenite/mmkv', domains)?.pluginId).toBe( '@rozenite/mmkv-plugin', ); - expect( - resolveDomainToken('@rozenite/mmkv-plugin', domains)?.pluginId, - ).toBe('@rozenite/mmkv-plugin'); + expect(resolveDomainToken('@rozenite/mmkv-plugin', domains)?.pluginId).toBe( + '@rozenite/mmkv-plugin', + ); }); it('still accepts the deprecated pre-#319 mangled slug as a compatibility alias', () => { @@ -211,7 +211,9 @@ describe('agent domain utils', () => { (domain) => domain.pluginId === '@rozenite/mmkv-plugin', ); - expect(appDomain?.description).toBe('Runtime tools exposed by the app itself.'); + expect(appDomain?.description).toBe( + 'Runtime tools exposed by the app itself.', + ); expect(pluginDomain?.description).toBe( 'Runtime tools exposed by plugin "@rozenite/mmkv-plugin".', ); @@ -227,17 +229,29 @@ describe('agent domain utils', () => { tool('app.echo'), ]); - expect(domains.some((domain) => domain.pluginId === 'getMessages')).toBe(false); + expect(domains.some((domain) => domain.pluginId === 'getMessages')).toBe( + false, + ); expect(domains.some((domain) => domain.pluginId === 'getNode')).toBe(false); - expect(domains.some((domain) => domain.pluginId === 'startTrace')).toBe(false); - expect(domains.some((domain) => domain.pluginId === 'takeHeapSnapshot')).toBe(false); - expect(domains.some((domain) => domain.pluginId === 'startRecording')).toBe(false); + expect(domains.some((domain) => domain.pluginId === 'startTrace')).toBe( + false, + ); + expect( + domains.some((domain) => domain.pluginId === 'takeHeapSnapshot'), + ).toBe(false); + expect(domains.some((domain) => domain.pluginId === 'startRecording')).toBe( + false, + ); expect(domains.some((domain) => domain.pluginId === 'app')).toBe(true); }); it('filters static domain tools by built-in tool names', () => { - const consoleDomain = STATIC_DOMAINS.find((domain) => domain.id === 'console'); - const networkDomain = STATIC_DOMAINS.find((domain) => domain.id === 'network'); + const consoleDomain = STATIC_DOMAINS.find( + (domain) => domain.id === 'console', + ); + const networkDomain = STATIC_DOMAINS.find( + (domain) => domain.id === 'network', + ); expect( getDomainToolsByDefinition( @@ -309,7 +323,9 @@ describe('agent domain utils', () => { expect( formatUnknownDomainError('netw', [ ...STATIC_DOMAINS, - ...buildRuntimePluginDomains([tool('@rozenite/mmkv-plugin.list-entries')]), + ...buildRuntimePluginDomains([ + tool('@rozenite/mmkv-plugin.list-entries'), + ]), ]).message, ).toBe( 'Unknown domain "netw". Did you mean: network? Run `rozenite agent domains` to list available domains.', @@ -317,17 +333,26 @@ describe('agent domain utils', () => { }); it('projects helper output for domain tool and schema views', () => { - const entry = tool('@rozenite/mmkv-plugin.list-entries'); + const entry: AgentTool = { + ...tool('@rozenite/mmkv-plugin.list-entries'), + pagination: { + kind: 'cursor', + fields: ['key', 'type'], + defaultFields: ['key'], + }, + }; - expect(toAgentDomainTool(entry)).toEqual({ + expect(toAgentDomainTool(entry, 'mmkv')).toEqual({ ...entry, shortName: 'list-entries', + domainId: 'mmkv', }); expect(toAgentToolSchema(entry)).toEqual({ name: '@rozenite/mmkv-plugin.list-entries', shortName: 'list-entries', inputSchema: entry.inputSchema, + pagination: entry.pagination, }); }); }); diff --git a/packages/agent-sdk/src/__tests__/domains.test.ts b/packages/agent-sdk/src/__tests__/domains.test.ts index a643db3a..25437f91 100644 --- a/packages/agent-sdk/src/__tests__/domains.test.ts +++ b/packages/agent-sdk/src/__tests__/domains.test.ts @@ -194,6 +194,7 @@ describe('agent session domain and tool helpers', () => { { name: '@rozenite/mmkv-plugin.list-entries', shortName: 'list-entries', + domainId: 'mmkv', description: 'List entries', inputSchema: { type: 'object', @@ -424,6 +425,93 @@ describe('agent session domain and tool helpers', () => { ]); }); + it('resolves a tool schema and call target with a single getSessionTools fetch', async () => { + const toolsFetches: string[] = []; + const calls: Array<{ toolName: string; args: unknown }> = []; + + httpTestHarness.requestHandler.mockImplementation( + async ({ + method, + pathname, + body, + }: MockHttpRequest): Promise => { + const sessionRoute = mockAttachedSessionRoute(pathname); + if (sessionRoute) { + return sessionRoute; + } + + if (pathname === getAgentSessionToolsRoute('session-1')) { + toolsFetches.push(pathname); + return { + payload: { + ok: true, + result: { + tools: [ + { + name: 'app.listRequests', + description: 'List requests', + inputSchema: { type: 'object' }, + pagination: { kind: 'cursor', fields: ['id'] }, + }, + ], + }, + }, + }; + } + + if ( + method === 'POST' && + pathname === getAgentSessionCallToolRoute('session-1') + ) { + calls.push(body as { toolName: string; args: unknown }); + return { + payload: { + ok: true, + result: { + result: { + items: [{ id: 1 }], + page: { limit: 1, hasMore: false }, + }, + }, + }, + }; + } + + return mockUnknownRoute(); + }, + ); + + const session = await attachSession(); + + const resolved = await session.tools.resolve({ + domain: 'app', + tool: 'listRequests', + }); + + expect(resolved.domainId).toBe('app'); + expect(resolved.schema).toEqual({ + name: 'app.listRequests', + shortName: 'listRequests', + inputSchema: { type: 'object' }, + pagination: { kind: 'cursor', fields: ['id'] }, + }); + + await expect(resolved.call({ limit: 1 })).resolves.toEqual({ + items: [{ id: 1 }], + page: { limit: 1, hasMore: false }, + }); + + // Exactly one getSessionTools fetch for both the schema lookup and the + // subsequent call, even though they resolve the same (domain, tool). + expect(toolsFetches).toEqual([getAgentSessionToolsRoute('session-1')]); + expect(calls).toEqual([ + { + toolName: 'app.listRequests', + args: { limit: 1 }, + }, + ]); + }); + it('merges paged tool results when auto-pagination is requested', async () => { const calls: Array<{ toolName: string; diff --git a/packages/agent-sdk/src/__tests__/paginated-tool-contract.test.ts b/packages/agent-sdk/src/__tests__/paginated-tool-contract.test.ts new file mode 100644 index 00000000..63068621 --- /dev/null +++ b/packages/agent-sdk/src/__tests__/paginated-tool-contract.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { + defineAgentToolDescriptors, + definePaginatedAgentToolContract, +} from '@rozenite/agent-shared'; + +type ListArgs = { + cursor?: string; +}; + +type ListResult = { + items: Array<{ id: string; label?: string }>; + page: { + limit: number; + hasMore: boolean; + nextCursor?: string; + }; +}; + +describe('paginated agent tool contracts', () => { + it('preserves pagination metadata in public descriptors', () => { + const list = definePaginatedAgentToolContract({ + name: 'list', + description: 'List rows', + inputSchema: { type: 'object', properties: {} }, + pagination: { + kind: 'cursor', + fields: ['id', 'label'], + defaultFields: ['id'], + }, + }); + + expect(defineAgentToolDescriptors('example', { list }).list).toMatchObject({ + domain: 'example', + name: 'list', + pagination: { + kind: 'cursor', + fields: ['id', 'label'], + defaultFields: ['id'], + }, + }); + }); + + it('rejects invalid runtime pagination metadata', () => { + expect(() => + definePaginatedAgentToolContract({ + name: 'list', + description: 'List rows', + inputSchema: { type: 'object', properties: {} }, + pagination: { + kind: 'cursor', + fields: ['id'], + defaultFields: ['label'], + }, + }), + ).toThrow('contain every default field'); + }); +}); diff --git a/packages/agent-sdk/src/client.ts b/packages/agent-sdk/src/client.ts index 4be2a867..e75966b9 100644 --- a/packages/agent-sdk/src/client.ts +++ b/packages/agent-sdk/src/client.ts @@ -20,6 +20,7 @@ import type { AgentClient, AgentClientOptions, AgentDynamicToolCallInput, + AgentResolvedTool, AgentSessionCallback, AgentSessionClient, AgentSessionTools, @@ -111,7 +112,9 @@ export const createAgentClient = ( )) as TResult; }; - const createSessionClient = (sessionInfo: AgentSessionInfo): AgentSessionClient => { + const createSessionClient = ( + sessionInfo: AgentSessionInfo, + ): AgentSessionClient => { let stopped = sessionInfo.status === 'stopped'; const listDomains = async () => { @@ -121,11 +124,13 @@ export const createAgentClient = ( const toolsApi: AgentSessionTools = { list: async ({ domain }) => { - const { domainTools } = await resolveDomainContext({ + const { resolvedDomain, domainTools } = await resolveDomainContext({ sessionId: sessionInfo.id, domain, }); - return domainTools.map(toAgentDomainTool); + return domainTools.map((tool) => + toAgentDomainTool(tool, resolvedDomain.id), + ); }, getSchema: async ({ domain, tool }) => { const { resolvedDomain, domainTools } = await resolveDomainContext({ @@ -136,6 +141,40 @@ export const createAgentClient = ( const selectedTool = resolveDomainTool(domainTools, domainLabel, tool); return toAgentToolSchema(selectedTool); }, + resolve: (async ({ + domain, + tool, + }: { + domain: string; + tool: string; + }): Promise => { + const { resolvedDomain, domainTools } = await resolveDomainContext({ + sessionId: sessionInfo.id, + domain, + }); + const domainLabel = resolvedDomain.pluginId ?? resolvedDomain.id; + const selectedTool = resolveDomainTool(domainTools, domainLabel, tool); + + return { + domainId: resolvedDomain.id, + schema: toAgentToolSchema(selectedTool), + call: async (args?: unknown, options?: AgentToolCallOptions) => + await callToolWithOptionalPagination( + { + callTool: async (name, payload) => + ( + await transport.callSessionTool(sessionInfo.id, { + toolName: name, + args: payload, + }) + ).result, + }, + selectedTool.name, + args ?? {}, + toAutoPaginationConfig(options?.autoPaginate), + ), + }; + }) as AgentSessionTools['resolve'], call: (async ( descriptorOrInput: | AgentToolDescriptor diff --git a/packages/agent-sdk/src/domain-utils.ts b/packages/agent-sdk/src/domain-utils.ts index f40b4aad..0617ad61 100644 --- a/packages/agent-sdk/src/domain-utils.ts +++ b/packages/agent-sdk/src/domain-utils.ts @@ -342,13 +342,18 @@ export const resolveDomainTool = ( return selectedTool; }; -export const toAgentDomainTool = (tool: AgentTool): AgentDomainTool => ({ +export const toAgentDomainTool = ( + tool: AgentTool, + domainId: string, +): AgentDomainTool => ({ ...tool, shortName: inferToolShortName(tool.name), + domainId, }); export const toAgentToolSchema = (tool: AgentTool): AgentToolSchema => ({ name: tool.name, shortName: inferToolShortName(tool.name), inputSchema: tool.inputSchema, + ...(tool.pagination ? { pagination: tool.pagination } : {}), }); diff --git a/packages/agent-sdk/src/index.ts b/packages/agent-sdk/src/index.ts index 4dcde2b9..0b6a4221 100644 --- a/packages/agent-sdk/src/index.ts +++ b/packages/agent-sdk/src/index.ts @@ -5,6 +5,7 @@ export type { AgentClient, AgentClientOptions, AgentDomainTool, + AgentResolvedTool, AgentSessionCallback, AgentSessionClient, AgentSessionDomains, diff --git a/packages/agent-sdk/src/types.ts b/packages/agent-sdk/src/types.ts index 7839b50d..ee3fed40 100644 --- a/packages/agent-sdk/src/types.ts +++ b/packages/agent-sdk/src/types.ts @@ -1,6 +1,7 @@ import type { AgentSessionInfo, AgentTool, + AgentToolPagination, AgentToolDescriptor, CallAgentSessionToolRequest, CallAgentSessionToolResponse, @@ -29,12 +30,15 @@ export interface DomainDefinition { export interface AgentDomainTool extends AgentTool { shortName: string; + /** Canonical domain id the requested domain token resolved to. */ + domainId: string; } export interface AgentToolSchema { name: string; shortName: string; inputSchema: JSONSchema7; + pagination?: AgentToolPagination; } export interface AgentClientOptions { @@ -60,23 +64,39 @@ export interface AgentToolCallOptions { type AgentDescriptorCallTuple< TDescriptor extends AgentToolDescriptor, -> = - [InferAgentToolArgs] extends [undefined] +> = [InferAgentToolArgs] extends [undefined] + ? [args?: InferAgentToolArgs, options?: AgentToolCallOptions] + : Record extends InferAgentToolArgs ? [args?: InferAgentToolArgs, options?: AgentToolCallOptions] - : Record extends InferAgentToolArgs - ? [args?: InferAgentToolArgs, options?: AgentToolCallOptions] - : [args: InferAgentToolArgs, options?: AgentToolCallOptions]; + : [args: InferAgentToolArgs, options?: AgentToolCallOptions]; export interface AgentSessionDomains { list: () => Promise; } +export interface AgentResolvedTool { + /** Canonical domain id the requested domain token resolved to. */ + domainId: string; + schema: AgentToolSchema; + call: (args?: TArgs, options?: AgentToolCallOptions) => Promise; +} + export interface AgentSessionTools { list: (input: { domain: string }) => Promise; getSchema: (input: { domain: string; tool: string; }) => Promise; + /** + * Resolves a domain token and tool name to its schema and a bound `call` + * once, so callers that need both the schema (e.g. to inspect pagination + * metadata) and the ability to invoke the tool don't pay for a second + * `getSessionTools` round trip. + */ + resolve: (input: { + domain: string; + tool: string; + }) => Promise>; call: { ( input: AgentDynamicToolCallInput, @@ -111,9 +131,7 @@ export interface AgentTransport { listSessions: () => Promise; getSession: (sessionId: string) => Promise; stopSession: (sessionId: string) => Promise; - getSessionTools: ( - sessionId: string, - ) => Promise; + getSessionTools: (sessionId: string) => Promise; callSessionTool: ( sessionId: string, body: CallAgentSessionToolRequest, @@ -131,6 +149,8 @@ export interface AgentClient { callback: AgentSessionCallback, ): Promise; }; - openSession: (input?: CreateAgentSessionRequest) => Promise; + openSession: ( + input?: CreateAgentSessionRequest, + ) => Promise; attachSession: (sessionId: string) => Promise; } diff --git a/packages/agent-shared/README.md b/packages/agent-shared/README.md index 5d406fb3..c7aa922a 100644 --- a/packages/agent-shared/README.md +++ b/packages/agent-shared/README.md @@ -9,6 +9,8 @@ ## Features - **Shared Tool Types**: Common `AgentTool` and JSON-schema-like input types +- **Paginated Tool Contracts**: Typed row fields and a shared page envelope for built-in and third-party tools +- **Row Shaping**: Frontend-neutral columnar/row-keyed encoding (`shapePaginatedRows`, `shapeToolResult`) shared by the CLI and any other Agent frontend - **Message Contracts**: Typed payloads for register, unregister, call, and result messages - **Single Protocol Constant**: Shared `AGENT_PLUGIN_ID` for the Agent transport - **Package Reuse**: Intended for bridge and runtime packages that implement Agent support @@ -27,6 +29,17 @@ This package exports: - `AGENT_PLUGIN_ID` - `AgentTool` +- `AgentToolPagination` +- `PageEnvelope` +- `PageResult` +- `definePaginatedAgentToolContract` +- `DEFAULT_PAGE_LIMIT` +- `MAX_PAGE_LIMIT` +- `parseFields` +- `parseLimit` +- `projectRows` +- `shapePaginatedRows` +- `shapeToolResult` - `JSONSchema7` - `DevToolsPluginMessage` - `RegisterToolPayload` diff --git a/packages/agent-shared/package.json b/packages/agent-shared/package.json index 96386500..fb2780db 100644 --- a/packages/agent-shared/package.json +++ b/packages/agent-shared/package.json @@ -30,6 +30,7 @@ "scripts": { "build": "vite build", "typecheck": "tsc -p tsconfig.lib.json --noEmit", + "test": "vitest --run --passWithNoTests", "clean": "rm -rf dist" }, "exports": { diff --git a/packages/agent-shared/src/__tests__/output-shaping.test.ts b/packages/agent-shared/src/__tests__/output-shaping.test.ts new file mode 100644 index 00000000..8db3b46c --- /dev/null +++ b/packages/agent-shared/src/__tests__/output-shaping.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from 'vitest'; +import { + parseFields, + parseLimit, + projectRows, + shapePaginatedRows, + shapeToolResult, +} from '../output-shaping.js'; + +describe('agent output shaping', () => { + it('uses default fields when none provided', () => { + const fields = parseFields( + undefined, + ['name', 'shortName', 'description'] as const, + ['name', 'shortName'] as const, + false, + ); + expect(fields).toEqual(['name', 'shortName']); + }); + + it('parses valid fields and preserves order', () => { + const fields = parseFields( + 'description,name', + ['name', 'shortName', 'description'] as const, + ['name', 'shortName'] as const, + false, + ); + expect(fields).toEqual(['description', 'name']); + }); + + it('throws on invalid fields', () => { + expect(() => + parseFields( + 'name,badField', + ['name', 'shortName', 'description'] as const, + ['name', 'shortName'] as const, + false, + ), + ).toThrow(/Unknown fields/); + }); + + it('defaults to a narrower projection than the full field set for a migrated tool', () => { + // Mirrors console getMessages: defaultFields drops the heavy + // argsPreview/context columns from the full declared field set. + const allFields = [ + 'seq', + 'timestamp', + 'level', + 'source', + 'text', + 'argsPreview', + 'context', + ] as const; + const defaultFields = [ + 'seq', + 'timestamp', + 'level', + 'source', + 'text', + ] as const; + + const fields = parseFields(undefined, allFields, defaultFields, false); + + expect(fields).toEqual([...defaultFields]); + expect(fields.length).toBeLessThan(allFields.length); + expect(fields).not.toContain('argsPreview'); + expect(fields).not.toContain('context'); + }); + + it('--verbose widens a migrated tool default projection back to the full field set', () => { + const allFields = [ + 'seq', + 'timestamp', + 'level', + 'source', + 'text', + 'argsPreview', + 'context', + ] as const; + const defaultFields = [ + 'seq', + 'timestamp', + 'level', + 'source', + 'text', + ] as const; + + const fields = parseFields(undefined, allFields, defaultFields, true); + + expect(fields).toEqual([...allFields]); + expect(fields).toContain('argsPreview'); + expect(fields).toContain('context'); + }); + + it('clamps limit to max range', () => { + expect(parseLimit('500')).toBe(100); + }); + + it('projects every row into the selected schema in deterministic order', () => { + const projected = projectRows( + [ + { + name: 'x', + shortName: 'x', + description: 'desc', + inputSchema: { type: 'object' }, + }, + ], + ['name', 'shortName'], + ); + + expect(projected).toEqual([{ name: 'x', shortName: 'x' }]); + expect(projected[0]).not.toHaveProperty('inputSchema'); + + expect( + projectRows([{ name: 'missing short name' }], ['shortName', 'name']), + ).toEqual([{ shortName: null, name: 'missing short name' }]); + }); + + it('uses selected fields to encode two or more rows as columns', () => { + const output = shapePaginatedRows( + { + items: [{ id: 'a', kind: 'static' }, { id: 'b' }], + page: { limit: 20, hasMore: false }, + }, + ['id', 'kind'], + undefined, + ); + + expect(output).toEqual({ + cols: ['id', 'kind'], + rows: [ + ['a', 'static'], + ['b', null], + ], + }); + }); + + it('keeps zero and one row listings expanded and omits terminal pagination', () => { + expect( + shapePaginatedRows( + { items: [], page: { limit: 20, hasMore: false } }, + ['id'], + undefined, + ), + ).toEqual({ items: [] }); + expect( + shapePaginatedRows( + { + items: [{ id: 'a' }], + page: { limit: 1, hasMore: true, nextCursor: 'cursor' }, + }, + ['id'], + 'npx rozenite agent domains --cursor cursor', + ), + ).toEqual({ + items: [{ id: 'a' }], + next: 'npx rozenite agent domains --cursor cursor', + }); + }); + + it('accepts any pre-rendered next-page affordance, not just shell commands', () => { + // The shared envelope carries the cursor/position; each frontend renders + // its own next-page affordance (a shell command for the CLI, a `page` + // argument description for MCP, etc). This function must not assume + // shell-command shape. + expect( + shapePaginatedRows( + { + items: [{ id: 'a' }], + page: { limit: 1, hasMore: true, nextCursor: 'cursor' }, + }, + ['id'], + 'call again with page="cursor"', + ), + ).toEqual({ + items: [{ id: 'a' }], + next: 'call again with page="cursor"', + }); + }); + + it('uses the selected schema for expanded tool rows', () => { + expect( + shapeToolResult( + { + items: [{ id: 'a', extra: 'not selected' }], + page: { limit: 1, hasMore: false }, + }, + ['name', 'id'], + undefined, + ), + ).toEqual({ items: [{ id: 'a' }] }); + }); + + it('omits absent fields (not null) from row-keyed items, unlike columnar rows', () => { + expect( + shapePaginatedRows( + { items: [{ id: 'a' }], page: { limit: 20, hasMore: false } }, + ['id', 'kind'], + undefined, + ), + ).toEqual({ items: [{ id: 'a' }] }); + + expect( + shapePaginatedRows( + { items: [], page: { limit: 20, hasMore: false } }, + ['id', 'kind'], + undefined, + ), + ).toEqual({ items: [] }); + }); + + it('degrades to unshaped when tool metadata collides with a shaped output key', () => { + const collidingResult = { + cols: 'tool-owned-value', + items: [{ id: 'a' }, { id: 'b' }], + page: { limit: 2, hasMore: false }, + }; + expect(shapeToolResult(collidingResult, ['id'], undefined)).toBe( + collidingResult, + ); + + const collidingNext = { + next: 'tool-owned-value', + items: [{ id: 'a' }], + page: { limit: 1, hasMore: true, nextCursor: 'cursor' }, + }; + expect( + shapeToolResult( + collidingNext, + ['id'], + 'npx rozenite agent domains --cursor cursor', + ), + ).toBe(collidingNext); + }); + + it('does not hide a malformed continuation from a known tool result', () => { + const result = { + items: [{ id: 'a' }, { id: 'b' }], + page: { limit: 2, hasMore: true }, + }; + + expect(shapeToolResult(result, ['id'], undefined)).toBe(result); + }); + + it('leaves malformed paginated envelopes untouched without throwing', () => { + const malformed = [ + null, + 1, + { items: [null], page: { limit: 1, hasMore: false } }, + { items: [1], page: { limit: 1, hasMore: false } }, + { items: [[]], page: { limit: 1, hasMore: false } }, + { items: [], page: null }, + { items: [], page: { limit: Number.NaN, hasMore: false } }, + { items: [], page: { limit: Number.POSITIVE_INFINITY, hasMore: false } }, + { items: [], page: { limit: Number.NEGATIVE_INFINITY, hasMore: false } }, + { items: [], page: { limit: 0, hasMore: false } }, + { items: [], page: { limit: 1, hasMore: 'false' } }, + { items: [], page: { limit: 1, hasMore: false, nextCursor: 1 } }, + ]; + + for (const result of malformed) { + expect(shapeToolResult(result, ['id'], undefined)).toBe(result); + } + }); + + it('preserves reset metadata on terminal and continued tool pages', () => { + expect( + shapeToolResult( + { + items: [{ id: 'a' }], + page: { limit: 1, hasMore: false, reset: true }, + }, + ['id'], + undefined, + ), + ).toEqual({ page: { reset: true }, items: [{ id: 'a' }] }); + expect( + shapeToolResult( + { + items: [{ id: 'a' }], + page: { limit: 1, hasMore: true, nextCursor: 'cursor', reset: true }, + }, + ['id'], + 'npx rozenite agent react call --cursor cursor', + ), + ).toEqual({ + page: { reset: true }, + items: [{ id: 'a' }], + next: 'npx rozenite agent react call --cursor cursor', + }); + }); +}); diff --git a/packages/agent-shared/src/index.ts b/packages/agent-shared/src/index.ts index d985c977..7e877695 100644 --- a/packages/agent-shared/src/index.ts +++ b/packages/agent-shared/src/index.ts @@ -1,3 +1,13 @@ +export { + DEFAULT_PAGE_LIMIT, + MAX_PAGE_LIMIT, + parseFields, + parseLimit, + projectRows, + shapePaginatedRows, + shapeToolResult, +} from './output-shaping.js'; + export const AGENT_PLUGIN_ID = 'rozenite-agent'; export const DEFAULT_AGENT_HOST = '127.0.0.1'; @@ -37,10 +47,66 @@ export interface JSONSchema7 { [key: string]: unknown; } +export interface PageEnvelope { + limit: number; + hasMore: boolean; + nextCursor?: string; + reset?: boolean; +} + +export interface PageResult { + items: TItem[]; + page: PageEnvelope; + meta?: TMeta; +} + +export interface AgentToolPagination { + kind: 'cursor'; + fields: readonly string[]; + defaultFields?: readonly string[]; +} + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === 'string'); + +export const isAgentToolPagination = ( + value: unknown, +): value is AgentToolPagination => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const pagination = value as Record; + if ( + pagination.kind !== 'cursor' || + !isStringArray(pagination.fields) || + pagination.fields.length === 0 || + new Set(pagination.fields).size !== pagination.fields.length + ) { + return false; + } + + if (pagination.defaultFields === undefined) { + return true; + } + + if ( + !isStringArray(pagination.defaultFields) || + pagination.defaultFields.length === 0 || + new Set(pagination.defaultFields).size !== pagination.defaultFields.length + ) { + return false; + } + + const allowedFields = new Set(pagination.fields); + return pagination.defaultFields.every((field) => allowedFields.has(field)); +}; + export interface AgentTool { name: string; description: string; inputSchema: JSONSchema7; + pagination?: AgentToolPagination; } type AgentToolTypeCarrier = { @@ -61,6 +127,7 @@ type AgentToolDescriptorShape = { name: string; description: string; inputSchema: JSONSchema7; + pagination?: AgentToolPagination; }; export interface AgentToolDescriptor @@ -81,6 +148,41 @@ export const defineAgentToolContract = ( return tool as AgentToolContract; }; +type PaginatedResultShape = { + items: TItem[]; + page: PageEnvelope; +}; + +type PaginatedItem = + TResult extends PaginatedResultShape ? TItem : never; + +type PaginatedItemField = Extract< + keyof PaginatedItem, + string +>; + +type PaginatedToolShape = AgentTool & { + pagination: Omit & { + fields: readonly PaginatedItemField[]; + defaultFields?: readonly PaginatedItemField[]; + }; +}; + +export const definePaginatedAgentToolContract = < + TArgs, + TResult extends PaginatedResultShape>, +>( + tool: PaginatedToolShape, +): AgentToolContract => { + if (!isAgentToolPagination(tool.pagination)) { + throw new Error( + 'Paginated agent tool fields must be non-empty, unique, and contain every default field', + ); + } + + return tool as AgentToolContract; +}; + export const defineAgentToolDescriptor = ( descriptor: AgentToolDescriptorShape, ): AgentToolDescriptor => { @@ -106,6 +208,7 @@ export const defineAgentToolDescriptors = < name: tool.name, description: tool.description, inputSchema: tool.inputSchema, + ...(tool.pagination ? { pagination: tool.pagination } : {}), }), ]), ) as { diff --git a/packages/agent-shared/src/output-shaping.ts b/packages/agent-shared/src/output-shaping.ts new file mode 100644 index 00000000..3e69a5ff --- /dev/null +++ b/packages/agent-shared/src/output-shaping.ts @@ -0,0 +1,240 @@ +/** + * Frontend-neutral shaping primitives for paginated Agent tool results. + * + * These primitives turn a `{ items, page }` envelope into the compact + * columnar/row-keyed wire shape shared by every Agent frontend (CLI today, + * MCP and anything built on `@rozenite/agent-sdk` later). They are + * transport-agnostic: they know nothing about shells, HTTP, or any other + * frontend surface. + * + * Notably absent: a next-page *affordance*. Each frontend renders its own — + * the CLI renders a copy-pasteable `npx rozenite agent ...` command, MCP + * would render a `page` argument for its call-tool schema — so these + * functions accept the already-rendered affordance as a plain string (or + * `undefined`) rather than building one themselves. See + * {@link shapePaginatedRows} and {@link shapeToolResult}. + */ + +export const DEFAULT_PAGE_LIMIT = 20; +export const MAX_PAGE_LIMIT = 100; + +export const parseFields = ( + rawFields: string | undefined, + allowedFields: readonly T[], + defaultFields: readonly T[], + verbose: boolean, +): T[] => { + if (verbose) { + return [...allowedFields]; + } + + if (!rawFields || rawFields.trim().length === 0) { + return [...defaultFields]; + } + + const requested = rawFields + .split(',') + .map((field) => field.trim()) + .filter(Boolean) as T[]; + + if (requested.length === 0) { + return [...defaultFields]; + } + + const allowedSet = new Set(allowedFields); + const invalid = requested.filter((field) => !allowedSet.has(field)); + if (invalid.length > 0) { + throw new Error( + `Unknown fields: ${invalid.join(', ')}. Allowed fields: ${allowedFields.join(', ')}`, + ); + } + + return requested; +}; + +export const parseLimit = (rawLimit: string | undefined): number => { + if (!rawLimit) { + return DEFAULT_PAGE_LIMIT; + } + + const parsed = Number(rawLimit); + if (!Number.isFinite(parsed) || parsed < 1 || !Number.isInteger(parsed)) { + throw new Error( + `--limit must be an integer between 1 and ${MAX_PAGE_LIMIT}`, + ); + } + + return Math.min(parsed, MAX_PAGE_LIMIT); +}; + +/** + * Projects rows onto the selected fields for columnar encoding, where each + * row becomes a fixed-width array positionally aligned with `cols`. Absent + * values become an explicit `null` placeholder because a missing array slot + * would shift every column after it. + */ +export const projectRows = >( + rows: T[], + fields: readonly string[], +): Record[] => { + return rows.map((row) => { + const projected: Record = {}; + for (const field of fields) { + projected[field] = row[field] ?? null; + } + return projected; + }); +}; + +/** + * Projects rows onto the selected fields for row-keyed encoding, where each + * row stays an object. Unlike {@link projectRows}, absent fields are omitted + * rather than nulled: row-keyed output exists specifically for the 0/1-row + * case (see {@link shapePaginatedRows}), where explicit nulls would make the + * payload larger than the pre-columnar shape and defeat the point of + * staying expanded. + */ +const projectRowKeyed = >( + rows: T[], + fields: readonly string[], +): Record[] => { + return rows.map((row) => { + const projected: Record = {}; + for (const field of fields) { + if (Object.hasOwn(row, field)) { + projected[field] = row[field]; + } + } + return projected; + }); +}; + +type PaginatedRows = { + items: T[]; + page: { + limit: number; + hasMore: boolean; + nextCursor?: string; + }; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Shapes a paginated row listing for Agent consumption. + * + * The selected fields, rather than fields observed in individual rows, define + * `cols`. This keeps the wire shape stable when optional values are absent. + * Zero- and one-row listings remain expanded because a column header costs + * more than the repeated keys it removes. + * + * The two branches project rows differently on purpose, via separate + * helpers rather than a shared one with a flag: the row-keyed (`items`) + * branch uses {@link projectRowKeyed}, which omits absent fields, so it + * never grows past the pre-columnar row-keyed shape — the entire reason + * 0/1-row listings stay expanded instead of going columnar. The columnar + * (`cols`/`rows`) branch uses {@link projectRows}, which fills absent + * fields with `null`, because positional array cells must line up with + * `cols` and can't simply be omitted. + * + * `nextAffordance` is the already-rendered next-page affordance for whatever + * frontend is calling this (a shell command for the CLI, a `page` argument + * description for MCP, etc.) — this function only decides *whether* to + * surface it (`page.hasMore` and a value present), never how to build it. + */ +export const shapePaginatedRows = ( + paged: PaginatedRows>, + fields: readonly string[], + nextAffordance: string | undefined, +): Record => { + const next = + paged.page.hasMore && nextAffordance ? { next: nextAffordance } : {}; + + if (paged.items.length < 2) { + return { + items: projectRowKeyed(paged.items, fields), + ...next, + }; + } + + const items = projectRows(paged.items, fields); + return { + cols: [...fields], + rows: items.map((row) => fields.map((field) => row[field])), + ...next, + }; +}; + +/** Top-level keys `shapeToolResult` itself introduces into its output. */ +const SHAPED_OUTPUT_KEYS = ['cols', 'rows', 'next'] as const; + +/** + * Applies the shared presentation contract to a known tool's paginated + * result while retaining any non-row metadata owned by that tool. + * + * `metadata` (every top-level key besides `items`/`page`) is spread ahead of + * the shaped output, so a tool whose own metadata uses one of the keys this + * function produces (`cols`, `rows`, `next`) would otherwise have it + * silently overwritten. Rather than let that happen invisibly, such a + * collision degrades the whole result to unshaped — the same "degrade + * rather than corrupt" rule this function already applies to malformed + * pagination envelopes. + * + * `nextAffordance` is the frontend's already-rendered next-page affordance, + * same as in {@link shapePaginatedRows}. This function stays frontend- + * agnostic: it decides only whether a continuation should be surfaced, and + * leaves rendering that continuation entirely to the caller. + */ +export const shapeToolResult = ( + result: unknown, + fields: readonly string[], + nextAffordance: string | undefined, +): unknown => { + if ( + !isRecord(result) || + !Array.isArray(result.items) || + !isRecord(result.page) + ) { + return result; + } + + const { items, page, ...metadata } = result; + if ( + !items.every(isRecord) || + typeof page.limit !== 'number' || + !Number.isFinite(page.limit) || + !Number.isInteger(page.limit) || + page.limit < 1 || + typeof page.hasMore !== 'boolean' || + (page.nextCursor !== undefined && typeof page.nextCursor !== 'string') || + (page.reset !== undefined && typeof page.reset !== 'boolean') + ) { + return result; + } + if (page.hasMore && !nextAffordance) { + return result; + } + if (SHAPED_OUTPUT_KEYS.some((key) => Object.hasOwn(metadata, key))) { + return result; + } + + return { + ...metadata, + ...(page.reset === true ? { page: { reset: true } } : {}), + ...shapePaginatedRows( + { + items: items as Record[], + page: { + limit: page.limit, + hasMore: page.hasMore, + ...(typeof page.nextCursor === 'string' + ? { nextCursor: page.nextCursor } + : {}), + }, + }, + fields, + nextAffordance, + ), + }; +}; diff --git a/packages/cli/skills/rozenite-agent/SKILL.md b/packages/cli/skills/rozenite-agent/SKILL.md index 750aaf73..c757be7b 100644 --- a/packages/cli/skills/rozenite-agent/SKILL.md +++ b/packages/cli/skills/rozenite-agent/SKILL.md @@ -8,6 +8,46 @@ description: Use Rozenite for Agents through CLI-driven `rozenite agent` command - Use `npx rozenite` for Rozenite commands. - Run `npx rozenite` from the app root where Metro is started for the target app. In monorepos, this is usually the app package root, not the repository root. +## Listing output contract + +`agent domains`, `agent tools`, and tools that declare the shared +pagination contract always write compact JSON by default. This includes +built-in and third-party plugin tools. Pass `--pretty` for indented JSON. The +`--json` / `-j` option is retained as a compatibility no-op and never changes +the output shape. + +- With two or more rows, row-shaped results use the stable columnar contract: + `{"cols":["id","kind"],"rows":[["console","static"],["react","static"]]}`. + `cols` is exactly the requested field order; an absent optional value is + represented by `null` in its row, since a positional array cell can't + simply be omitted without shifting the columns after it. +- With zero or one row, row-shaped results remain row-keyed: + `{"items":[{"id":"console","kind":"static"}]}`. Here an absent optional + value is omitted from the object entirely (not `null`), so these payloads + never grow past their pre-columnar shape — the reason 0/1-row results stay + row-keyed in the first place. +- Terminal pages omit pagination metadata. When more rows exist, `next` is a + shell-escaped, runnable `npx rozenite agent ... --cursor ` command + that preserves the listing's connection, session, projection, and limit + options. +- A `--cursor` from an earlier page can go stale if the underlying data was + invalidated (for example, an app relaunch resets the network domain's + capture buffer). Re-running a stale cursor returns + `{"page":{"reset":true},"items":[]}` instead of a normal empty page — treat + that as "restart this listing from scratch," not "no more rows." + +Declared paginated calls include console messages, React +tree/search/inspection rows, render data, network request listings, and any +plugin tool registered with pagination metadata. Tool-specific metadata (for +example `roots`, `totalCount`, or `recording`) remains alongside the row shape. +Undeclared tool results, SDK responses, and genuinely non-row command results +retain their existing shapes. + +- Paginated calls return a **trimmed default projection**, not every declared + field. For example, `console getMessages` omits `argsPreview` and `context` + by default. Pass `-f, --fields ` to pick specific columns, or + `-v, --verbose` to include every field the tool declares. + ## Handoff - Keep this skill for shell-driven `rozenite agent ...` workflows. diff --git a/packages/cli/skills/rozenite-agent/domains/console.md b/packages/cli/skills/rozenite-agent/domains/console.md index 0d014526..6b949d58 100644 --- a/packages/cli/skills/rozenite-agent/domains/console.md +++ b/packages/cli/skills/rozenite-agent/domains/console.md @@ -8,3 +8,11 @@ Read, filter, and paginate React Native console messages from the app, and clear ## Flow Logs are captured automatically. Use `getMessages` -> optional filtered and paginated reads -> `clearMessages`. + +CLI discovery listings and paginated `getMessages` calls use columnar `cols` +and `rows` output when two or more rows are returned. Tool metadata remains +present, and `next` replaces the page envelope when another page is available. + +By default `getMessages` omits the bulky `argsPreview` and `context` columns. +Pass `--fields argsPreview,context` (or any subset) or `--verbose` to include +them. diff --git a/packages/cli/skills/rozenite-agent/domains/network-activity.md b/packages/cli/skills/rozenite-agent/domains/network-activity.md index 36ec588f..db3afa6a 100644 --- a/packages/cli/skills/rozenite-agent/domains/network-activity.md +++ b/packages/cli/skills/rozenite-agent/domains/network-activity.md @@ -36,3 +36,11 @@ HTTP fallback: Realtime: `startRecording` -> reproduce traffic -> `listRealtimeConnections` -> `getRealtimeConnectionDetails`. + +CLI discovery listings and paginated calls use columnar `cols` and `rows` +output when two or more rows are returned. Tool metadata remains present, and +`next` replaces the page envelope when another page is available. + +`listRequests` and `listRealtimeConnections` return a trimmed default column +set (identifiers, status, and duration, not every field). Pass `--fields` or +`--verbose` to widen the projection. diff --git a/packages/cli/skills/rozenite-agent/domains/network.md b/packages/cli/skills/rozenite-agent/domains/network.md index f1a42b59..0a766d02 100644 --- a/packages/cli/skills/rozenite-agent/domains/network.md +++ b/packages/cli/skills/rozenite-agent/domains/network.md @@ -18,3 +18,12 @@ Record HTTP/HTTPS traffic, then list requests, inspect request and response deta ## Flow `startRecording` -> reproduce traffic -> `listRequests` -> `getRequestDetails` -> optional body fetch. + +The `agent network tools` discovery listing and `listRequests` use columnar +`cols` and `rows` output at two or more rows. `recording` metadata remains +present, and `next` replaces the page envelope when another page is available. + +`listRequests` returns a trimmed default column set (`requestId`, `method`, +`url`, `status`, `durationMs`, `outcome`). Pass `--fields` or `--verbose` for +the remaining fields such as `type`, `startTimeMs`, `endTimeMs`, +`transferSize`, and `encodedDataLength`. diff --git a/packages/cli/skills/rozenite-agent/domains/react.md b/packages/cli/skills/rozenite-agent/domains/react.md index 557eecb2..148884b0 100644 --- a/packages/cli/skills/rozenite-agent/domains/react.md +++ b/packages/cli/skills/rozenite-agent/domains/react.md @@ -22,3 +22,15 @@ Search and inspect: Profile: `startProfiling` -> reproduce interaction -> `stopProfiling` -> `getRenderData`. + +CLI discovery listings and paginated React calls (`getTree`, `searchNodes`, +`getChildren`, `getProps`, `getState`, `getHooks`, and `getRenderData`) use +columnar `cols` and `rows` output when two or more rows are returned. Their +tool-specific metadata remains present, and `next` replaces the page envelope +when another page is available. + +`getTree`, `getChildren`, `searchNodes`, and `getRenderData` return a trimmed +default column set (identifiers and labels, not every field such as `key`, +`parentId`, or `changeTypeHints`). Pass `--fields` or `--verbose` to widen the +projection. `getProps`, `getState`, and `getHooks` always return both of their +only two fields (`name`, `value`). diff --git a/packages/cli/skills/rozenite-agent/domains/tanstack-query.md b/packages/cli/skills/rozenite-agent/domains/tanstack-query.md index cc1426e0..07c96a43 100644 --- a/packages/cli/skills/rozenite-agent/domains/tanstack-query.md +++ b/packages/cli/skills/rozenite-agent/domains/tanstack-query.md @@ -25,6 +25,10 @@ A Rozenite plugin for inspecting and managing TanStack Query caches in React Nat - `get-mutation-details` -> `{"mutationId":12}` - `clear-mutation-cache` -> `{}` +`list-queries` and `list-mutations` return a trimmed default column set +(identifiers, status, and error flag, not every field). Pass `--fields` or +`--verbose` to widen the projection. + ## Minimal Flow Inspection: diff --git a/packages/cli/src/__tests__/agent-command-output.test.ts b/packages/cli/src/__tests__/agent-command-output.test.ts index ce09cfe4..80b0601e 100644 --- a/packages/cli/src/__tests__/agent-command-output.test.ts +++ b/packages/cli/src/__tests__/agent-command-output.test.ts @@ -1,4 +1,5 @@ import { Command } from 'commander'; +import { execFileSync } from 'node:child_process'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { getPackageJSON } from '../package-json.js'; import { registerAgentCommand } from '../commands/agent/register-agent-command.js'; @@ -28,6 +29,7 @@ const mocks = vi.hoisted(() => ({ tools: { list: vi.fn(), getSchema: vi.fn(), + resolve: vi.fn(), call: vi.fn(), }, }, @@ -48,6 +50,32 @@ vi.mock('@rozenite/agent-sdk/transport', () => ({ })); describe('agent command output', () => { + const reactTreeFields = [ + 'nodeId', + 'label', + 'displayName', + 'elementType', + 'key', + 'childCount', + 'parentId', + 'parentLabel', + 'childIds', + 'depth', + ]; + const networkRequestFields = [ + 'requestId', + 'method', + 'url', + 'status', + 'type', + 'startTimeMs', + 'endTimeMs', + 'durationMs', + 'transferSize', + 'encodedDataLength', + 'outcome', + ]; + afterEach(() => { process.exitCode = undefined; vi.restoreAllMocks(); @@ -57,6 +85,32 @@ describe('agent command output', () => { const setupClient = () => { mocks.createAgentClient.mockReturnValue(mocks.client); mocks.client.attachSession.mockResolvedValue(mocks.session); + mocks.session.tools.resolve.mockResolvedValue({ + domainId: 'domain', + schema: { + name: 'plugin.tool', + shortName: 'tool', + inputSchema: { type: 'object', properties: {} }, + }, + call: mocks.session.tools.call, + }); + }; + + const setupPaginatedTool = (fields: string[], defaultFields?: string[]) => { + mocks.session.tools.resolve.mockResolvedValue({ + domainId: 'domain', + schema: { + name: 'plugin.list', + shortName: 'list', + inputSchema: { type: 'object', properties: {} }, + pagination: { + kind: 'cursor', + fields, + ...(defaultFields ? { defaultFields } : {}), + }, + }, + call: mocks.session.tools.call, + }); }; const setupTransport = () => { @@ -91,7 +145,7 @@ describe('agent command output', () => { '{"items":[{"id":"device-1","name":"iPhone"}]}\n', ); expect(mocks.createAgentClient).toHaveBeenCalledWith({ - host: 'localhost', + host: '127.0.0.1', port: 8081, }); }); @@ -298,7 +352,80 @@ describe('agent command output', () => { ); expect(stdoutWrite).toHaveBeenCalledWith( - '{"items":[{"id":"app","kind":"plugin"},{"id":"console","kind":"static"},{"id":"memory","kind":"static"},{"id":"network","kind":"static"},{"id":"performance","kind":"static"},{"id":"react","kind":"static"}],"page":{"limit":20,"hasMore":false}}\n', + '{"cols":["id","kind"],"rows":[["app","plugin"],["console","static"],["memory","static"],["network","static"],["performance","static"],["react","static"]]}\n', + ); + }); + + it('prints a runnable next command instead of a pagination envelope', async () => { + setupClient(); + mocks.session.domains.list.mockResolvedValue([ + { id: 'app', kind: 'plugin' }, + { id: 'console', kind: 'static' }, + ]); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'domains', + '--session', + 'session 1', + '--limit', + '1', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith( + '{"items":[{"id":"app","kind":"plugin"}],"next":"npx rozenite agent domains --host 127.0.0.1 --port 8081 --session \'session 1\' --limit 1 --cursor eyJ2IjoxLCJraW5kIjoiZG9tYWlucyIsInNjb3BlIjoiYWxsIiwiaW5kZXgiOjF9"}\n', + ); + }); + + it('uses the requested field order for columnar tool listings', async () => { + setupClient(); + mocks.session.tools.list.mockResolvedValue([ + { + name: 'network.listRequests', + shortName: 'listRequests', + description: 'List requests', + }, + { + name: 'network.getRequestDetails', + shortName: 'getRequestDetails', + description: 'Get details', + }, + ]); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'network', + 'tools', + '--session', + 'session-1', + '--fields', + 'description,name', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith( + '{"cols":["description","name"],"rows":[["Get details","network.getRequestDetails"],["List requests","network.listRequests"]]}\n', ); }); @@ -327,6 +454,30 @@ describe('agent command output', () => { expect(help).toContain('targets'); }); + it('describes fields without claiming a discovery-only field enum', () => { + setupClient(); + + const program = new Command(); + registerAgentCommand(program); + + const agentCommand = program.commands.find( + (command) => command.name() === 'agent', + ); + const domainCommand = agentCommand?.commands.find( + (command) => command.name() === '*', + ); + const fieldsOption = domainCommand?.options.find( + (option) => option.long === '--fields', + ); + + expect(fieldsOption?.description).toContain( + 'allowed fields depend on the listing or declared paginated tool', + ); + expect(fieldsOption?.description).not.toContain( + '(name, shortName, description)', + ); + }); + it('prints tool schemas without the domain envelope', async () => { setupClient(); mocks.session.tools.getSchema.mockResolvedValue({ @@ -402,6 +553,585 @@ describe('agent command output', () => { expect(stdoutWrite).toHaveBeenCalledWith('{"value":"hello"}\n'); }); + it('columnar-encodes terminal getTree rows using requested fields', async () => { + setupClient(); + setupPaginatedTool(reactTreeFields); + mocks.session.tools.call.mockResolvedValueOnce({ + roots: [1], + totalCount: 2, + items: [ + { nodeId: 1, displayName: 'App', childIds: [2] }, + { nodeId: 2, displayName: 'Screen' }, + ], + page: { limit: 20, hasMore: false }, + }); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'react', + 'call', + '--tool', + 'getTree', + '--args', + '{}', + '--fields', + 'displayName,nodeId,childIds', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith( + '{"roots":[1],"totalCount":2,"cols":["displayName","nodeId","childIds"],"rows":[["App",1,[2]],["Screen",2,null]]}\n', + ); + }); + + it('keeps an empty getTree result expanded without terminal page metadata', async () => { + setupClient(); + setupPaginatedTool(reactTreeFields); + mocks.session.tools.call.mockResolvedValueOnce({ + roots: [], + totalCount: 0, + items: [], + page: { limit: 20, hasMore: false }, + }); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'react', + 'call', + '--tool', + 'getTree', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith( + '{"roots":[],"totalCount":0,"items":[]}\n', + ); + }); + + it('keeps one row tool results expanded and prints a complete continuation', async () => { + setupClient(); + setupPaginatedTool(networkRequestFields); + mocks.session.tools.call.mockResolvedValueOnce({ + recording: { isRecording: true }, + items: [ + { + requestId: 'request-1', + method: 'GET', + url: 'https://example.test', + status: null, + type: 'fetch', + startTimeMs: 1, + endTimeMs: null, + durationMs: null, + transferSize: null, + encodedDataLength: null, + outcome: 'in-flight', + }, + ], + page: { limit: 1, hasMore: true, nextCursor: "next ' cursor" }, + }); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'network', + 'call', + '--tool', + 'listRequests', + '--args', + '{"limit":1,"filter":"two words"}', + '--fields', + 'url,status', + '--session', + 'session id', + '--pretty', + ], + { from: 'node' }, + ); + + const rawOutput = String(stdoutWrite.mock.calls[0][0]); + expect(rawOutput).toMatch(/^\{\n\s{2}"recording"/); + const output = JSON.parse(rawOutput); + expect(output).toMatchObject({ + recording: { isRecording: true }, + items: [{ url: 'https://example.test', status: null }], + next: expect.stringContaining('npx rozenite agent domain call'), + }); + + const continuation = output.next; + const args = execFileSync( + 'zsh', + ['-c', `for arg in ${continuation}; do print -r -- "$arg"; done`], + { encoding: 'utf8' }, + ) + .trim() + .split('\n'); + expect(args).toEqual([ + 'npx', + 'rozenite', + 'agent', + 'domain', + 'call', + '--host', + '127.0.0.1', + '--port', + '8081', + '--session', + 'session id', + '--tool', + 'listRequests', + '--args', + '{"limit":1,"filter":"two words","cursor":"next \' cursor"}', + '--fields', + 'url,status', + '--pretty', + ]); + }); + + it('rejects non-object --args for a declared paginated tool instead of silently using {}', async () => { + setupClient(); + setupPaginatedTool(networkRequestFields); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'network', + 'call', + '--tool', + 'listRequests', + '--args', + 'null', + '--fields', + 'url,status', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(mocks.session.tools.call).not.toHaveBeenCalled(); + expect(stdoutWrite).toHaveBeenCalledWith( + '{"error":{"message":"--args must be a JSON object"}}\n', + ); + }); + + it('still normalizes non-object --args for non-paginated tools', async () => { + setupClient(); + mocks.session.tools.call.mockResolvedValueOnce({ value: 'hello' }); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'app', + 'call', + '--tool', + 'echo', + '--args', + 'null', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(mocks.session.tools.call).toHaveBeenCalledWith(null, { + autoPaginate: {}, + }); + expect(stdoutWrite).toHaveBeenCalledWith('{"value":"hello"}\n'); + }); + + it('validates declared row fields before invoking the remote tool', async () => { + setupClient(); + setupPaginatedTool(networkRequestFields); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'network', + 'call', + '--tool', + 'listRequests', + '--fields', + 'name', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(mocks.session.tools.call).not.toHaveBeenCalled(); + expect(stdoutWrite).toHaveBeenCalledWith( + expect.stringContaining( + '"message":"Unknown fields: name. Allowed fields: requestId, method, url, status', + ), + ); + }); + + it('degrades to unshaped output on stdout with a stderr warning for invalid pagination metadata', async () => { + setupClient(); + mocks.session.tools.resolve.mockResolvedValue({ + domainId: 'broken-plugin', + schema: { + name: 'plugin.list', + shortName: 'list', + inputSchema: { type: 'object', properties: {} }, + pagination: { kind: 'cursor', fields: [] }, + }, + call: mocks.session.tools.call, + }); + const result = { + items: [{ id: 'one' }], + page: { limit: 20, hasMore: false }, + }; + mocks.session.tools.call.mockResolvedValueOnce(result); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const stderrWrite = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'broken-plugin', + 'call', + '--tool', + 'list', + '--args', + '{}', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith(`${JSON.stringify(result)}\n`); + expect(stderrWrite).toHaveBeenCalledWith( + expect.stringContaining('invalid pagination metadata'), + ); + }); + + it('degrades to unshaped output when tool metadata collides with a shaped output key', async () => { + setupClient(); + setupPaginatedTool(networkRequestFields); + const result = { + cols: 'owned by the tool, not the shaping contract', + items: [ + { requestId: 'one', method: 'GET', url: '/', status: 200 }, + { requestId: 'two', method: 'POST', url: '/two', status: null }, + ], + page: { limit: 20, hasMore: false }, + }; + mocks.session.tools.call.mockResolvedValueOnce(result); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'network', + 'call', + '--tool', + 'listRequests', + '--args', + '{}', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith(`${JSON.stringify(result)}\n`); + }); + + it('uses the fixed default schema for two-row listRequests results', async () => { + setupClient(); + setupPaginatedTool(networkRequestFields); + mocks.session.tools.call.mockResolvedValueOnce({ + items: [ + { requestId: 'one', method: 'GET', url: '/', status: 200 }, + { requestId: 'two', method: 'POST', url: '/two', status: null }, + ], + page: { limit: 20, hasMore: false }, + }); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'network', + 'call', + '--tool', + 'listRequests', + '--args', + '{}', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith( + '{"cols":["requestId","method","url","status","type","startTimeMs","endTimeMs","durationMs","transferSize","encodedDataLength","outcome"],"rows":[["one","GET","/",200,null,null,null,null,null,null,null],["two","POST","/two",null,null,null,null,null,null,null,null]]}\n', + ); + }); + + it('leaves a plugin tool with a colliding short name unchanged', async () => { + setupClient(); + const result = { + items: [{ pluginValue: 'preserve me' }], + page: { limit: 1, hasMore: false }, + }; + mocks.session.tools.call.mockResolvedValueOnce(result); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'third-party-plugin', + 'call', + '--tool', + 'listRequests', + '--args', + '{}', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith(`${JSON.stringify(result)}\n`); + }); + + it('shapes an arbitrary third-party plugin from its declared pagination metadata', async () => { + setupClient(); + setupPaginatedTool(['url', 'requestId', 'status']); + mocks.session.tools.call.mockResolvedValueOnce({ + items: [ + { requestId: 'request-1', url: 'https://example.test' }, + { + requestId: 'request-2', + url: 'https://example.test/two', + status: 200, + }, + ], + page: { limit: 2, hasMore: false }, + }); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'at-rozenite__network-activity-plugin', + 'call', + '--tool', + 'listRequests', + '--args', + '{}', + '--fields', + 'url,requestId,status', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith( + '{"cols":["url","requestId","status"],"rows":[["https://example.test","request-1",null],["https://example.test/two","request-2",200]]}\n', + ); + }); + + it('uses a third-party tool default projection without a CLI allowlist', async () => { + setupClient(); + setupPaginatedTool(['id', 'label', 'details'], ['id', 'label']); + mocks.session.tools.call.mockResolvedValueOnce({ + items: [ + { id: 'one', label: 'First', details: { size: 1 } }, + { id: 'two', label: 'Second', details: { size: 2 } }, + ], + page: { limit: 2, hasMore: false }, + }); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + '@example/plugin', + 'call', + '--tool', + 'list', + '--session', + 'session-1', + ], + { from: 'node' }, + ); + + expect(stdoutWrite).toHaveBeenCalledWith( + '{"cols":["id","label"],"rows":[["one","First"],["two","Second"]]}\n', + ); + }); + + it('keeps pretty output in runnable listing continuations', async () => { + setupClient(); + mocks.session.tools.list.mockResolvedValue([ + { + name: 'network.getRequestDetails', + shortName: 'getRequestDetails', + domainId: 'network', + }, + { + name: 'network.listRequests', + shortName: 'listRequests', + domainId: 'network', + }, + ]); + + const stdoutWrite = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const program = new Command(); + registerAgentCommand(program); + + await program.parseAsync( + [ + 'node', + 'test', + 'agent', + 'network domain', + 'tools', + '--session', + 'session id', + '--limit', + '1', + '--pretty', + ], + { from: 'node' }, + ); + + const output = JSON.parse(String(stdoutWrite.mock.calls[0][0])); + const args = execFileSync( + 'zsh', + ['-c', `for arg in ${output.next}; do print -r -- "$arg"; done`], + { encoding: 'utf8' }, + ) + .trim() + .split('\n'); + expect(args).toEqual([ + 'npx', + 'rozenite', + 'agent', + 'network', + 'tools', + '--host', + '127.0.0.1', + '--port', + '8081', + '--session', + 'session id', + '--limit', + '1', + '--cursor', + expect.any(String), + '--pretty', + ]); + }); + it('prints JSON errors for agent command failures', async () => { setupClient(); mocks.client.targets.list.mockRejectedValue( diff --git a/packages/cli/src/__tests__/agent-output-shaping.test.ts b/packages/cli/src/__tests__/agent-output-shaping.test.ts index a2f4fe16..842d4951 100644 --- a/packages/cli/src/__tests__/agent-output-shaping.test.ts +++ b/packages/cli/src/__tests__/agent-output-shaping.test.ts @@ -1,43 +1,10 @@ import { describe, expect, it } from 'vitest'; import { - parseFields, - parseLimit, + formatAgentCommand, paginateRows, - projectRows, } from '../commands/agent/output-shaping.js'; -describe('agent output shaping', () => { - it('uses default fields when none provided', () => { - const fields = parseFields( - undefined, - ['name', 'shortName', 'description'] as const, - ['name', 'shortName'] as const, - false, - ); - expect(fields).toEqual(['name', 'shortName']); - }); - - it('parses valid fields and preserves order', () => { - const fields = parseFields( - 'description,name', - ['name', 'shortName', 'description'] as const, - ['name', 'shortName'] as const, - false, - ); - expect(fields).toEqual(['description', 'name']); - }); - - it('throws on invalid fields', () => { - expect(() => - parseFields( - 'name,badField', - ['name', 'shortName', 'description'] as const, - ['name', 'shortName'] as const, - false, - ), - ).toThrow(/Unknown fields/); - }); - +describe('agent output shaping (CLI-only)', () => { it('supports cursor pagination across pages', () => { const rows = [ { name: 'a' }, @@ -89,24 +56,15 @@ describe('agent output shaping', () => { ).toThrow(/Invalid --cursor/); }); - it('projects rows and excludes schema-like fields', () => { - const projected = projectRows( - [ - { - name: 'x', - shortName: 'x', - description: 'desc', - inputSchema: { type: 'object' }, - }, - ], - ['name', 'shortName'], - ); - - expect(projected).toEqual([{ name: 'x', shortName: 'x' }]); - expect(projected[0]).not.toHaveProperty('inputSchema'); + it('makes next commands safe to paste into a POSIX shell', () => { + expect( + formatAgentCommand(['domains', '--session', "session with ' quote"]), + ).toBe("npx rozenite agent domains --session 'session with '\"'\"' quote'"); }); - it('clamps limit to max range', () => { - expect(parseLimit('500')).toBe(100); + it('emits slash-containing domain ids unquoted and shell-safe', () => { + expect( + formatAgentCommand(['avasapp/ably', 'tools', '--cursor', 'cursor']), + ).toBe('npx rozenite agent avasapp/ably tools --cursor cursor'); }); }); diff --git a/packages/cli/src/__tests__/agent-runtime-handler.test.ts b/packages/cli/src/__tests__/agent-runtime-handler.test.ts index a38e8793..e80d8e78 100644 --- a/packages/cli/src/__tests__/agent-runtime-handler.test.ts +++ b/packages/cli/src/__tests__/agent-runtime-handler.test.ts @@ -9,12 +9,28 @@ describe('agent runtime handler console domain', () => { sendMessage() {}, }); - const toolNames = handler.getTools().map((tool) => tool.name); + const tools = handler.getTools(); + const toolNames = tools.map((tool) => tool.name); expect(toolNames).toContain('getMessages'); expect(toolNames).toContain('clearMessages'); expect(toolNames).not.toContain('enable'); expect(toolNames).not.toContain('disable'); + expect( + tools.find((tool) => tool.name === 'getMessages')?.pagination, + ).toEqual({ + kind: 'cursor', + fields: [ + 'seq', + 'timestamp', + 'level', + 'source', + 'text', + 'argsPreview', + 'context', + ], + defaultFields: ['seq', 'timestamp', 'level', 'source', 'text'], + }); }); it('captures console messages without an explicit enable step', async () => { @@ -31,7 +47,7 @@ describe('agent runtime handler console domain', () => { timestamp: Date.now(), }); - const result = await handler.callTool('getMessages', {}) as { + const result = (await handler.callTool('getMessages', {})) as { items: Array<{ text: string }>; meta: { bufferSize: number }; }; diff --git a/packages/cli/src/commands/agent/output-shaping.ts b/packages/cli/src/commands/agent/output-shaping.ts index 59252f62..c00b4bad 100644 --- a/packages/cli/src/commands/agent/output-shaping.ts +++ b/packages/cli/src/commands/agent/output-shaping.ts @@ -1,3 +1,20 @@ +/** + * CLI-only Agent output shaping. + * + * The transport-agnostic shaping primitives (`parseFields`, `parseLimit`, + * `projectRows`, `shapePaginatedRows`, `shapeToolResult`, and friends) live + * in `@rozenite/agent-shared` — they're shared with MCP and any other + * `@rozenite/agent-sdk` consumer. What stays here is CLI-specific: + * + * - `paginateRows` is an offset pager scoped to CLI-owned listings + * (`tools`/`domains`), not the general shared pagination engine (that's + * a separate effort — see #320). + * - `formatAgentCommand`/`shellEscape` build the `npx rozenite agent ...` + * next-page affordance, which is meaningless outside a shell (in MCP the + * next-page affordance is a `page` argument to the call-tool, not a + * command to run). + */ + type CursorPayload = { v: 1; kind: 'tools' | 'domains'; @@ -5,9 +22,6 @@ type CursorPayload = { index: number; }; -export const DEFAULT_PAGE_LIMIT = 20; -export const MAX_PAGE_LIMIT = 100; - const encodeCursor = (payload: CursorPayload): string => { return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); }; @@ -27,71 +41,32 @@ const decodeCursor = (raw: string): CursorPayload => { } return payload; } catch { - throw new Error('Invalid --cursor. Run the listing command again with --limit 20.'); - } -}; - -export const parseFields = ( - rawFields: string | undefined, - allowedFields: readonly T[], - defaultFields: readonly T[], - verbose: boolean, -): T[] => { - if (verbose) { - return [...allowedFields]; - } - - if (!rawFields || rawFields.trim().length === 0) { - return [...defaultFields]; - } - - const requested = rawFields - .split(',') - .map((field) => field.trim()) - .filter(Boolean) as T[]; - - if (requested.length === 0) { - return [...defaultFields]; - } - - const allowedSet = new Set(allowedFields); - const invalid = requested.filter((field) => !allowedSet.has(field)); - if (invalid.length > 0) { throw new Error( - `Unknown fields: ${invalid.join(', ')}. Allowed fields: ${allowedFields.join(', ')}`, + 'Invalid --cursor. Run the listing command again with --limit 20.', ); } - - return requested; }; -export const parseLimit = (rawLimit: string | undefined): number => { - if (!rawLimit) { - return DEFAULT_PAGE_LIMIT; - } +type PaginatedRows = { + items: T[]; + page: { + limit: number; + hasMore: boolean; + nextCursor?: string; + }; +}; - const parsed = Number(rawLimit); - if (!Number.isFinite(parsed) || parsed < 1 || !Number.isInteger(parsed)) { - throw new Error(`--limit must be an integer between 1 and ${MAX_PAGE_LIMIT}`); +const shellEscape = (value: string): string => { + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) { + return value; } - return Math.min(parsed, MAX_PAGE_LIMIT); + return `'${value.replaceAll("'", "'\"'\"'")}'`; }; -export const projectRows = >( - rows: T[], - fields: readonly string[], -): Record[] => { - return rows.map((row) => { - const projected: Record = {}; - for (const field of fields) { - if (Object.hasOwn(row, field)) { - projected[field] = row[field]; - } - } - return projected; - }); -}; +/** Builds a copy-pasteable POSIX shell command from already-separated args. */ +export const formatAgentCommand = (args: readonly string[]): string => + ['npx', 'rozenite', 'agent', ...args].map(shellEscape).join(' '); export const paginateRows = ( rows: T[], @@ -101,19 +76,14 @@ export const paginateRows = ( limit: number; cursor?: string; }, -): { - items: T[]; - page: { - limit: number; - hasMore: boolean; - nextCursor?: string; - }; -} => { +): PaginatedRows => { let startIndex = 0; if (options.cursor) { const decoded = decodeCursor(options.cursor); if (decoded.kind !== options.kind || decoded.scope !== options.scope) { - throw new Error('Cursor does not match the requested listing. Run the command again.'); + throw new Error( + 'Cursor does not match the requested listing. Run the command again.', + ); } startIndex = decoded.index; } diff --git a/packages/cli/src/commands/agent/register-agent-command.ts b/packages/cli/src/commands/agent/register-agent-command.ts index 47daec0d..de9bde0c 100644 --- a/packages/cli/src/commands/agent/register-agent-command.ts +++ b/packages/cli/src/commands/agent/register-agent-command.ts @@ -4,14 +4,15 @@ import { createAgentTransport } from '@rozenite/agent-sdk/transport'; import { DEFAULT_AGENT_HOST, DEFAULT_AGENT_PORT, -} from '@rozenite/agent-shared'; -import { printOutput } from './output.js'; -import { - paginateRows, + isAgentToolPagination, parseFields, parseLimit, - projectRows, -} from './output-shaping.js'; + shapePaginatedRows, + shapeToolResult, + type AgentToolPagination, +} from '@rozenite/agent-shared'; +import { printOutput } from './output.js'; +import { formatAgentCommand, paginateRows } from './output-shaping.js'; import { getErrorMessage } from './error-message.js'; import { getPackageJSON } from '../../package-json.js'; @@ -122,6 +123,89 @@ const getConnectionOptions = (cmd: Command): CommonOptions => { }; }; +const getConnectionCommandArgs = (options: CommonOptions): string[] => [ + '--host', + options.host, + '--port', + String(options.port), +]; + +const getListingOptionArgs = ( + options: Pick, + limit: number, + cursor: string, + pretty: boolean, +): string[] => [ + ...(options.verbose + ? ['--verbose'] + : options.fields + ? ['--fields', options.fields] + : []), + '--limit', + String(limit), + '--cursor', + cursor, + ...(pretty ? ['--pretty'] : []), +]; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +type ToolPaginationResolution = { + pagination?: AgentToolPagination; + warning?: string; +}; + +/** + * Reads a tool's declared pagination metadata. Malformed metadata (e.g. from + * a third-party plugin) degrades to "non-paginated" with a warning rather + * than failing the call outright, per the documented contract: undeclared, + * malformed, and non-row results remain unchanged. + */ +const getToolPagination = (value: unknown): ToolPaginationResolution => { + if (!isRecord(value) || !isRecord(value.pagination)) { + return {}; + } + + if (!isAgentToolPagination(value.pagination)) { + return { + warning: + 'Warning: tool exposes invalid pagination metadata; returning its result unshaped.', + }; + } + + return { pagination: value.pagination }; +}; + +const getToolCallCommand = ( + domain: string, + tool: string, + args: Record, + cursor: string, + options: DynamicDomainCommandOptions, + connection: CommonOptions, + sessionId: string, +): string => + formatAgentCommand([ + domain, + 'call', + ...getConnectionCommandArgs(connection), + '--session', + sessionId, + '--tool', + tool, + '--args', + JSON.stringify({ ...args, cursor }), + ...(options.verbose + ? ['--verbose'] + : options.fields + ? ['--fields', options.fields] + : []), + ...(options.pages ? ['--pages', options.pages] : []), + ...(options.maxItems ? ['--max-items', options.maxItems] : []), + ...(connection.pretty ? ['--pretty'] : []), + ]); + const getSessionId = (cmd: Command): string => { const options = cmd.optsWithGlobals(); if (!options.session) { @@ -222,7 +306,7 @@ const registerDynamicPluginDomainDispatcher = (mcpCommand: Command): void => { .option('-a, --args ', 'Tool arguments as JSON object', '{}') .option( '-f, --fields ', - `Fields to include (${TOOL_LIST_FIELDS.join(', ')})`, + 'Comma-separated output fields; allowed fields depend on the listing or declared paginated tool', ) .option('-v, --verbose', 'Include all supported fields') .option('-n, --limit ', 'Page size (default 20, max 100)') @@ -295,29 +379,43 @@ const registerDynamicPluginDomainDispatcher = (mcpCommand: Command): void => { !!dynamicOptions.verbose, ); const limit = parseLimit(dynamicOptions.limit); - const rows = ( - await session.tools.list({ - domain: domainToken, - }) - ) + const domainTools = await session.tools.list({ + domain: domainToken, + }); + const domainId = domainTools[0]?.domainId ?? domainToken; + const rows = domainTools .map((tool) => ({ name: tool.name, shortName: tool.shortName, description: tool.description, })) .sort((a, b) => a.name.localeCompare(b.name)); - const projected = projectRows(rows, fields); - const paged = paginateRows(projected, { + const paged = paginateRows(rows, { kind: 'tools', - scope: `domain:${domainToken}`, + scope: `domain:${domainId}`, limit, cursor: dynamicOptions.cursor, }); - return { - items: paged.items, - page: paged.page, - }; + return shapePaginatedRows( + paged, + fields, + paged.page.nextCursor + ? formatAgentCommand([ + domainId, + 'tools', + ...getConnectionCommandArgs(options), + '--session', + sessionId, + ...getListingOptionArgs( + dynamicOptions, + limit, + paged.page.nextCursor, + !!options.pretty, + ), + ]) + : undefined, + ); } if (!dynamicOptions.tool) { @@ -334,13 +432,64 @@ const registerDynamicPluginDomainDispatcher = (mcpCommand: Command): void => { } const parsedArgs = parseJsonArgs(dynamicOptions.args); - const autoPagination = resolveAutoPaginationConfig(dynamicOptions); - return await session.tools.call({ + const resolvedTool = await session.tools.resolve({ domain: domainToken, tool: dynamicOptions.tool, - args: parsedArgs, - autoPaginate: autoPagination, }); + const { pagination, warning } = getToolPagination( + resolvedTool.schema, + ); + if (warning) { + process.stderr.write(`${warning}\n`); + } + + if (pagination && !isRecord(parsedArgs)) { + throw new Error('--args must be a JSON object'); + } + + const paginatedToolArgs: Record | undefined = + pagination ? (parsedArgs as Record) : undefined; + const fields = pagination + ? parseFields( + dynamicOptions.fields, + pagination.fields, + pagination.defaultFields ?? pagination.fields, + !!dynamicOptions.verbose, + ) + : undefined; + const autoPagination = resolveAutoPaginationConfig(dynamicOptions); + const toolResult = await resolvedTool.call( + paginatedToolArgs ?? parsedArgs, + { autoPaginate: autoPagination }, + ); + + if (!pagination || !paginatedToolArgs || !fields) { + return toolResult; + } + + const nextCursor = + isRecord(toolResult) && + isRecord(toolResult.page) && + toolResult.page.hasMore === true && + typeof toolResult.page.nextCursor === 'string' + ? toolResult.page.nextCursor + : undefined; + + return shapeToolResult( + toolResult, + fields, + nextCursor + ? getToolCallCommand( + resolvedTool.domainId, + dynamicOptions.tool, + paginatedToolArgs, + nextCursor, + dynamicOptions, + options, + sessionId, + ) + : undefined, + ); })(); printOutput(result, true, !!options.pretty); @@ -358,7 +507,7 @@ export const registerAgentCommand = (program: Command): void => { .option('--host ', 'Metro host', DEFAULT_METRO_HOST) .option('--port ', 'Metro port', String(DEFAULT_METRO_PORT)) .option('-j, --json', 'Deprecated no-op; agent commands always output JSON') - .option('--pretty', 'Pretty-print JSON output when --json is used'); + .option('--pretty', 'Pretty-print JSON output'); mcpCommand .command('targets') @@ -390,7 +539,7 @@ export const registerAgentCommand = (program: Command): void => { .option('--host ', 'Metro host', DEFAULT_METRO_HOST) .option('--port ', 'Metro port', String(DEFAULT_METRO_PORT)) .option('-j, --json', 'Deprecated no-op; agent commands always output JSON') - .option('--pretty', 'Pretty-print JSON output when --json is used'); + .option('--pretty', 'Pretty-print JSON output'); sessionCommand .command('create') @@ -514,18 +663,31 @@ export const registerAgentCommand = (program: Command): void => { description: domain.description, })) .sort((a, b) => a.id.localeCompare(b.id)); - const projected = projectRows(domains, fields); - const paged = paginateRows(projected, { + const paged = paginateRows(domains, { kind: 'domains', scope: 'all', limit, cursor: listOptions.cursor, }); - return { - items: paged.items, - page: paged.page, - }; + return shapePaginatedRows( + paged, + fields, + paged.page.nextCursor + ? formatAgentCommand([ + 'domains', + ...getConnectionCommandArgs(options), + '--session', + sessionId, + ...getListingOptionArgs( + listOptions, + limit, + paged.page.nextCursor, + !!options.pretty, + ), + ]) + : undefined, + ); })(); printOutput(result, true, !!options.pretty); diff --git a/packages/cli/src/commands/agent/runtime/handler.ts b/packages/cli/src/commands/agent/runtime/handler.ts index 9a1d67ad..5f56b7d1 100644 --- a/packages/cli/src/commands/agent/runtime/handler.ts +++ b/packages/cli/src/commands/agent/runtime/handler.ts @@ -1,3 +1,4 @@ +import type { AgentToolPagination } from '@rozenite/agent-shared'; import { createToolRegistry } from './tool-registry.js'; import type { DevToolsPluginMessage, @@ -9,7 +10,22 @@ import type { } from './types.js'; import { AGENT_PLUGIN_ID } from './types.js'; import { createConsoleLogStore } from './console/store.js'; -import type { ConsoleMessageInput } from './console/types.js'; +import type { ConsoleMessageInput, ConsoleLogEntry } from './console/types.js'; + +/** + * Ties a tool's declared pagination `fields`/`defaultFields` to the keys of + * its actual row type at compile time, so a renamed or removed field on + * `TRow` becomes a build error here instead of a silent `null` column at + * runtime. + */ +const cursorPagination = (config: { + fields: readonly Extract[]; + defaultFields?: readonly Extract[]; +}): AgentToolPagination => ({ + kind: 'cursor', + fields: config.fields, + ...(config.defaultFields ? { defaultFields: config.defaultFields } : {}), +}); const CONSOLE_TOOL_NAMES = { getMessages: 'getMessages', @@ -62,6 +78,20 @@ const CONSOLE_TOOLS: AgentTool[] = [ }, }, }, + pagination: cursorPagination({ + fields: [ + 'seq', + 'timestamp', + 'level', + 'source', + 'text', + 'argsPreview', + 'context', + ], + // Drop the two heaviest columns from the default projection; they + // remain available via --fields/--verbose. + defaultFields: ['seq', 'timestamp', 'level', 'source', 'text'], + }), }, ]; @@ -89,7 +119,11 @@ export const createAgentMessageHandler = () => { return CONSOLE_TOOLS.some((tool) => tool.name === toolName); }; - const attachDeviceSchema = (tools: AgentTool[], deviceIds: string[], deviceNames: string[]): AgentTool[] => { + const attachDeviceSchema = ( + tools: AgentTool[], + deviceIds: string[], + deviceNames: string[], + ): AgentTool[] => { if (deviceIds.length <= 1) { return tools; } @@ -130,7 +164,9 @@ export const createAgentMessageHandler = () => { const resolveDeviceForLocalTool = (requestedDeviceId?: string): string => { const devices = registry.getDevices(); if (devices.length === 0) { - throw new Error('No connected device is available for local Agent tools.'); + throw new Error( + 'No connected device is available for local Agent tools.', + ); } if (requestedDeviceId) { @@ -260,7 +296,10 @@ export const createAgentMessageHandler = () => { return registry.getDevices(); }; - const callTool = async (toolName: string, args: unknown): Promise => { + const callTool = async ( + toolName: string, + args: unknown, + ): Promise => { let deviceId: string | undefined; let toolArgs = args; @@ -315,7 +354,12 @@ export const createAgentMessageHandler = () => { } }, 30000); - pendingCalls.set(callId, { deviceId: targetDeviceId, resolve, reject, timeoutId }); + pendingCalls.set(callId, { + deviceId: targetDeviceId, + resolve, + reject, + timeoutId, + }); }); try { diff --git a/packages/cli/src/commands/agent/runtime/tool-registry.ts b/packages/cli/src/commands/agent/runtime/tool-registry.ts index d631e258..c7a355b8 100644 --- a/packages/cli/src/commands/agent/runtime/tool-registry.ts +++ b/packages/cli/src/commands/agent/runtime/tool-registry.ts @@ -11,7 +11,11 @@ export const createToolRegistry = () => { deviceName: string, reactNativeVersion?: string, ): void => { - devices.set(deviceId, { id: deviceId, name: deviceName, reactNativeVersion }); + devices.set(deviceId, { + id: deviceId, + name: deviceName, + reactNativeVersion, + }); if (!tools.has(deviceId)) { tools.set(deviceId, new Map()); } @@ -22,7 +26,10 @@ export const createToolRegistry = () => { tools.delete(deviceId); }; - const registerTools = (deviceId: string, incomingTools: AgentTool[]): void => { + const registerTools = ( + deviceId: string, + incomingTools: AgentTool[], + ): void => { let deviceTools = tools.get(deviceId); if (!deviceTools) { deviceTools = new Map(); @@ -129,7 +136,7 @@ export const createToolRegistry = () => { const aggregatedTools: AgentTool[] = []; - for (const [toolName, registeredTools] of toolsByName.entries()) { + for (const registeredTools of toolsByName.values()) { const firstTool = registeredTools[0].tool; if (availableDevices.length <= 1 || registeredTools.length === 1) { @@ -156,8 +163,7 @@ export const createToolRegistry = () => { }; aggregatedTools.push({ - name: toolName, - description: firstTool.description, + ...firstTool, inputSchema: modifiedSchema, }); } @@ -165,7 +171,10 @@ export const createToolRegistry = () => { return aggregatedTools; }; - const findToolDevice = (toolName: string, deviceId?: string): string | null => { + const findToolDevice = ( + toolName: string, + deviceId?: string, + ): string | null => { if (deviceId) { const deviceTools = tools.get(deviceId); if (deviceTools && deviceTools.has(toolName)) { diff --git a/packages/cli/src/commands/agent/runtime/types.ts b/packages/cli/src/commands/agent/runtime/types.ts index 44d9eb7c..87f2c630 100644 --- a/packages/cli/src/commands/agent/runtime/types.ts +++ b/packages/cli/src/commands/agent/runtime/types.ts @@ -1,51 +1,16 @@ -export const AGENT_PLUGIN_ID = 'rozenite-agent'; - -export interface JSONSchema7 { - type?: string | string[]; - properties?: Record; - items?: JSONSchema7 | JSONSchema7[]; - required?: string[]; - enum?: unknown[]; - const?: unknown; - description?: string; - title?: string; - default?: unknown; - examples?: unknown[]; - [key: string]: unknown; -} - -export interface AgentTool { - name: string; - description: string; - inputSchema: JSONSchema7; -} - -export type DevToolsPluginMessage = { - pluginId: string; - type: string; - payload: unknown; -}; - -export type RegisterToolPayload = { - tools: AgentTool[]; -}; - -export type UnregisterToolPayload = { - toolNames: string[]; -}; - -export type ToolCallPayload = { - callId: string; - toolName: string; - arguments: unknown; -}; - -export type ToolResultPayload = { - callId: string; - success: boolean; - result?: unknown; - error?: string; -}; +import type { AgentTool } from '@rozenite/agent-shared'; + +export { AGENT_PLUGIN_ID } from '@rozenite/agent-shared'; + +export type { + AgentTool, + DevToolsPluginMessage, + JSONSchema7, + RegisterToolPayload, + UnregisterToolPayload, + ToolCallPayload, + ToolResultPayload, +} from '@rozenite/agent-shared'; export interface DeviceInfo { id: string; diff --git a/packages/middleware/src/__tests__/agent-local-domains.test.ts b/packages/middleware/src/__tests__/agent-local-domains.test.ts index 2251573a..32131d25 100644 --- a/packages/middleware/src/__tests__/agent-local-domains.test.ts +++ b/packages/middleware/src/__tests__/agent-local-domains.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createMemoryDomainService, createNetworkDomainService, + createReactDomainService, } from '../agent/local-domains.js'; const waitForWriteCalls = async ( @@ -19,6 +20,83 @@ const waitForWriteCalls = async ( expect(fn.mock.calls.length).toBeGreaterThanOrEqual(count); }; +describe('paginated local domain contracts', () => { + it('publishes row metadata from React and network tool definitions', async () => { + const react = createReactDomainService({ + sessionId: 'session-1', + sendReactDevToolsMessage() {}, + }); + const network = createNetworkDomainService({ + getSessionInfo: () => ({ + sessionId: 'session-1', + pageId: 'page-1', + deviceId: 'device-1', + }), + sendCommand: async () => ({}), + subscribeToCDPEvent: () => () => {}, + }); + + expect( + react.getTools().find((tool) => tool.name === 'getTree')?.pagination, + ).toMatchObject({ + kind: 'cursor', + fields: expect.arrayContaining(['nodeId', 'childIds', 'depth']), + }); + expect( + network.getTools().find((tool) => tool.name === 'listRequests') + ?.pagination, + ).toMatchObject({ + kind: 'cursor', + fields: expect.arrayContaining(['requestId', 'url', 'status']), + }); + + await react.dispose(); + await network.dispose(); + }); + + it('trims defaultFields narrower than the full field set for migrated tools', async () => { + const react = createReactDomainService({ + sessionId: 'session-1', + sendReactDevToolsMessage() {}, + }); + const network = createNetworkDomainService({ + getSessionInfo: () => ({ + sessionId: 'session-1', + pageId: 'page-1', + deviceId: 'device-1', + }), + sendCommand: async () => ({}), + subscribeToCDPEvent: () => () => {}, + }); + + const getTreePagination = react + .getTools() + .find((tool) => tool.name === 'getTree')?.pagination; + const listRequestsPagination = network + .getTools() + .find((tool) => tool.name === 'listRequests')?.pagination; + + expect(getTreePagination?.defaultFields?.length).toBeLessThan( + getTreePagination?.fields.length ?? 0, + ); + expect(getTreePagination?.defaultFields).not.toContain('childIds'); + for (const field of getTreePagination?.defaultFields ?? []) { + expect(getTreePagination?.fields).toContain(field); + } + + expect(listRequestsPagination?.defaultFields?.length).toBeLessThan( + listRequestsPagination?.fields.length ?? 0, + ); + expect(listRequestsPagination?.defaultFields).not.toContain('type'); + for (const field of listRequestsPagination?.defaultFields ?? []) { + expect(listRequestsPagination?.fields).toContain(field); + } + + await react.dispose(); + await network.dispose(); + }); +}); + describe('memory domain service', () => { it('waits for pending heap snapshot chunk writes before finalizing', async () => { const listeners = new Map< @@ -205,4 +283,79 @@ describe('network domain service', () => { }; expect(listResult.items).toEqual([]); }); + + it('signals page.reset instead of silently-empty items for a cursor from before a disconnect', async () => { + const listeners = new Map< + string, + Set<(params: Record) => void | Promise> + >(); + + const subscribeToCDPEvent = ( + method: string, + listener: (params: Record) => void | Promise, + ) => { + const entries = listeners.get(method) || new Set(); + entries.add(listener); + listeners.set(method, entries); + + return () => { + entries.delete(listener); + if (entries.size === 0) { + listeners.delete(method); + } + }; + }; + + const emit = (method: string, params: Record) => { + for (const listener of listeners.get(method) || []) { + void listener(params); + } + }; + + const service = createNetworkDomainService({ + getSessionInfo: () => ({ + sessionId: 'device-1', + pageId: 'page-1', + deviceId: 'device-1', + }), + sendCommand: async () => ({}), + subscribeToCDPEvent, + }); + + await service.callTool('startRecording', {}); + emit('Network.requestWillBeSent', { + requestId: 'req-1', + request: { url: 'https://example.com/one', method: 'GET' }, + }); + emit('Network.requestWillBeSent', { + requestId: 'req-2', + request: { url: 'https://example.com/two', method: 'GET' }, + }); + + const firstPage = (await service.callTool('listRequests', { + limit: 1, + })) as { + items: unknown[]; + page: { hasMore: boolean; nextCursor?: string; reset?: boolean }; + }; + expect(firstPage.items).toHaveLength(1); + expect(firstPage.page.hasMore).toBe(true); + expect(firstPage.page.nextCursor).toBeTruthy(); + expect(firstPage.page.reset).toBeUndefined(); + + // An app relaunch (or any disconnect) wipes the capture buffer and bumps + // the generation, so the cursor issued above now points at a buffer + // that no longer exists. + service.onDisconnected(); + + const staleContinuation = (await service.callTool('listRequests', { + cursor: firstPage.page.nextCursor, + })) as { + items: unknown[]; + page: { hasMore: boolean; nextCursor?: string; reset?: boolean }; + }; + expect(staleContinuation.items).toEqual([]); + expect(staleContinuation.page.hasMore).toBe(false); + expect(staleContinuation.page.reset).toBe(true); + }); }); diff --git a/packages/middleware/src/__tests__/agent-tool-registry.test.ts b/packages/middleware/src/__tests__/agent-tool-registry.test.ts new file mode 100644 index 00000000..a3e0463f --- /dev/null +++ b/packages/middleware/src/__tests__/agent-tool-registry.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { createToolRegistry } from '../agent/runtime/tool-registry.js'; + +describe('agent tool registry', () => { + it('preserves pagination metadata when aggregating multiple devices', () => { + const registry = createToolRegistry(); + const tool = { + name: 'plugin.list', + description: 'List rows', + inputSchema: { type: 'object' as const, properties: {} }, + pagination: { + kind: 'cursor' as const, + fields: ['id', 'label'], + }, + }; + + registry.registerDevice('device-1', 'iPhone'); + registry.registerDevice('device-2', 'Android'); + registry.registerTools('device-1', [tool]); + registry.registerTools('device-2', [tool]); + + expect(registry.getAggregatedTools()).toEqual([ + expect.objectContaining({ + name: 'plugin.list', + pagination: tool.pagination, + inputSchema: expect.objectContaining({ + required: ['deviceId'], + }), + }), + ]); + }); +}); diff --git a/packages/middleware/src/agent/local-domains.ts b/packages/middleware/src/agent/local-domains.ts index aeed529a..fefc8217 100644 --- a/packages/middleware/src/agent/local-domains.ts +++ b/packages/middleware/src/agent/local-domains.ts @@ -1,6 +1,31 @@ -import type { JSONSchema7, AgentTool } from '@rozenite/agent-shared'; +import type { + JSONSchema7, + AgentTool, + AgentToolPagination, +} from '@rozenite/agent-shared'; import { createReactTreeStore } from './runtime/react/store.js'; import type { ArtifactBucket, ArtifactFileWriter } from './artifacts.js'; +import type { + ReactTreeNode, + ReactNodeSummary, + ReactInspectableEntry, + ReactRenderDataItem, +} from './runtime/react/types.js'; + +/** + * Ties a tool's declared pagination `fields`/`defaultFields` to the keys of + * its actual row type at compile time, so a renamed or removed field on + * `TRow` becomes a build error here instead of a silent `null` column at + * runtime. + */ +const cursorPagination = (config: { + fields: readonly Extract[]; + defaultFields?: readonly Extract[]; +}): AgentToolPagination => ({ + kind: 'cursor', + fields: config.fields, + ...(config.defaultFields ? { defaultFields: config.defaultFields } : {}), +}); type CDPCommandSender = ( method: string, @@ -259,12 +284,29 @@ const isTextLikeMimeType = (mimeType: string | undefined): boolean => { ); }; +/** + * Generic offset-cursor pagination for local domain listings. + * + * `generation` is an optional stamp a caller can bump whenever the + * underlying buffer is invalidated out from under any outstanding cursors + * (e.g. the network domain's capture buffer is wiped on disconnect). When a + * presented cursor's generation doesn't match the current one, the rows it + * indexes into no longer correspond to what the caller had in mind, so this + * returns `page.reset: true` with an empty page instead of silently + * resuming into the new buffer at the same numeric offset — which could + * return unrelated rows, or an empty page indistinguishable from "no more + * results". This is intentionally narrow (a single caller-supplied + * generation counter), not the general shared cursor engine tracked by + * issue #320 — that issue's scope is a cross-domain cursor abstraction, + * which this function does not attempt to be. + */ const paginateRows = ( rows: T[], options: { scope: string; limit: number; cursor?: string; + generation?: number; }, ): { items: T[]; @@ -272,14 +314,21 @@ const paginateRows = ( limit: number; hasMore: boolean; nextCursor?: string; + reset?: boolean; }; } => { let startIndex = 0; if (options.cursor) { + let decoded: { + v: 1; + scope: string; + index: number; + generation?: number; + }; try { - const decoded = JSON.parse( + decoded = JSON.parse( Buffer.from(options.cursor, 'base64url').toString('utf8'), - ) as { v: 1; scope: string; index: number }; + ) as typeof decoded; if ( decoded.v !== 1 || decoded.scope !== options.scope || @@ -288,12 +337,27 @@ const paginateRows = ( ) { throw new Error('Invalid cursor payload'); } - startIndex = decoded.index; } catch { throw new Error( 'Invalid "cursor". Run the command again without cursor to restart pagination.', ); } + + if ( + options.generation !== undefined && + decoded.generation !== options.generation + ) { + return { + items: [], + page: { + limit: options.limit, + hasMore: false, + reset: true, + }, + }; + } + + startIndex = decoded.index; } const endIndex = Math.min(startIndex + options.limit, rows.length); @@ -301,7 +365,14 @@ const paginateRows = ( const hasMore = endIndex < rows.length; const nextCursor = hasMore ? Buffer.from( - JSON.stringify({ v: 1, scope: options.scope, index: endIndex }), + JSON.stringify({ + v: 1, + scope: options.scope, + index: endIndex, + ...(options.generation !== undefined + ? { generation: options.generation } + : {}), + }), 'utf8', ).toString('base64url') : undefined; @@ -355,6 +426,8 @@ const createNetworkSummary = (record: NetworkRequestRecord) => ({ : 'in-flight', }); +type NetworkRequestSummary = ReturnType; + const createNetworkStatus = (state: NetworkRecordingState) => ({ recording: { isRecording: state.isRecording, @@ -663,7 +736,8 @@ export const createReactDomainService = (deps: { properties: { root: { type: 'integer', - description: 'Optional root node ID to scope the tree to a subtree.', + description: + 'Optional root node ID to scope the tree to a subtree.', }, depth: { type: 'integer', @@ -680,6 +754,28 @@ export const createReactDomainService = (deps: { }, }, }, + pagination: cursorPagination({ + fields: [ + 'nodeId', + 'label', + 'displayName', + 'elementType', + 'key', + 'childCount', + 'parentId', + 'parentLabel', + 'childIds', + 'depth', + ], + defaultFields: [ + 'nodeId', + 'label', + 'displayName', + 'elementType', + 'childCount', + 'depth', + ], + }), }, { name: 'getComponent', @@ -757,6 +853,25 @@ export const createReactDomainService = (deps: { }, ...nodeIdentifierRequirement, }, + pagination: cursorPagination({ + fields: [ + 'nodeId', + 'label', + 'displayName', + 'elementType', + 'key', + 'childCount', + 'parentId', + 'parentLabel', + ], + defaultFields: [ + 'nodeId', + 'label', + 'displayName', + 'elementType', + 'childCount', + ], + }), }, { name: 'getProps', @@ -784,6 +899,10 @@ export const createReactDomainService = (deps: { }, ...nodeIdentifierRequirement, }, + pagination: cursorPagination({ + // Only two fields exist; there is nothing sensible to trim. + fields: ['name', 'value'], + }), }, { name: 'getState', @@ -811,6 +930,10 @@ export const createReactDomainService = (deps: { }, ...nodeIdentifierRequirement, }, + pagination: cursorPagination({ + // Only two fields exist; there is nothing sensible to trim. + fields: ['name', 'value'], + }), }, { name: 'getHooks', @@ -845,6 +968,10 @@ export const createReactDomainService = (deps: { }, ...nodeIdentifierRequirement, }, + pagination: cursorPagination({ + // Only two fields exist; there is nothing sensible to trim. + fields: ['name', 'value'], + }), }, { name: 'searchNodes', @@ -877,6 +1004,26 @@ export const createReactDomainService = (deps: { }, required: ['query'], }, + pagination: cursorPagination({ + fields: [ + 'nodeId', + 'label', + 'displayName', + 'elementType', + 'key', + 'childCount', + 'parentId', + 'parentLabel', + ], + defaultFields: [ + 'nodeId', + 'label', + 'displayName', + 'elementType', + 'childCount', + 'parentLabel', + ], + }), }, { name: 'startProfiling', @@ -958,6 +1105,21 @@ export const createReactDomainService = (deps: { }, required: ['rootId', 'commitIndex'], }, + pagination: cursorPagination({ + fields: [ + 'fiberId', + 'actualDurationMs', + 'selfDurationMs', + 'isSlow', + 'changeTypeHints', + ], + defaultFields: [ + 'fiberId', + 'actualDurationMs', + 'selfDurationMs', + 'isSlow', + ], + }), }, ]; @@ -1274,6 +1436,29 @@ export const createNetworkDomainService = (deps: { }, }, }, + pagination: cursorPagination({ + fields: [ + 'requestId', + 'method', + 'url', + 'status', + 'type', + 'startTimeMs', + 'endTimeMs', + 'durationMs', + 'transferSize', + 'encodedDataLength', + 'outcome', + ], + defaultFields: [ + 'requestId', + 'method', + 'url', + 'status', + 'durationMs', + 'outcome', + ], + }), }, { name: 'getRequestDetails', @@ -1639,9 +1824,10 @@ export const createNetworkDomainService = (deps: { .reverse() .map(createNetworkSummary); const page = paginateRows(rows, { - scope: `network:requests:${state.generation}`, + scope: 'network:requests', limit, cursor, + generation: state.generation, }); return { diff --git a/packages/middleware/src/agent/runtime/handler.ts b/packages/middleware/src/agent/runtime/handler.ts index cafb5d7d..5e320906 100644 --- a/packages/middleware/src/agent/runtime/handler.ts +++ b/packages/middleware/src/agent/runtime/handler.ts @@ -1,4 +1,7 @@ -import { AGENT_PLUGIN_ID } from '@rozenite/agent-shared'; +import { + AGENT_PLUGIN_ID, + type AgentToolPagination, +} from '@rozenite/agent-shared'; import { createToolRegistry } from './tool-registry.js'; import type { DevToolsPluginMessage, @@ -9,7 +12,22 @@ import type { ToolResultPayload, } from './types.js'; import { createConsoleLogStore } from './console/store.js'; -import type { ConsoleMessageInput } from './console/types.js'; +import type { ConsoleMessageInput, ConsoleLogEntry } from './console/types.js'; + +/** + * Ties a tool's declared pagination `fields`/`defaultFields` to the keys of + * its actual row type at compile time, so a renamed or removed field on + * `TRow` becomes a build error here instead of a silent `null` column at + * runtime. + */ +const cursorPagination = (config: { + fields: readonly Extract[]; + defaultFields?: readonly Extract[]; +}): AgentToolPagination => ({ + kind: 'cursor', + fields: config.fields, + ...(config.defaultFields ? { defaultFields: config.defaultFields } : {}), +}); const CONSOLE_TOOL_NAMES = { getMessages: 'getMessages', @@ -62,6 +80,20 @@ const CONSOLE_TOOLS: AgentTool[] = [ }, }, }, + pagination: cursorPagination({ + fields: [ + 'seq', + 'timestamp', + 'level', + 'source', + 'text', + 'argsPreview', + 'context', + ], + // Drop the two heaviest columns from the default projection; they + // remain available via --fields/--verbose. + defaultFields: ['seq', 'timestamp', 'level', 'source', 'text'], + }), }, ]; diff --git a/packages/middleware/src/agent/runtime/tool-registry.ts b/packages/middleware/src/agent/runtime/tool-registry.ts index c69c8c63..25421aec 100644 --- a/packages/middleware/src/agent/runtime/tool-registry.ts +++ b/packages/middleware/src/agent/runtime/tool-registry.ts @@ -136,7 +136,7 @@ export const createToolRegistry = () => { const aggregatedTools: AgentTool[] = []; - for (const [toolName, registeredTools] of toolsByName.entries()) { + for (const registeredTools of toolsByName.values()) { const firstTool = registeredTools[0].tool; if (availableDevices.length <= 1 || registeredTools.length === 1) { @@ -163,8 +163,7 @@ export const createToolRegistry = () => { }; aggregatedTools.push({ - name: toolName, - description: firstTool.description, + ...firstTool, inputSchema: modifiedSchema, }); } diff --git a/packages/network-activity-plugin/src/react-native/agent/__tests__/network-activity-agent-state.test.ts b/packages/network-activity-plugin/src/react-native/agent/__tests__/network-activity-agent-state.test.ts index 86bcfba6..8bd5746a 100644 --- a/packages/network-activity-plugin/src/react-native/agent/__tests__/network-activity-agent-state.test.ts +++ b/packages/network-activity-plugin/src/react-native/agent/__tests__/network-activity-agent-state.test.ts @@ -34,6 +34,34 @@ describe('network activity agent state', () => { expect( networkActivityToolDefinitions.getRequestDetails.inputSchema.required, ).toEqual(['requestId']); + expect( + networkActivityToolDefinitions.listRequests.pagination, + ).toMatchObject({ + kind: 'cursor', + fields: expect.arrayContaining(['requestId', 'url', 'status']), + }); + }); + + it('trims listRequests/listRealtimeConnections defaultFields narrower than the full field set', () => { + const listRequestsPagination = + networkActivityToolDefinitions.listRequests.pagination; + const listRealtimeConnectionsPagination = + networkActivityToolDefinitions.listRealtimeConnections.pagination; + + expect(listRequestsPagination?.defaultFields?.length).toBeLessThan( + listRequestsPagination?.fields.length ?? 0, + ); + for (const field of listRequestsPagination?.defaultFields ?? []) { + expect(listRequestsPagination?.fields).toContain(field); + } + + expect( + listRealtimeConnectionsPagination?.defaultFields?.length, + ).toBeLessThan(listRealtimeConnectionsPagination?.fields.length ?? 0); + for (const field of listRealtimeConnectionsPagination?.defaultFields ?? + []) { + expect(listRealtimeConnectionsPagination?.fields).toContain(field); + } }); it('tracks HTTP requests with parity-oriented list/detail/body results', () => { diff --git a/packages/network-activity-plugin/src/shared/agent-tools.ts b/packages/network-activity-plugin/src/shared/agent-tools.ts index da35fa45..21ae1989 100644 --- a/packages/network-activity-plugin/src/shared/agent-tools.ts +++ b/packages/network-activity-plugin/src/shared/agent-tools.ts @@ -1,5 +1,6 @@ import { defineAgentToolContract, + definePaginatedAgentToolContract, type AgentToolContract, } from '@rozenite/agent-shared'; import type { @@ -53,11 +54,13 @@ export type NetworkActivityGetRequestDetailsResult = ReturnType< export type NetworkActivityGetRequestBodyArgs = NetworkActivityRequestIdArgs; -export type NetworkActivityGetRequestBodyResult = NetworkActivityAgentBodyResult; +export type NetworkActivityGetRequestBodyResult = + NetworkActivityAgentBodyResult; export type NetworkActivityGetResponseBodyArgs = NetworkActivityRequestIdArgs; -export type NetworkActivityGetResponseBodyResult = NetworkActivityAgentBodyResult; +export type NetworkActivityGetResponseBodyResult = + NetworkActivityAgentBodyResult; export type NetworkActivityListRealtimeConnectionsArgs = NetworkActivityPaginationArgs; @@ -110,7 +113,7 @@ export const networkActivityToolDefinitions = { properties: {}, }, }), - listRequests: defineAgentToolContract< + listRequests: definePaginatedAgentToolContract< NetworkActivityListRequestsArgs, NetworkActivityListRequestsResult >({ @@ -131,6 +134,30 @@ export const networkActivityToolDefinitions = { }, }, }, + pagination: { + kind: 'cursor', + fields: [ + 'requestId', + 'method', + 'url', + 'status', + 'type', + 'startTimeMs', + 'endTimeMs', + 'durationMs', + 'transferSize', + 'encodedDataLength', + 'outcome', + ], + defaultFields: [ + 'requestId', + 'method', + 'url', + 'status', + 'durationMs', + 'outcome', + ], + }, }), getRequestDetails: defineAgentToolContract< NetworkActivityGetRequestDetailsArgs, @@ -186,7 +213,7 @@ export const networkActivityToolDefinitions = { required: ['requestId'], }, }), - listRealtimeConnections: defineAgentToolContract< + listRealtimeConnections: definePaginatedAgentToolContract< NetworkActivityListRealtimeConnectionsArgs, NetworkActivityListRealtimeConnectionsResult >({ @@ -208,6 +235,30 @@ export const networkActivityToolDefinitions = { }, }, }, + pagination: { + kind: 'cursor', + fields: [ + 'requestId', + 'kind', + 'url', + 'status', + 'startedAt', + 'endedAt', + 'durationMs', + 'messageCount', + 'error', + 'closeCode', + 'httpStatus', + ], + defaultFields: [ + 'requestId', + 'kind', + 'url', + 'status', + 'durationMs', + 'messageCount', + ], + }, }), getRealtimeConnectionDetails: defineAgentToolContract< NetworkActivityGetRealtimeConnectionDetailsArgs, diff --git a/packages/tanstack-query-plugin/src/react-native/agent/__tests__/tanstack-query-agent.test.ts b/packages/tanstack-query-plugin/src/react-native/agent/__tests__/tanstack-query-agent.test.ts index f6086efe..e167987f 100644 --- a/packages/tanstack-query-plugin/src/react-native/agent/__tests__/tanstack-query-agent.test.ts +++ b/packages/tanstack-query-plugin/src/react-native/agent/__tests__/tanstack-query-agent.test.ts @@ -106,10 +106,34 @@ describe('tanstack query agent controller', () => { 'get-mutation-details', 'clear-mutation-cache', ]); - expect(tanstackQueryToolDefinitions.setQueryLoading.inputSchema.required).toEqual([ - 'queryHash', - 'enabled', - ]); + expect( + tanstackQueryToolDefinitions.setQueryLoading.inputSchema.required, + ).toEqual(['queryHash', 'enabled']); + expect(tanstackQueryToolDefinitions.listQueries.pagination).toMatchObject({ + kind: 'cursor', + fields: expect.arrayContaining(['queryHash', 'status', 'fetchStatus']), + }); + }); + + it('trims listQueries/listMutations defaultFields narrower than the full field set', () => { + const listQueriesPagination = + tanstackQueryToolDefinitions.listQueries.pagination; + const listMutationsPagination = + tanstackQueryToolDefinitions.listMutations.pagination; + + expect(listQueriesPagination?.defaultFields?.length).toBeLessThan( + listQueriesPagination?.fields.length ?? 0, + ); + for (const field of listQueriesPagination?.defaultFields ?? []) { + expect(listQueriesPagination?.fields).toContain(field); + } + + expect(listMutationsPagination?.defaultFields?.length).toBeLessThan( + listMutationsPagination?.fields.length ?? 0, + ); + for (const field of listMutationsPagination?.defaultFields ?? []) { + expect(listMutationsPagination?.fields).toContain(field); + } }); it('lists queries with cursor pagination and invalidates stale cursors after additions', async () => { diff --git a/packages/tanstack-query-plugin/src/shared/agent-tools.ts b/packages/tanstack-query-plugin/src/shared/agent-tools.ts index 92735b48..7797d310 100644 --- a/packages/tanstack-query-plugin/src/shared/agent-tools.ts +++ b/packages/tanstack-query-plugin/src/shared/agent-tools.ts @@ -1,9 +1,14 @@ import { defineAgentToolContract, + definePaginatedAgentToolContract, type AgentToolContract, type JSONSchema7, } from '@rozenite/agent-shared'; -import type { FetchStatus, MutationStatus, QueryStatus } from '@tanstack/react-query'; +import type { + FetchStatus, + MutationStatus, + QueryStatus, +} from '@tanstack/react-query'; import type { applyTanStackQueryDevtoolsAction } from '../react-native/devtools-actions'; export const TANSTACK_QUERY_AGENT_PLUGIN_ID = '@rozenite/tanstack-query-plugin'; @@ -166,12 +171,14 @@ export type TanStackQueryGetCacheSummaryArgs = undefined; export type TanStackQueryGetCacheSummaryResult = TanStackQueryCacheSummary; export type TanStackQueryGetOnlineStatusArgs = undefined; export type TanStackQueryGetOnlineStatusResult = { online: boolean }; -export type TanStackQuerySetOnlineStatusArgs = TanStackQueryAgentOnlineStatusInput; +export type TanStackQuerySetOnlineStatusArgs = + TanStackQueryAgentOnlineStatusInput; export type TanStackQuerySetOnlineStatusResult = { online: boolean }; export type TanStackQueryListQueriesArgs = TanStackQueryAgentPaginationInput; export type TanStackQueryGetQueryDetailsArgs = TanStackQueryAgentQueryHashInput; export type TanStackQueryRefetchQueryArgs = TanStackQueryAgentQueryHashInput; -export type TanStackQuerySetQueryLoadingArgs = TanStackQueryAgentQueryToggleInput; +export type TanStackQuerySetQueryLoadingArgs = + TanStackQueryAgentQueryToggleInput; export type TanStackQuerySetQueryErrorArgs = TanStackQueryAgentQueryToggleInput; export type TanStackQueryInvalidateQueryArgs = TanStackQueryAgentQueryHashInput; export type TanStackQueryResetQueryArgs = TanStackQueryAgentQueryHashInput; @@ -179,7 +186,8 @@ export type TanStackQueryRemoveQueryArgs = TanStackQueryAgentQueryHashInput; export type TanStackQueryClearQueryCacheArgs = undefined; export type TanStackQueryClearQueryCacheResult = TanStackQueryActionResult; export type TanStackQueryListMutationsArgs = TanStackQueryAgentPaginationInput; -export type TanStackQueryGetMutationDetailsArgs = TanStackQueryAgentMutationIdInput; +export type TanStackQueryGetMutationDetailsArgs = + TanStackQueryAgentMutationIdInput; export type TanStackQueryClearMutationCacheArgs = undefined; export type TanStackQueryClearMutationCacheResult = TanStackQueryActionResult; @@ -200,7 +208,8 @@ const mutationActionProperties = { const paginationProperties = { limit: { type: 'number', - description: 'Maximum number of items to return. Defaults to 20. Maximum 100.', + description: + 'Maximum number of items to return. Defaults to 20. Maximum 100.', }, cursor: { type: 'string', @@ -243,13 +252,14 @@ export const tanstackQueryToolDefinitions = { properties: { online: { type: 'boolean', - description: 'Whether the TanStack Query onlineManager should be online.', + description: + 'Whether the TanStack Query onlineManager should be online.', }, }, required: ['online'], }, }), - listQueries: defineAgentToolContract< + listQueries: definePaginatedAgentToolContract< TanStackQueryListQueriesArgs, TanStackQueryListQueriesResult >({ @@ -259,6 +269,28 @@ export const tanstackQueryToolDefinitions = { type: 'object', properties: paginationProperties, }, + pagination: { + kind: 'cursor', + fields: [ + 'queryHash', + 'queryKey', + 'status', + 'fetchStatus', + 'observersCount', + 'isInvalidated', + 'dataUpdatedAt', + 'errorUpdatedAt', + 'hasData', + 'hasError', + ], + defaultFields: [ + 'queryHash', + 'queryKey', + 'status', + 'fetchStatus', + 'hasError', + ], + }, }), getQueryDetails: defineAgentToolContract< TanStackQueryGetQueryDetailsArgs, @@ -368,16 +400,31 @@ export const tanstackQueryToolDefinitions = { description: 'Clear the full TanStack Query query cache.', inputSchema: emptyInputSchema, }), - listMutations: defineAgentToolContract< + listMutations: definePaginatedAgentToolContract< TanStackQueryListMutationsArgs, TanStackQueryListMutationsResult >({ name: 'list-mutations', - description: 'List TanStack Query mutation summaries using cursor pagination.', + description: + 'List TanStack Query mutation summaries using cursor pagination.', inputSchema: { type: 'object', properties: paginationProperties, }, + pagination: { + kind: 'cursor', + fields: [ + 'mutationId', + 'mutationKey', + 'status', + 'isPaused', + 'submittedAt', + 'failureCount', + 'hasData', + 'hasError', + ], + defaultFields: ['mutationId', 'mutationKey', 'status', 'hasError'], + }, }), getMutationDetails: defineAgentToolContract< TanStackQueryGetMutationDetailsArgs, diff --git a/website/src/docs/agent/making-your-plugin-agent-enabled.mdx b/website/src/docs/agent/making-your-plugin-agent-enabled.mdx index 44a5dd8b..4bdaf1cf 100644 --- a/website/src/docs/agent/making-your-plugin-agent-enabled.mdx +++ b/website/src/docs/agent/making-your-plugin-agent-enabled.mdx @@ -32,7 +32,10 @@ Each tool is qualified as `{pluginId}.{tool.name}`. The hook registers the tool ### Example: echo tool ```tsx -import { useRozenitePluginAgentTool, type AgentTool } from '@rozenite/agent-bridge'; +import { + useRozenitePluginAgentTool, + type AgentTool, +} from '@rozenite/agent-bridge'; const PLUGIN_ID = '@my-org/my-rozenite-plugin'; @@ -193,6 +196,59 @@ Recommended conventions: - Export any shared value/model types referenced by your public results from `sdk.ts`, not just the descriptor object. - Keep the tool `name`, `description`, and `inputSchema` in the shared contract module so your runtime registration and published SDK surface cannot drift. +### Paginated tools + +Use the shared paginated contract for tools that return cursor-paginated rows. +The declaration travels with the registered tool, so the CLI can validate +`--fields` and use compact columnar output without knowing your plugin ID or +tool name. + +```ts +import { + definePaginatedAgentToolContract, + type PageResult, +} from '@rozenite/agent-bridge'; + +type ListEntriesArgs = { + limit?: number; + cursor?: string; +}; + +type EntryRow = { + key: string; + type: string; + size?: number; +}; + +type ListEntriesResult = PageResult; + +export const listEntriesTool = definePaginatedAgentToolContract< + ListEntriesArgs, + ListEntriesResult +>({ + name: 'list-entries', + description: 'List entries with cursor pagination.', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number' }, + cursor: { type: 'string' }, + }, + }, + pagination: { + kind: 'cursor', + fields: ['key', 'type', 'size'], + defaultFields: ['key', 'type'], + }, +}); +``` + +Return `{items, page}` from the handler. `fields` is the stable set available +to agents, while `defaultFields` is the smaller projection used unless the +caller passes `--fields` or `--verbose`. Both arrays are validated against the +row type, and the CLI never infers columns from observed values. Tools without +pagination metadata keep their original output unchanged. + :::tip You do not need to hand-maintain the `./sdk` export in `package.json`. If your plugin has `sdk.ts`, `rozenite build` will detect it and publish the `./sdk` subpath automatically. :::