From be944daf715eda5445c4f566b2ce2095cbb0c383 Mon Sep 17 00:00:00 2001 From: Careless Dept <336442357+carelessdept@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:13:55 +0530 Subject: [PATCH] fix(agent): validate sdk-agent status and payment tool-call payloads - define and export AgentStatus from @wraith-protocol/sdk-agent - validate /status, /chat, and scan_payments tool-call JSON payloads at runtime - add and export AgentResponseError (WRAITH/NETWORK/AGENT_RESPONSE) for invalid server responses - add unit and compile-time type tests for status and payment payload validation Closes #238 --- CHANGELOG.md | 2 + docs/errors.md | 2 + etc/sdk.api.md | 10 + packages/sdk-agent/README.md | 5 + packages/sdk-agent/src/client.ts | 224 +++++++++++++-- packages/sdk-agent/src/index.ts | 11 +- packages/sdk-agent/src/types.ts | 5 + packages/sdk-agent/test/client.test.ts | 324 +++++++++++++++++++++- src/errors.ts | 24 ++ src/index.ts | 1 + src/types/@wraith-protocol/sdk-agent.d.ts | 11 +- test/errors.test.ts | 29 ++ tsconfig.test-types.json | 7 +- 13 files changed, 621 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478b4c0..f493464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,8 @@ All notable changes to the Wraith Protocol SDK will be documented in this file. - **Stellar `findStrictReceivePath` reads and validates real Horizon responses** (issue #236): the function now reads paths from Horizon's `_embedded.records` envelope instead of expecting a bare array, and sends the query parameters Horizon accepts (`source_assets` with `destination_asset_type` / `_code` / `_issuer`; the old `destination_asset` and dummy `source_account` made every live request fail with HTTP 400). - Every record is validated before use: amounts, asset type / code / issuer, that the source and destination assets and `destination_amount` match the request, and a path of at most 5 hops. The body is read as a stream and abandoned past 256 KiB; responses over 100 records are rejected. The cheapest valid record is returned. An invalid `destAmount` now throws before the request, and the request uses the Horizon client's default timeouts (`RPCTimeoutError`). - New `HorizonResponseError` (`WRAITH/NETWORK/HORIZON_RESPONSE`), exported from the package root, carries the URL and the rejected field. A non-2xx status now throws `RPCRequestError` instead of a plain `Error`. +- **Typed `sdk-agent` status and payment tool-call validation** (issue #238): `WraithAgent.getStatus()` now returns a typed `Promise` instead of `Promise`, and both `/status` payloads and `scan_payments` tool-call JSON payloads are validated at runtime before use. + - New `AgentResponseError` (`WRAITH/NETWORK/AGENT_RESPONSE`), exported from both `@wraith-protocol/sdk` and `@wraith-protocol/sdk-agent`, is thrown when an agent endpoint returns invalid JSON, a malformed status object, or invalid payment records in `scanPayments()`. ### Changed diff --git a/docs/errors.md b/docs/errors.md index 0cfcd7e..4a54488 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -24,6 +24,7 @@ WraithError (Abstract Base) ├── WraithNetworkError │ ├── RPCRequestError │ ├── HorizonResponseError +│ ├── AgentResponseError │ ├── RPCRetryExhaustedError │ ├── RPCTimeoutError │ └── RetentionExceededError @@ -83,6 +84,7 @@ Thrown when HTTP queries to Wraith APIs, Horizon/Soroban endpoints, Solana clust | :----------------------- | :------------------------------------- | :------------------------------------------------- | :--------------------------------------------------------------------------------------------------- | | `RPCRequestError` | `"WRAITH/NETWORK/RPC_REQUEST"` | `url`, `statusCode`, `responseText` | Thrown when an HTTP/RPC endpoint returns a non-2xx status code. | | `HorizonResponseError` | `"WRAITH/NETWORK/HORIZON_RESPONSE"` | `url`, `field`, `reason` | Thrown when Horizon answers 2xx but the body is oversized, malformed, or does not match the request. | +| `AgentResponseError` | `"WRAITH/NETWORK/AGENT_RESPONSE"` | `url`, `field`, `reason` | Thrown when the Wraith Agent API returns malformed JSON or invalid status/payment payloads. | | `RPCRetryExhaustedError` | `"WRAITH/NETWORK/RPC_RETRY_EXHAUSTED"` | `url`, `attempts`, `lastError` | Thrown when all query retry strategies have timed out or failed. | | `RPCTimeoutError` | `"WRAITH/NETWORK/RPC_TIMEOUT"` | `url`, `endpoint`, `attempt`, `phase`, `timeoutMs` | Thrown when a Horizon/Soroban attempt exceeds its connect or request timeout. | | `RetentionExceededError` | `"WRAITH/NETWORK/RETENTION_EXCEEDED"` | `limit`, `actual` | Thrown when querying historical logs beyond maximum retention boundaries. | diff --git a/etc/sdk.api.md b/etc/sdk.api.md index 7bcabef..4d24fab 100644 --- a/etc/sdk.api.md +++ b/etc/sdk.api.md @@ -4,6 +4,16 @@ ```ts +// @public +export class AgentResponseError extends WraithNetworkError { + constructor(url: string, field: string, reason: string); + // (undocumented) + readonly code = "WRAITH/NETWORK/AGENT_RESPONSE"; + // (undocumented) + describe(): string; + readonly field: string; +} + // @public export function assertWalletNetwork(adapter: WalletAdapter, expectedNetwork: string): Promise; diff --git a/packages/sdk-agent/README.md b/packages/sdk-agent/README.md index b5619fa..a40b170 100644 --- a/packages/sdk-agent/README.md +++ b/packages/sdk-agent/README.md @@ -223,6 +223,11 @@ interface Balance { tokens: Record; } +interface AgentStatus { + balance: string; + tokens: Record; +} + interface Payment { stealthAddress: string; balance: string; diff --git a/packages/sdk-agent/src/client.ts b/packages/sdk-agent/src/client.ts index f81bb05..b269430 100644 --- a/packages/sdk-agent/src/client.ts +++ b/packages/sdk-agent/src/client.ts @@ -1,9 +1,11 @@ -import { RPCRequestError } from '@wraith-protocol/sdk'; +import { AgentResponseError, RPCRequestError } from '@wraith-protocol/sdk'; import type { WraithConfig, AgentConfig, AgentInfo, + AgentStatus, ChatResponse, + ToolCall, Balance, Payment, Notification, @@ -11,8 +13,175 @@ import type { } from './types'; import { Chain } from './types'; +export { AgentResponseError, RPCRequestError }; + const DEFAULT_BASE_URL = 'https://api.wraith.dev'; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * Validates a raw `/agent/:id/status` response payload at runtime. + * + * @param payload Parsed JSON value from the server. + * @param url Endpoint URL for error diagnostics. + * @returns Validated {@link AgentStatus}. + * @throws {AgentResponseError} If the payload is not an object or has invalid `balance` or `tokens`. + */ +export function validateAgentStatus(payload: unknown, url: string): AgentStatus { + if (!isRecord(payload)) { + throw new AgentResponseError(url, 'body', 'expected a JSON object'); + } + + if (!isNonEmptyString(payload.balance)) { + throw new AgentResponseError(url, 'balance', 'must be a non-empty string'); + } + + if (!isRecord(payload.tokens)) { + throw new AgentResponseError( + url, + 'tokens', + 'must be an object mapping token symbols to balance strings', + ); + } + + const tokens: Record = {}; + for (const [symbol, amount] of Object.entries(payload.tokens)) { + if (!isNonEmptyString(symbol)) { + throw new AgentResponseError(url, 'tokens', 'token symbol must be a non-empty string'); + } + if (!isNonEmptyString(amount)) { + throw new AgentResponseError(url, `tokens.${symbol}`, 'must be a non-empty string'); + } + tokens[symbol] = amount; + } + + return { + balance: payload.balance, + tokens, + }; +} + +/** + * Validates a single stealth payment record at runtime. + * + * @param payload Candidate payment record. + * @param url Endpoint URL for error diagnostics. + * @param field Field path within the response. + * @returns Validated {@link Payment}. + * @throws {AgentResponseError} If any required field is missing or not a non-empty string. + */ +export function validatePayment(payload: unknown, url: string, field = 'payment'): Payment { + if (!isRecord(payload)) { + throw new AgentResponseError(url, field, 'payment record must be an object'); + } + + if (!isNonEmptyString(payload.stealthAddress)) { + throw new AgentResponseError(url, `${field}.stealthAddress`, 'must be a non-empty string'); + } + + if (!isNonEmptyString(payload.balance)) { + throw new AgentResponseError(url, `${field}.balance`, 'must be a non-empty string'); + } + + if (!isNonEmptyString(payload.ephemeralPubKey)) { + throw new AgentResponseError(url, `${field}.ephemeralPubKey`, 'must be a non-empty string'); + } + + return { + stealthAddress: payload.stealthAddress, + balance: payload.balance, + ephemeralPubKey: payload.ephemeralPubKey, + }; +} + +/** + * Parses and validates a `scan_payments` tool-call `detail` JSON payload into {@link Payment} records. + * + * @param detail Raw `detail` field from a `scan_payments` tool call. + * @param url Endpoint URL for error diagnostics. + * @param field Field path for the tool call's `detail` property. + * @returns Validated array of {@link Payment} records. + * @throws {AgentResponseError} If `detail` is missing, invalid JSON, not an array, or contains invalid records. + */ +export function parseToolCallPayments(detail: unknown, url: string, field = 'detail'): Payment[] { + if (!isNonEmptyString(detail)) { + throw new AgentResponseError(url, field, 'must be a non-empty JSON string'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(detail); + } catch { + throw new AgentResponseError(url, field, 'not valid JSON'); + } + + if (!Array.isArray(parsed)) { + throw new AgentResponseError(url, field, 'expected a JSON array of payment records'); + } + + return parsed.map((entry, index) => validatePayment(entry, url, `${field}[${index}]`)); +} + +function validateToolCall(payload: unknown, url: string, field: string): ToolCall { + if (!isRecord(payload)) { + throw new AgentResponseError(url, field, 'tool call must be an object'); + } + + if (!isNonEmptyString(payload.name)) { + throw new AgentResponseError(url, `${field}.name`, 'must be a non-empty string'); + } + + if (!isNonEmptyString(payload.status)) { + throw new AgentResponseError(url, `${field}.status`, 'must be a non-empty string'); + } + + if (payload.detail !== undefined && typeof payload.detail !== 'string') { + throw new AgentResponseError(url, `${field}.detail`, 'must be a string when present'); + } + + return { + name: payload.name, + status: payload.status, + ...(payload.detail !== undefined ? { detail: payload.detail } : {}), + }; +} + +function validateChatResponse(payload: unknown, url: string): ChatResponse { + if (!isRecord(payload)) { + throw new AgentResponseError(url, 'body', 'expected a JSON object'); + } + + if (typeof payload.response !== 'string') { + throw new AgentResponseError(url, 'response', 'must be a string'); + } + + if (!isNonEmptyString(payload.conversationId)) { + throw new AgentResponseError(url, 'conversationId', 'must be a non-empty string'); + } + + let toolCalls: ToolCall[] | undefined; + if (payload.toolCalls !== undefined) { + if (!Array.isArray(payload.toolCalls)) { + throw new AgentResponseError(url, 'toolCalls', 'must be an array'); + } + toolCalls = payload.toolCalls.map((tc, index) => + validateToolCall(tc, url, `toolCalls[${index}]`), + ); + } + + return { + response: payload.response, + ...(toolCalls !== undefined ? { toolCalls } : {}), + conversationId: payload.conversationId, + }; +} + export class Wraith { private readonly apiKey: string; private readonly baseUrl: string; @@ -39,11 +208,19 @@ export class Wraith { }); if (!res.ok) { - const error = await res.json().catch(() => ({ message: res.statusText })); - throw new RPCRequestError(url, res.status, error.message || res.statusText); + const error = (await res.json().catch(() => null)) as unknown; + const message = + isRecord(error) && typeof error.message === 'string' && error.message.length > 0 + ? error.message + : res.statusText; + throw new RPCRequestError(url, res.status, message); } - return res.json(); + try { + return (await res.json()) as T; + } catch { + throw new AgentResponseError(url, 'body', 'not valid JSON'); + } } /** @@ -106,6 +283,11 @@ export class Wraith { _request(method: string, path: string, body?: unknown): Promise { return this.request(method, path, body); } + + /** @internal Returns the normalized base URL for error reporting. */ + _getBaseUrl(): string { + return this.baseUrl; + } } export class WraithAgent { @@ -121,30 +303,38 @@ export class WraithAgent { return this.wraith._request(method, `/agent/${this.info.id}${path}`, body); } + private endpointUrl(path: string): string { + return `${this.wraith._getBaseUrl()}/agent/${this.info.id}${path}`; + } + async chat(message: string, conversationId?: string): Promise { - return this.req('POST', '/chat', { message, conversationId }); + const path = '/chat'; + const data = await this.req('POST', path, { message, conversationId }); + return validateChatResponse(data, this.endpointUrl(path)); } - async getStatus(): Promise { - return this.req('GET', '/status'); + async getStatus(): Promise { + const path = '/status'; + const data = await this.req('GET', path); + return validateAgentStatus(data, this.endpointUrl(path)); } async getBalance(): Promise { const status = await this.getStatus(); - return { native: status.balance || '0', tokens: status.tokens || {} }; + return { native: status.balance, tokens: status.tokens }; } async scanPayments(): Promise { + const url = this.endpointUrl('/chat'); const res = await this.chat('Scan for incoming stealth payments'); - return (res.toolCalls || []) - .filter((tc) => tc.name === 'scan_payments') - .flatMap((tc) => { - try { - return JSON.parse(tc.detail || '[]'); - } catch { - return []; - } - }); + const payments: Payment[] = []; + + for (const [index, tc] of (res.toolCalls ?? []).entries()) { + if (tc.name !== 'scan_payments') continue; + payments.push(...parseToolCallPayments(tc.detail, url, `toolCalls[${index}].detail`)); + } + + return payments; } async exportKey(signature: string, message: string): Promise<{ secret: string }> { diff --git a/packages/sdk-agent/src/index.ts b/packages/sdk-agent/src/index.ts index 4b04ce2..44e49e3 100644 --- a/packages/sdk-agent/src/index.ts +++ b/packages/sdk-agent/src/index.ts @@ -1,9 +1,18 @@ -export { Wraith, WraithAgent } from './client'; +export { + Wraith, + WraithAgent, + AgentResponseError, + RPCRequestError, + validateAgentStatus, + validatePayment, + parseToolCallPayments, +} from './client'; export { Chain } from './types'; export type { WraithConfig, AgentConfig, AgentInfo, + AgentStatus, ChatResponse, ToolCall, Balance, diff --git a/packages/sdk-agent/src/types.ts b/packages/sdk-agent/src/types.ts index 1036b7a..207f851 100644 --- a/packages/sdk-agent/src/types.ts +++ b/packages/sdk-agent/src/types.ts @@ -50,6 +50,11 @@ export interface Balance { tokens: Record; } +export interface AgentStatus { + balance: string; + tokens: Record; +} + export interface Payment { stealthAddress: string; balance: string; diff --git a/packages/sdk-agent/test/client.test.ts b/packages/sdk-agent/test/client.test.ts index 4d41085..db019f5 100644 --- a/packages/sdk-agent/test/client.test.ts +++ b/packages/sdk-agent/test/client.test.ts @@ -1,8 +1,16 @@ -import { describe, test, expect, beforeAll, afterAll } from 'vitest'; +import { describe, test, expect, expectTypeOf, beforeAll, afterAll, beforeEach } from 'vitest'; import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'http'; -import { Wraith, WraithAgent } from '../src/client'; +import { + Wraith, + WraithAgent, + AgentResponseError, + RPCRequestError, + validateAgentStatus, + validatePayment, + parseToolCallPayments, +} from '../src/client'; import { Chain } from '../src/types'; -import type { AgentInfo } from '../src/types'; +import type { AgentInfo, AgentStatus, Payment } from '../src/types'; const TEST_API_KEY = 'wraith_test_key123'; const AGENT_ID = 'agent-001'; @@ -17,6 +25,9 @@ const mockAgentInfo: AgentInfo = { let server: Server; let baseUrl: string; +let nextStatusPayload: unknown; +let nextStatusRawBody: string | null = null; +let nextChatPayload: unknown; let lastRequest: { method: string; url: string; @@ -40,6 +51,16 @@ function parseBody(req: IncomingMessage): Promise { }); } +beforeEach(() => { + nextStatusPayload = { balance: '1.5', tokens: { USDC: '100' } }; + nextStatusRawBody = null; + nextChatPayload = { + response: 'Hello! How can I help?', + toolCalls: [], + conversationId: 'conv-001', + }; +}); + beforeAll(async () => { server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const body = await parseBody(req); @@ -58,18 +79,16 @@ beforeAll(async () => { } if (req.url === `/agent/${AGENT_ID}/chat` && req.method === 'POST') { - res.end( - JSON.stringify({ - response: 'Hello! How can I help?', - toolCalls: [], - conversationId: 'conv-001', - }), - ); + res.end(JSON.stringify(nextChatPayload)); return; } if (req.url === `/agent/${AGENT_ID}/status` && req.method === 'GET') { - res.end(JSON.stringify({ balance: '1.5', tokens: { USDC: '100' } })); + if (nextStatusRawBody !== null) { + res.end(nextStatusRawBody); + } else { + res.end(JSON.stringify(nextStatusPayload)); + } return; } @@ -223,9 +242,10 @@ describe('Wraith', () => { expect(agent.info.id).toBe(AGENT_ID); }); - test('HTTP error throws with server message', async () => { + test('HTTP error throws RPCRequestError with server message', async () => { const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + await expect(wraith._request('GET', '/error-test')).rejects.toThrow(RPCRequestError); await expect(wraith._request('GET', '/error-test')).rejects.toThrow('Bad request'); }); }); @@ -249,14 +269,290 @@ describe('WraithAgent', () => { expect(lastRequest.body.message).toBe('hello'); }); - test('getBalance returns formatted balance', async () => { + test('getStatus returns typed AgentStatus for a valid payload', async () => { const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); const agent = wraith.agent(AGENT_ID); - const balance = await agent.getBalance(); + const statusPromise = agent.getStatus(); + expectTypeOf(statusPromise).toEqualTypeOf>(); + + const status = await statusPromise; + expect(status).toEqual({ balance: '1.5', tokens: { USDC: '100' } }); + }); + + test('getStatus throws AgentResponseError when response body is not valid JSON', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + nextStatusRawBody = '<<>>'; + + await expect(agent.getStatus()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: 'body', + }); + }); + + test('getStatus throws AgentResponseError when payload is not an object', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + nextStatusPayload = ['not', 'an', 'object']; + + await expect(agent.getStatus()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: 'body', + }); + }); + + test('getStatus throws AgentResponseError when balance is missing or not a non-empty string', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + for (const invalidBalance of [undefined, null, 1.5, '', ' ', {}]) { + nextStatusPayload = { balance: invalidBalance, tokens: { USDC: '100' } }; + await expect(agent.getStatus()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: 'balance', + }); + } + }); + + test('getStatus throws AgentResponseError when tokens is missing or malformed', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + + for (const invalidTokens of [undefined, null, ['USDC'], '100']) { + nextStatusPayload = { balance: '1.5', tokens: invalidTokens }; + await expect(agent.getStatus()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: 'tokens', + }); + } + + nextStatusPayload = { balance: '1.5', tokens: { USDC: 100 } }; + await expect(agent.getStatus()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: 'tokens.USDC', + }); + + nextStatusPayload = { balance: '1.5', tokens: { USDC: '' } }; + await expect(agent.getStatus()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: 'tokens.USDC', + }); + }); + + test('getBalance returns formatted balance and propagates status validation errors', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + + const balance = await agent.getBalance(); expect(balance.native).toBe('1.5'); expect(balance.tokens.USDC).toBe('100'); + + nextStatusPayload = { balance: 123, tokens: {} }; + await expect(agent.getBalance()).rejects.toThrow(AgentResponseError); + }); + + test('scanPayments parses and validates tool-call payment records', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + + const validPayments: Payment[] = [ + { + stealthAddress: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + balance: '10.5', + ephemeralPubKey: '0x1111111111111111111111111111111111111111111111111111111111111111', + }, + { + stealthAddress: '0x1234567890abcdef1234567890abcdef12345678', + balance: '0.25', + ephemeralPubKey: '0x02abcdef', + }, + ]; + + nextChatPayload = { + response: 'Found 2 incoming payments.', + conversationId: 'conv-scan-1', + toolCalls: [ + { name: 'resolve_name', status: 'completed', detail: 'non-json-ignored-for-other-tools' }, + { + name: 'scan_payments', + status: 'completed', + detail: JSON.stringify(validPayments), + }, + ], + }; + + const paymentsPromise = agent.scanPayments(); + expectTypeOf(paymentsPromise).toEqualTypeOf>(); + + const payments = await paymentsPromise; + expect(payments).toEqual(validPayments); + }); + + test('scanPayments returns empty array when no scan_payments tool call is present', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + + nextChatPayload = { + response: 'No payments found.', + conversationId: 'conv-scan-empty', + }; + await expect(agent.scanPayments()).resolves.toEqual([]); + }); + + test('scanPayments throws AgentResponseError when scan_payments detail is missing or invalid JSON', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + + nextChatPayload = { + response: 'Scanned', + conversationId: 'conv-bad-detail', + toolCalls: [{ name: 'scan_payments', status: 'completed' }], + }; + await expect(agent.scanPayments()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: 'toolCalls[0].detail', + }); + + nextChatPayload = { + response: 'Scanned', + conversationId: 'conv-bad-json', + toolCalls: [{ name: 'scan_payments', status: 'completed', detail: '{malformed-json' }], + }; + await expect(agent.scanPayments()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: 'toolCalls[0].detail', + }); + }); + + test('scanPayments throws AgentResponseError when parsed detail is not an array', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + + nextChatPayload = { + response: 'Scanned', + conversationId: 'conv-non-array', + toolCalls: [ + { + name: 'scan_payments', + status: 'completed', + detail: JSON.stringify({ + stealthAddress: 'GAAA', + balance: '10', + ephemeralPubKey: '0x11', + }), + }, + ], + }; + + await expect(agent.scanPayments()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: 'toolCalls[0].detail', + }); + }); + + test('scanPayments throws AgentResponseError when any payment record has missing or wrong-type fields', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + + const invalidCases: Array<{ record: unknown; expectedField: string }> = [ + { record: null, expectedField: 'toolCalls[0].detail[0]' }, + { record: 'not-an-object', expectedField: 'toolCalls[0].detail[0]' }, + { + record: { balance: '10', ephemeralPubKey: '0x11' }, + expectedField: 'toolCalls[0].detail[0].stealthAddress', + }, + { + record: { stealthAddress: 123, balance: '10', ephemeralPubKey: '0x11' }, + expectedField: 'toolCalls[0].detail[0].stealthAddress', + }, + { + record: { stealthAddress: '0xabc', balance: 10, ephemeralPubKey: '0x11' }, + expectedField: 'toolCalls[0].detail[0].balance', + }, + { + record: { stealthAddress: '0xabc', balance: '10', ephemeralPubKey: '' }, + expectedField: 'toolCalls[0].detail[0].ephemeralPubKey', + }, + ]; + + for (const { record, expectedField } of invalidCases) { + nextChatPayload = { + response: 'Scanned', + conversationId: 'conv-invalid-record', + toolCalls: [ + { + name: 'scan_payments', + status: 'completed', + detail: JSON.stringify([record]), + }, + ], + }; + + await expect(agent.scanPayments()).rejects.toMatchObject({ + name: 'AgentResponseError', + code: 'WRAITH/NETWORK/AGENT_RESPONSE', + field: expectedField, + }); + } + }); + + test('chat throws AgentResponseError on malformed ChatResponse envelope or toolCalls', async () => { + const wraith = new Wraith({ apiKey: TEST_API_KEY, baseUrl }); + const agent = wraith.agent(AGENT_ID); + + nextChatPayload = { response: 123, conversationId: 'conv-1' }; + await expect(agent.chat('hi')).rejects.toMatchObject({ + name: 'AgentResponseError', + field: 'response', + }); + + nextChatPayload = { response: 'ok', conversationId: '' }; + await expect(agent.chat('hi')).rejects.toMatchObject({ + name: 'AgentResponseError', + field: 'conversationId', + }); + + nextChatPayload = { response: 'ok', conversationId: 'conv-1', toolCalls: 'not-an-array' }; + await expect(agent.chat('hi')).rejects.toMatchObject({ + name: 'AgentResponseError', + field: 'toolCalls', + }); + + nextChatPayload = { + response: 'ok', + conversationId: 'conv-1', + toolCalls: [{ name: 'scan_payments', status: 200 }], + }; + await expect(agent.chat('hi')).rejects.toMatchObject({ + name: 'AgentResponseError', + field: 'toolCalls[0].status', + }); + }); + + test('validation helpers validateAgentStatus, validatePayment, and parseToolCallPayments directly', () => { + const url = 'https://api.wraith.dev/agent/agent-001/status'; + expect(validateAgentStatus({ balance: '5.0', tokens: { XLM: '10' } }, url)).toEqual({ + balance: '5.0', + tokens: { XLM: '10' }, + }); + + const payment = validatePayment( + { stealthAddress: 'GABC', balance: '2.5', ephemeralPubKey: '0xdeadbeef' }, + url, + ); + expect(payment.stealthAddress).toBe('GABC'); + + expect(parseToolCallPayments(JSON.stringify([payment]), url)).toEqual([payment]); }); test('exportKey sends signature', async () => { diff --git a/src/errors.ts b/src/errors.ts index c78640f..1b66776 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -282,6 +282,30 @@ export class HorizonResponseError extends WraithNetworkError { } } +/** + * Thrown when the Wraith Agent API returns a 2xx response whose JSON payload or tool-call output + * is malformed or does not match the expected schema. + */ +export class AgentResponseError extends WraithNetworkError { + readonly code = 'WRAITH/NETWORK/AGENT_RESPONSE'; + /** Location of the rejected value in the response, e.g. `balance` or `toolCalls[0].detail[0].stealthAddress`. */ + readonly field: string; + + constructor(url: string, field: string, reason: string) { + super(`Invalid agent response from "${url}" at ${field}: ${reason}`, { url, field, reason }); + this.field = field; + } + + describe(): string { + const { url, field } = this.context ?? {}; + return ( + `Agent endpoint "${url}" returned a response that failed validation at ${field}. ` + + `Try: verify that baseUrl points to a compatible Wraith Agent API server and that the ` + + `SDK version matches the server response schema. See ${this.docsLink}.` + ); + } +} + export class RPCRetryExhaustedError extends WraithNetworkError { readonly code = 'WRAITH/NETWORK/RPC_RETRY_EXHAUSTED'; diff --git a/src/index.ts b/src/index.ts index ab5a440..9e320c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,6 +64,7 @@ export { ECDHFailedError, RPCRequestError, HorizonResponseError, + AgentResponseError, RPCRetryExhaustedError, RPCTimeoutError, RetentionExceededError, diff --git a/src/types/@wraith-protocol/sdk-agent.d.ts b/src/types/@wraith-protocol/sdk-agent.d.ts index fb07555..92ec368 100644 --- a/src/types/@wraith-protocol/sdk-agent.d.ts +++ b/src/types/@wraith-protocol/sdk-agent.d.ts @@ -24,7 +24,7 @@ declare module '@wraith-protocol/sdk-agent' { export class WraithAgent { readonly info: AgentInfo; chat(message: string, conversationId?: string): Promise; - getStatus(): Promise; + getStatus(): Promise; getBalance(): Promise; scanPayments(): Promise; exportKey(signature: string, message: string): Promise<{ secret: string }>; @@ -36,6 +36,10 @@ declare module '@wraith-protocol/sdk-agent' { clearNotifications(): Promise; } + export function validateAgentStatus(payload: unknown, url: string): AgentStatus; + export function validatePayment(payload: unknown, url: string, field?: string): Payment; + export function parseToolCallPayments(detail: unknown, url: string, field?: string): Payment[]; + export enum Chain { Horizen = 'horizen', Ethereum = 'ethereum', @@ -88,6 +92,11 @@ declare module '@wraith-protocol/sdk-agent' { tokens: Record; } + export interface AgentStatus { + balance: string; + tokens: Record; + } + export interface Payment { stealthAddress: string; balance: string; diff --git a/test/errors.test.ts b/test/errors.test.ts index f4bcf3b..50d69f4 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -15,6 +15,7 @@ import { ECDHFailedError, RPCRequestError, HorizonResponseError, + AgentResponseError, RPCRetryExhaustedError, RPCTimeoutError, RetentionExceededError, @@ -127,6 +128,26 @@ describe('Wraith Custom Errors Taxonomy', () => { }); }); + test('AgentResponseError instanceof checks', () => { + const error = new AgentResponseError( + 'https://api.wraith.dev/agent/agent-001/status', + 'balance', + 'must be a non-empty string', + ); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(WraithError); + expect(error).toBeInstanceOf(WraithNetworkError); + expect(error).toBeInstanceOf(AgentResponseError); + expect(error.code).toBe('WRAITH/NETWORK/AGENT_RESPONSE'); + expect(error.name).toBe('AgentResponseError'); + expect(error.field).toBe('balance'); + expect(error.context).toEqual({ + url: 'https://api.wraith.dev/agent/agent-001/status', + field: 'balance', + reason: 'must be a non-empty string', + }); + }); + // Test 3: Validate instanceof chain for WraithNetworkError and its subclasses test('RPCRequestError instanceof checks', () => { const error = new RPCRequestError('https://horizon.stellar.org', 404, 'Not Found'); @@ -315,6 +336,14 @@ describe('Wraith Custom Errors Taxonomy', () => { 'HorizonResponseError', new HorizonResponseError('https://horizon.stellar.org', 'body', 'not valid JSON'), ], + [ + 'AgentResponseError', + new AgentResponseError( + 'https://api.wraith.dev/agent/agent-001/status', + 'balance', + 'invalid', + ), + ], [ 'RPCRetryExhaustedError', new RPCRetryExhaustedError('https://horizon.stellar.org', 5, 'timeout'), diff --git a/tsconfig.test-types.json b/tsconfig.test-types.json index c6da85d..0e42d7d 100644 --- a/tsconfig.test-types.json +++ b/tsconfig.test-types.json @@ -5,6 +5,11 @@ "declaration": false, "rootDir": "." }, - "include": ["src/**/*.ts", "test/scanner/unified.test-d.ts"], + "include": [ + "src/**/*.ts", + "test/scanner/unified.test-d.ts", + "packages/sdk-agent/src/**/*.ts", + "packages/sdk-agent/test/client.test.ts" + ], "exclude": ["node_modules", "dist"] }