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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions packages/agent/src/__tests__/events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

import type { AgentLifecycleEvent } from '@astroid/types';

import { AgentResource } from '../index.js';

function createClientMock() {
return {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
}

function makeEvent(overrides: Partial<AgentLifecycleEvent> = {}): AgentLifecycleEvent {
return {
id: 'evt_1',
type: 'agent.created',
agentId: 'agt_1',
organizationId: 'org_1',
occurredAt: '2026-08-29T10:00:00.000Z',
payload: {
agentId: 'agt_1',
organizationId: 'org_1',
occurredAt: '2026-08-29T10:00:00.000Z',
},
...overrides,
};
}

describe('AgentResource event endpoints', () => {
let http: ReturnType<typeof createClientMock>;
let resource: AgentResource;

beforeEach(() => {
http = createClientMock();
resource = new AgentResource(http as never);
});

it('listEvents() GETs the agent events path with an encoded id', async () => {
http.get.mockResolvedValue({ data: [makeEvent()] });
await resource.listEvents('agt/1');
expect(http.get).toHaveBeenCalledWith('/v1/agents/agt%2F1/events', { query: undefined });
});

it('listEvents() serializes filters and pagination as a query', async () => {
http.get.mockResolvedValue({ data: [makeEvent()] });
await resource.listEvents('agt_1', {
eventTypes: ['agent.created', 'agent.suspended'],
cursor: 'c1',
limit: 25,
order: 'desc',
from: '2026-08-01T00:00:00.000Z',
});
expect(http.get).toHaveBeenCalledWith('/v1/agents/agt_1/events', {
query: {
cursor: 'c1',
limit: 25,
order: 'desc',
from: '2026-08-01T00:00:00.000Z',
eventTypes: 'agent.created,agent.suspended',
},
});
});

it('listEvents() omits empty filters', async () => {
http.get.mockResolvedValue({ data: [makeEvent()] });
await resource.listEvents('agt_1', { eventTypes: [] });
expect(http.get).toHaveBeenCalledWith('/v1/agents/agt_1/events', { query: {} });
});

it('subscribe() POSTs the subscription options to the subscriptions path', async () => {
const subscription = {
id: 'sub_1',
agentId: 'agt_1',
organizationId: 'org_1',
eventTypes: ['agent.budget_exhausted'] as const,
includeHistory: true,
status: 'ACTIVE' as const,
createdAt: '2026-08-29T10:00:00.000Z',
};
http.post.mockResolvedValue(subscription);
const result = await resource.subscribe('agt_1', {
eventTypes: ['agent.budget_exhausted'],
includeHistory: true,
});
expect(http.post).toHaveBeenCalledWith('/v1/agents/agt_1/events/subscriptions', {
eventTypes: ['agent.budget_exhausted'],
includeHistory: true,
});
expect(result).toBe(subscription);
});

it('subscribe() defaults to an empty options payload', async () => {
http.post.mockResolvedValue({});
await resource.subscribe('agt_1');
expect(http.post).toHaveBeenCalledWith('/v1/agents/agt_1/events/subscriptions', {});
});

it('unsubscribe() DELETEs the subscription path with encoded ids', async () => {
http.delete.mockResolvedValue(undefined);
await resource.unsubscribe('agt/1', 'sub/1');
expect(http.delete).toHaveBeenCalledWith('/v1/agents/agt%2F1/events/subscriptions/sub%2F1');
});
});
80 changes: 75 additions & 5 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Resource } from '@astroid/core';
import type {
Agent,
AgentActivity,
AgentLifecycleEventRecord,
AgentEventSubscription,
AgentEventSubscriptionOptions,
AgentStatus,
CreateAgentInput,
Paginated,
AgentLifecycleEvent,
CreateAgentParams,
ListAgentEventsParams,
PaginatedResponse,
PaginationParams,
UpdateAgentParams,
} from '@astroid/types';
import { validateCreateAgentParams } from './validation.js';
Expand Down Expand Up @@ -166,6 +167,75 @@ export class AgentResource extends Resource {
options.signal?.removeEventListener('abort', abort);
};
}

