Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/columnar-agent-cli-output.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/agent-bridge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ export {
useRozenitePluginAgentTool,
useRozeniteInAppAgentTool,
} from './useRozeniteAgentTool.js';
export {
definePaginatedAgentToolContract,
isAgentToolPagination,
} from '@rozenite/agent-shared';
export type {
UseRozeniteAgentToolOptions,
UseRozenitePluginAgentToolOptions,
Expand All @@ -10,6 +14,9 @@ export type {
export type {
JSONSchema7,
AgentTool,
AgentToolPagination,
PageEnvelope,
PageResult,
AgentMessage,
AgentSessionReadyPayload,
AgentSessionReadyMessage,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bridge/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export { AGENT_PLUGIN_ID } from '@rozenite/agent-shared';
export type {
JSONSchema7,
AgentTool,
AgentToolPagination,
PageEnvelope,
PageResult,
DevToolsPluginMessage,
AgentSessionReadyPayload,
RegisterToolPayload,
Expand Down
23 changes: 23 additions & 0 deletions packages/agent-bridge/src/useRozeniteAgentTool.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, never>,
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();

Expand Down
63 changes: 44 additions & 19 deletions packages/agent-sdk/src/__tests__/domain-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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".',
);
Expand All @@ -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(
Expand Down Expand Up @@ -309,25 +323,36 @@ 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.',
);
});

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,
});
});
});
88 changes: 88 additions & 0 deletions packages/agent-sdk/src/__tests__/domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<MockHttpResult> => {
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;
Expand Down
58 changes: 58 additions & 0 deletions packages/agent-sdk/src/__tests__/paginated-tool-contract.test.ts
Original file line number Diff line number Diff line change
@@ -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<ListArgs, ListResult>({
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<ListArgs, ListResult>({
name: 'list',
description: 'List rows',
inputSchema: { type: 'object', properties: {} },
pagination: {
kind: 'cursor',
fields: ['id'],
defaultFields: ['label'],
},
}),
).toThrow('contain every default field');
});
});
Loading
Loading