/* ------------------------------------------------------------------------ */
/* Lifecycle event stream */
/* ------------------------------------------------------------------------ */

/**
* Page through an agent's lifecycle events (creation, suspension, resumption,
* budget exhaustion).
*
* @param agentId The unique agent ID.
* @param params Optional event-type filters and pagination.
* @returns A paginated list of lifecycle events.
*/
async listEvents(
agentId: string,
params?: ListAgentEventsParams,
): Promise<PaginatedResponse<AgentLifecycleEvent>> {
return this.client.get<PaginatedResponse<AgentLifecycleEvent>>(
`/v1/agents/${encodeURIComponent(agentId)}/events`,
{ query: toAgentEventQuery(params) },
);
}

/**
* Create a subscription to an agent's lifecycle event stream.
*
* @param agentId The unique agent ID.
* @param options Which event types to receive and whether to replay history.
* @returns The created subscription.
*/
async subscribe(
agentId: string,
options: AgentEventSubscriptionOptions = {},
): Promise<AgentEventSubscription> {
return this.client.post<AgentEventSubscription>(
`/v1/agents/${encodeURIComponent(agentId)}/events/subscriptions`,
options,
);
}

/**
* Remove a subscription to an agent's lifecycle event stream.
*
* @param agentId The unique agent ID.
* @param subscriptionId The subscription to remove.
*/
async unsubscribe(agentId: string, subscriptionId: string): Promise<void> {
await this.client.delete<void>(
`/v1/agents/${encodeURIComponent(agentId)}/events/subscriptions/${encodeURIComponent(subscriptionId)}`,
);
}
}

/**
* Drop `undefined` / `null` entries so they never reach the query string, and
* serialise event-type filters as a comma-separated list.
*/
function toAgentEventQuery(params?: ListAgentEventsParams): Record<string, string | number | boolean> | undefined {
if (!params) return undefined;
const query: Record<string, string | number | boolean> = {};
if (params.cursor !== undefined) query['cursor'] = params.cursor;
if (params.limit !== undefined) query['limit'] = params.limit;
if (params.order !== undefined) query['order'] = params.order;
if (params.from !== undefined) query['from'] = params.from;
if (params.to !== undefined) query['to'] = params.to;
if (params.eventTypes !== undefined && params.eventTypes.length > 0) {
query['eventTypes'] = params.eventTypes.join(',');
}
return query;
}

/** Alias of {@link AgentResource} matching the `*sResource` client naming. */
Expand Down
37 changes: 37 additions & 0 deletions packages/analytics/__tests__/resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,41 @@ describe('AnalyticsResource', () => {

expect(mockGet).toHaveBeenCalledWith('/analytics/summary?timeframe=day&asset=XLM');
});

it('serializes pagination params on getMetrics', async () => {
const mockGet = vi.fn().mockResolvedValue({ points: [], summary: { timeframe: 'day', totalVolume: '0', totalFees: '0', transactionCount: 0, successRate: 0, averageLatencyMs: 0 } });
const client = { get: mockGet } as unknown as HttpClient;
const resource = new AnalyticsResource(client);

await resource.getMetrics({ asset: 'USDC', cursor: 'c1', limit: 50, order: 'desc' });

const calledUrl = mockGet.mock.calls[0]![0];
expect(calledUrl).toContain('/analytics/metrics?');
expect(calledUrl).toContain('cursor=c1');
expect(calledUrl).toContain('limit=50');
expect(calledUrl).toContain('order=desc');
});

it('omits pagination params when undefined', async () => {
const mockGet = vi.fn().mockResolvedValue({ points: [], summary: { timeframe: 'day', totalVolume: '0', totalFees: '0', transactionCount: 0, successRate: 0, averageLatencyMs: 0 } });
const client = { get: mockGet } as unknown as HttpClient;
const resource = new AnalyticsResource(client);

await resource.getMetrics({ asset: 'USDC' });

const calledUrl = mockGet.mock.calls[0]![0];
expect(calledUrl).not.toContain('cursor=');
expect(calledUrl).not.toContain('limit=');
expect(calledUrl).not.toContain('order=');
});

it('serializes pagination params on getVolumeSummary', async () => {
const mockGet = vi.fn().mockResolvedValue({ timeframe: 'day', totalVolume: '50', totalFees: '0.5', transactionCount: 5, successRate: 0.8, averageLatencyMs: 200 });
const client = { get: mockGet } as unknown as HttpClient;
const resource = new AnalyticsResource(client);

await resource.getVolumeSummary({ timeframe: 'day', cursor: 'c2', limit: 10, order: 'asc' });

expect(mockGet).toHaveBeenCalledWith('/analytics/summary?timeframe=day&cursor=c2&limit=10&order=asc');
});
});
56 changes: 33 additions & 23 deletions packages/analytics/src/metrics.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import type { HttpClient } from '@astroid/core';
import type {
AnalyticsOverview,
AnalyticsQueryParams,
AnalyticsMetricsResponse,
VolumeSummary,
} from '@astroid/types';
import type { AnalyticsQueryParams, AnalyticsMetricsResponse, PaginationParams, VolumeSummary } from '@astroid/types';

/**
* Serialize analytics query parameters — including pagination — into a
* URLSearchParams instance, skipping undefined values.
*/
function toSearchParams(params?: AnalyticsQueryParams): URLSearchParams {
const searchParams = new URLSearchParams();
if (!params) return searchParams;
if (params.startDate) searchParams.set('startDate', params.startDate);
if (params.endDate) searchParams.set('endDate', params.endDate);
if (params.timeframe) searchParams.set('timeframe', params.timeframe);
if (params.asset) searchParams.set('asset', params.asset);
if (params.walletId) searchParams.set('walletId', params.walletId);
if (params.agentId) searchParams.set('agentId', params.agentId);
// Standard pagination arguments shared across resource packages.
if (params.cursor) searchParams.set('cursor', params.cursor);
if (params.limit !== undefined) searchParams.set('limit', String(params.limit));
if (params.order) searchParams.set('order', params.order);
return searchParams;
}

export class AnalyticsResource {
constructor(private readonly client: HttpClient) {}

/**
* Fetch time-series analytics metrics matching the given parameters.
*
* Accepts optional {@link PaginationParams} (cursor / limit / order) for
* paging through large metric sets.
*/
async getMetrics(params?: AnalyticsQueryParams): Promise<AnalyticsMetricsResponse> {
const searchParams = new URLSearchParams();
if (params) {
if (params.startDate) searchParams.set('startDate', params.startDate);
if (params.endDate) searchParams.set('endDate', params.endDate);
if (params.timeframe) searchParams.set('timeframe', params.timeframe);
if (params.asset) searchParams.set('asset', params.asset);
if (params.walletId) searchParams.set('walletId', params.walletId);
if (params.agentId) searchParams.set('agentId', params.agentId);
}

const searchParams = toSearchParams(params);
const query = searchParams.toString();
const path = query ? `/analytics/metrics?${query}` : '/analytics/metrics';
const res = await this.client.get<AnalyticsMetricsResponse>(path);
Expand All @@ -31,18 +40,19 @@ export class AnalyticsResource {

/**
* Fetch summary statistics for a given timeframe or query.
*
* Accepts optional {@link PaginationParams} (cursor / limit / order) when a
* params object is supplied.
*/
async getVolumeSummary(timeframeOrParams?: string | AnalyticsQueryParams): Promise<VolumeSummary> {
const searchParams = new URLSearchParams();
if (typeof timeframeOrParams === 'string') {
searchParams.set('timeframe', timeframeOrParams);
} else if (timeframeOrParams) {
if (timeframeOrParams.startDate) searchParams.set('startDate', timeframeOrParams.startDate);
if (timeframeOrParams.endDate) searchParams.set('endDate', timeframeOrParams.endDate);
if (timeframeOrParams.timeframe) searchParams.set('timeframe', timeframeOrParams.timeframe);
if (timeframeOrParams.asset) searchParams.set('asset', timeframeOrParams.asset);
if (timeframeOrParams.walletId) searchParams.set('walletId', timeframeOrParams.walletId);
if (timeframeOrParams.agentId) searchParams.set('agentId', timeframeOrParams.agentId);
} else {
const merged = toSearchParams(timeframeOrParams);
for (const [key, value] of merged.entries()) {
searchParams.set(key, value);
}
}

const query = searchParams.toString();
Expand Down
60 changes: 60 additions & 0 deletions packages/budget/src/__tests__/budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ describe('BudgetClient', () => {
expect(http.get).toHaveBeenCalledWith('/v1/budgets/bud%2F1', {});
});

it('getBudget() aliases get()', async () => {
const budget = makeBudget();
http.get.mockResolvedValue(budget);
const result = await client.getBudget('bud_1');
expect(http.get).toHaveBeenCalledWith('/v1/budgets/bud_1', {});
expect(result).toBe(budget);
});

it('list() forwards filters as a query', async () => {
http.get.mockResolvedValue({ data: [makeBudget()] });
await client.list({ agentId: 'agt_1', enabled: true, limit: 50, cursor: undefined });
Expand All @@ -96,6 +104,58 @@ describe('BudgetClient', () => {
});
});

it('listBudgets() aliases list() with filters', async () => {
http.get.mockResolvedValue({ data: [makeBudget()] });
await client.listBudgets({ agentId: 'agt_1', limit: 25 });
expect(http.get).toHaveBeenCalledWith('/v1/budgets', {
query: { agentId: 'agt_1', limit: 25 },
});
});

it('simulateBudgetCheck() POSTs the spend to the simulate endpoint and reports allowed spends', async () => {
const result = {
budgetId: 'bud_1',
allowed: true,
wouldExceed: false,
afterRemaining: '750',
utilizationAfter: 0.25,
state: 'healthy',
violations: [],
explanation: 'Spend is within the budget limit.',
};
http.post.mockResolvedValue(result);
const res = await client.simulateBudgetCheck('bud_1', { asset: 'USDC', amount: '250' });
expect(http.post).toHaveBeenCalledWith('/v1/budgets/bud_1/simulate', {
asset: 'USDC',
amount: '250',
});
expect(res).toBe(result);
expect(res.allowed).toBe(true);
});

it('simulateBudgetCheck() reports a limit breach', async () => {
const result = {
budgetId: 'bud_1',
allowed: false,
wouldExceed: true,
afterRemaining: '0',
utilizationAfter: 1,
state: 'exhausted',
violations: ['Spend would exceed the monthly budget limit of 1000.'],
explanation: 'Spend would exceed the monthly budget limit of 1000.',
};
http.post.mockResolvedValue(result);
const res = await client.simulateBudgetCheck('bud_1', { asset: 'USDC', amount: '9999' });
expect(http.post).toHaveBeenCalledWith('/v1/budgets/bud_1/simulate', {
asset: 'USDC',
amount: '9999',
});
expect(res.allowed).toBe(false);
expect(res.wouldExceed).toBe(true);
expect(res.state).toBe('exhausted');
expect(res.violations.length).toBeGreaterThan(0);
});

it('update() PATCHes the budget', async () => {
http.patch.mockResolvedValue(makeBudget({ name: 'Renamed' }));
const result = await client.update('bud_1', { name: 'Renamed' });
Expand Down
Loading
Loading