From 3f0b4b1af4ba6311d278e5cbd32bfec192117de8 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Wed, 6 May 2026 09:35:42 -0400 Subject: [PATCH 1/4] feat(sdk): expose chat lifecycle Signed-off-by: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> --- .../services/registry-broker-client.test.ts | 121 ++++++++++++-- .../registry-broker/client/base-client.ts | 27 +++- src/services/registry-broker/client/chat.ts | 126 ++++++++++++++- src/services/registry-broker/schemas.ts | 152 +++++++++++++++++- src/services/registry-broker/types.ts | 25 +++ 5 files changed, 434 insertions(+), 17 deletions(-) diff --git a/__tests__/services/registry-broker-client.test.ts b/__tests__/services/registry-broker-client.test.ts index fbdf35da..d42219e3 100644 --- a/__tests__/services/registry-broker-client.test.ts +++ b/__tests__/services/registry-broker-client.test.ts @@ -65,6 +65,40 @@ const mockSessionResponse = { history: [], historyTtlSeconds: 900, encryption: null, + route: { + type: 'a2a', + replyMode: 'direct', + transport: 'a2a', + endpoint: 'https://demo.agent', + }, + transport: 'a2a', + visibility: 'private', + payment: { + required: false, + status: 'not_required', + }, + state: 'ready', +}; + +const mockReadinessResponse = { + status: 'responsive', + routeType: 'a2a', + replyMode: 'direct', + transport: 'a2a', + endpoint: 'https://demo.agent', + checkedAt: '2025-01-01T00:00:00.000Z', + cachedUntil: '2025-01-01T00:01:00.000Z', + latencyMs: null, + lastSuccessfulReplyAt: null, + lastDeliveryConfirmationAt: null, + lastFailureCode: null, + supportsStreaming: true, + supportsHistory: true, + supportsEncryption: true, + supportsPayments: false, + supportsAttachments: false, + requiresAuth: false, + operatorActionRequired: false, }; const mockHistorySnapshot = { @@ -171,6 +205,12 @@ const mockMessageResponse = { uaid: null, message: 'Hello', timestamp: '2025-01-01T00:00:00.000Z', + messageId: 'idem-1', + assistantMessageId: 'agent-1', + deliveryState: 'responded', + replyMode: 'direct', + deliveryConfirmation: false, + idempotent: false, rawResponse: { status: 200, headers: { @@ -179,6 +219,12 @@ const mockMessageResponse = { }, }; +const mockEndSessionResponse = { + message: 'Session ended', + sessionId: 'session-1', + state: 'ended', +}; + const mockStatsResponse = { totalAgents: 1, registries: { @@ -1373,8 +1419,13 @@ describe('RegistryBrokerClient', () => { keySpy.mockRestore(); }); - it('supports chat session flow and endSession', async () => { + it('supports chat readiness, session flow, retry, cancel, and endSession', async () => { fetchImplementation + .mockResolvedValueOnce( + createResponse({ + json: async () => mockReadinessResponse, + }) as unknown as Response, + ) .mockResolvedValueOnce( createResponse({ json: async () => mockSessionResponse, @@ -1387,10 +1438,20 @@ describe('RegistryBrokerClient', () => { ) .mockResolvedValueOnce( createResponse({ - status: 204, - ok: true, - json: async () => undefined, - text: async () => '', + json: async () => ({ ...mockMessageResponse, idempotent: true }), + }) as unknown as Response, + ) + .mockResolvedValueOnce( + createResponse({ + json: async () => ({ + ...mockEndSessionResponse, + message: 'Session cancelled', + }), + }) as unknown as Response, + ) + .mockResolvedValueOnce( + createResponse({ + json: async () => mockEndSessionResponse, }) as unknown as Response, ); @@ -1400,49 +1461,89 @@ describe('RegistryBrokerClient', () => { }); const auth: AgentAuthConfig = { type: 'bearer', token: 'user-key' }; + const readiness = await client.chat.readiness({ + agentUrl: 'https://demo.agent', + }); + expect(readiness.status).toBe('responsive'); + const session = await client.chat.createSession({ agentUrl: 'https://demo.agent', auth, + visibility: 'private', }); expect(session.sessionId).toBe('session-1'); + expect(session.route?.type).toBe('a2a'); const message = await client.chat.sendMessage({ agentUrl: 'https://demo.agent', sessionId: 'session-1', message: 'Hi', + idempotencyKey: 'idem-1', auth, }); expect(message.message).toBe('Hello'); expect(message.rawResponse).toEqual(mockMessageResponse.rawResponse); + expect(message.deliveryState).toBe('responded'); - await expect(client.chat.endSession('session-1')).resolves.toBeUndefined(); + const retry = await client.chat.retryMessage('idem-1', { + sessionId: 'session-1', + message: 'Hi', + }); + expect(retry.idempotent).toBe(true); + + await expect(client.chat.cancelSession('session-1')).resolves.toMatchObject( + { + state: 'ended', + }, + ); + + await expect(client.chat.endSession('session-1')).resolves.toEqual( + mockEndSessionResponse, + ); expect(fetchImplementation).toHaveBeenNthCalledWith( 1, + 'https://api.example.com/api/v1/chat/readiness', + expect.objectContaining({ method: 'POST' }), + ); + expect(fetchImplementation).toHaveBeenNthCalledWith( + 2, 'https://api.example.com/api/v1/chat/session', expect.objectContaining({ method: 'POST' }), ); const sessionRequestInit = fetchImplementation.mock - .calls[0][1] as RequestInit; + .calls[1][1] as RequestInit; expect(JSON.parse(sessionRequestInit.body as string)).toEqual({ agentUrl: 'https://demo.agent', auth: { type: 'bearer', token: 'user-key' }, + visibility: 'private', }); expect(fetchImplementation).toHaveBeenNthCalledWith( - 2, + 3, 'https://api.example.com/api/v1/chat/message', expect.objectContaining({ method: 'POST' }), ); const messageRequestInit = fetchImplementation.mock - .calls[1][1] as RequestInit; + .calls[2][1] as RequestInit; expect(JSON.parse(messageRequestInit.body as string)).toEqual({ agentUrl: 'https://demo.agent', auth: { type: 'bearer', token: 'user-key' }, + idempotencyKey: 'idem-1', message: 'Hi', sessionId: 'session-1', }); expect(fetchImplementation).toHaveBeenNthCalledWith( - 3, + 4, + 'https://api.example.com/api/v1/chat/message/idem-1/retry', + expect.objectContaining({ method: 'POST' }), + ); + expect(fetchImplementation).toHaveBeenNthCalledWith( + 5, + 'https://api.example.com/api/v1/chat/session/session-1/cancel', + expect.objectContaining({ method: 'POST' }), + ); + expect(fetchImplementation).toHaveBeenNthCalledWith( + 6, 'https://api.example.com/api/v1/chat/session/session-1', expect.objectContaining({ method: 'DELETE' }), ); diff --git a/src/services/registry-broker/client/base-client.ts b/src/services/registry-broker/client/base-client.ts index bc75a6d6..18928328 100644 --- a/src/services/registry-broker/client/base-client.ts +++ b/src/services/registry-broker/client/base-client.ts @@ -46,6 +46,11 @@ import type { ChatHistoryFetchOptions, ChatHistorySnapshotResponse, ChatHistorySnapshotWithDecryptedEntries, + ChatReadinessRequestPayload, + ChatReadinessResponse, + ChatRetryRequestPayload, + ChatRetryResponse, + ChatSessionEndResponse, CipherEnvelope, CompactHistoryRequestPayload, CreateAdapterRegistryCategoryRequest, @@ -215,6 +220,8 @@ import { import type { RegistryBrokerChatApi } from './chat'; import { acceptConversation as acceptConversationImpl, + cancelSession as cancelSessionImpl, + checkChatReadiness as checkChatReadinessImpl, compactHistory as compactHistoryImpl, createChatApi, createPlaintextConversationHandle as createPlaintextConversationHandleImpl, @@ -222,6 +229,7 @@ import { endSession as endSessionImpl, fetchEncryptionStatus as fetchEncryptionStatusImpl, postEncryptionHandshake as postEncryptionHandshakeImpl, + retryMessage as retryMessageImpl, sendMessage as sendMessageImpl, startChat as startChatImpl, startConversation as startConversationImpl, @@ -1706,6 +1714,12 @@ export class RegistryBrokerClient { return createSessionImpl(this, payload, allowHistoryAutoTopUp); } + async checkChatReadiness( + payload: ChatReadinessRequestPayload, + ): Promise { + return checkChatReadinessImpl(this, payload); + } + async startChat(options: StartChatOptions): Promise { return startChatImpl(this, this.getEncryptedChatManager(), options); } @@ -1751,7 +1765,18 @@ export class RegistryBrokerClient { return sendMessageImpl(this, payload); } - endSession(sessionId: string): Promise { + retryMessage( + messageId: string, + payload: ChatRetryRequestPayload, + ): Promise { + return retryMessageImpl(this, messageId, payload); + } + + cancelSession(sessionId: string): Promise { + return cancelSessionImpl(this, sessionId); + } + + endSession(sessionId: string): Promise { return endSessionImpl(this, sessionId); } diff --git a/src/services/registry-broker/client/chat.ts b/src/services/registry-broker/client/chat.ts index 44833731..f47e4cad 100644 --- a/src/services/registry-broker/client/chat.ts +++ b/src/services/registry-broker/client/chat.ts @@ -7,6 +7,11 @@ import type { ChatHistoryCompactionResponse, ChatHistoryFetchOptions, ChatHistorySnapshotWithDecryptedEntries, + ChatReadinessRequestPayload, + ChatReadinessResponse, + ChatRetryRequestPayload, + ChatRetryResponse, + ChatSessionEndResponse, CompactHistoryRequestPayload, CreateSessionRequestPayload, CreateSessionResponse, @@ -25,6 +30,8 @@ import type { } from '../types'; import { chatHistoryCompactionResponseSchema, + chatReadinessResponseSchema, + chatSessionEndResponseSchema, createSessionResponseSchema, encryptionHandshakeResponseSchema, sendMessageResponseSchema, @@ -39,13 +46,21 @@ import { export interface RegistryBrokerChatApi { start: (options: StartChatOptions) => Promise; + readiness: ( + payload: ChatReadinessRequestPayload, + ) => Promise; createSession: ( payload: CreateSessionRequestPayload, ) => Promise; sendMessage: ( payload: SendMessageRequestPayload, ) => Promise; - endSession: (sessionId: string) => Promise; + retryMessage: ( + messageId: string, + payload: ChatRetryRequestPayload, + ) => Promise; + cancelSession: (sessionId: string) => Promise; + endSession: (sessionId: string) => Promise; getHistory: ( sessionId: string, options?: ChatHistoryFetchOptions, @@ -80,10 +95,15 @@ export function createChatApi( ): RegistryBrokerChatApi { return { start: (options: StartChatOptions) => client.startChat(options), + readiness: (payload: ChatReadinessRequestPayload) => + client.checkChatReadiness(payload), createSession: (payload: CreateSessionRequestPayload) => client.createSession(payload), sendMessage: (payload: SendMessageRequestPayload) => client.sendMessage(payload), + retryMessage: (messageId: string, payload: ChatRetryRequestPayload) => + client.retryMessage(messageId, payload), + cancelSession: (sessionId: string) => client.cancelSession(sessionId), endSession: (sessionId: string) => client.endSession(sessionId), getHistory: (sessionId: string, options?: ChatHistoryFetchOptions) => client.fetchHistorySnapshot(sessionId, options), @@ -106,6 +126,29 @@ export function createChatApi( }; } +export async function checkChatReadiness( + client: RegistryBrokerClient, + payload: ChatReadinessRequestPayload, +): Promise { + const body: JsonObject = {}; + if ('uaid' in payload && payload.uaid) { + body.uaid = payload.uaid; + } + if ('agentUrl' in payload && payload.agentUrl) { + body.agentUrl = payload.agentUrl; + } + const raw = await client.requestJson('/chat/readiness', { + method: 'POST', + body, + headers: { 'content-type': 'application/json' }, + }); + return client.parseWithSchema( + raw, + chatReadinessResponseSchema, + 'chat readiness response', + ); +} + export async function createSession( client: RegistryBrokerClient, payload: CreateSessionRequestPayload, @@ -130,6 +173,9 @@ export async function createSession( if (payload.senderUaid) { body.senderUaid = payload.senderUaid; } + if (payload.visibility) { + body.visibility = payload.visibility; + } try { const raw = await client.requestJson('/chat/session', { method: 'POST', @@ -415,6 +461,15 @@ export async function sendMessage( if (payload.streaming !== undefined) { body.streaming = payload.streaming; } + if (payload.idempotencyKey) { + body.idempotencyKey = payload.idempotencyKey; + } + if (payload.senderUaid) { + body.senderUaid = payload.senderUaid; + } + if (payload.transport) { + body.transport = payload.transport; + } if (payload.auth) { body.auth = serialiseAuthConfig(payload.auth); } @@ -463,8 +518,69 @@ export async function sendMessage( export async function endSession( client: RegistryBrokerClient, sessionId: string, -): Promise { - await client.request(`/chat/session/${encodeURIComponent(sessionId)}`, { - method: 'DELETE', - }); +): Promise { + const raw = await client.requestJson( + `/chat/session/${encodeURIComponent(sessionId)}`, + { + method: 'DELETE', + }, + ); + return client.parseWithSchema( + raw, + chatSessionEndResponseSchema, + 'chat session end response', + ); +} + +export async function cancelSession( + client: RegistryBrokerClient, + sessionId: string, +): Promise { + const raw = await client.requestJson( + `/chat/session/${encodeURIComponent(sessionId)}/cancel`, + { + method: 'POST', + }, + ); + return client.parseWithSchema( + raw, + chatSessionEndResponseSchema, + 'chat session cancel response', + ); +} + +export async function retryMessage( + client: RegistryBrokerClient, + messageId: string, + payload: ChatRetryRequestPayload, +): Promise { + const body: JsonObject = { + sessionId: payload.sessionId, + message: payload.message, + }; + if (payload.uaid) { + body.uaid = payload.uaid; + } + if (payload.agentUrl) { + body.agentUrl = payload.agentUrl; + } + if (payload.idempotencyKey) { + body.idempotencyKey = payload.idempotencyKey; + } + if (payload.auth) { + body.auth = serialiseAuthConfig(payload.auth); + } + const raw = await client.requestJson( + `/chat/message/${encodeURIComponent(messageId)}/retry`, + { + method: 'POST', + body, + headers: { 'content-type': 'application/json' }, + }, + ); + return client.parseWithSchema( + raw, + sendMessageResponseSchema, + 'chat retry response', + ); } diff --git a/src/services/registry-broker/schemas.ts b/src/services/registry-broker/schemas.ts index 4ccb7e78..a6295464 100644 --- a/src/services/registry-broker/schemas.ts +++ b/src/services/registry-broker/schemas.ts @@ -139,13 +139,140 @@ const sessionEncryptionSummarySchema = z.object({ const chatHistoryEntrySchema = z.object({ messageId: z.string(), - role: z.enum(['user', 'agent']), + role: z.enum([ + 'user', + 'agent', + 'system', + 'tool', + 'payment', + 'delivery', + 'error', + ]), content: z.string(), timestamp: z.string(), cipherEnvelope: cipherEnvelopeSchema.optional(), metadata: z.record(jsonValueSchema).optional(), }); +const chatDeliveryStateSchema = z.enum([ + 'draft', + 'queued', + 'persisted', + 'delivered', + 'streaming', + 'responded', + 'failed', + 'timeout', + 'cancelled', +]); + +const chatReadinessStatusSchema = z.enum([ + 'responsive', + 'delivery_only', + 'degraded', + 'blocked', + 'unknown', +]); + +const chatReplyModeSchema = z.enum([ + 'direct', + 'stream', + 'poll', + 'delivery_only', + 'none', +]); + +const chatRouteTypeSchema = z.enum([ + 'a2a', + 'hcs-10', + 'mcp', + 'openrouter', + 'acp', + 'xmtp', + 'moltbook', + 'agentverse', + 'nanda', + 'http', + 'erc-8004', + 'x402', + 'unknown', +]); + +const chatSessionStateSchema = z.enum([ + 'connecting', + 'ready', + 'blocked', + 'ended', + 'expired', +]); + +const chatErrorCodeSchema = z.enum([ + 'AUTH_REQUIRED', + 'CREDITS_REQUIRED', + 'PAYMENT_REQUIRED', + 'AGENT_UNRESPONSIVE', + 'ROUTE_UNAVAILABLE', + 'PROTOCOL_UNSUPPORTED', + 'BROKER_NOT_EXECUTABLE', + 'NETWORK_TIMEOUT', + 'STREAM_STALLED', + 'HISTORY_UNAVAILABLE', + 'ENCRYPTION_REQUIRED', + 'RATE_LIMITED', + 'VALIDATION_ERROR', + 'UNKNOWN_ERROR', +]); + +export const chatRouteSummarySchema = z.object({ + type: chatRouteTypeSchema, + replyMode: chatReplyModeSchema, + transport: z.string(), + endpoint: z.string().optional(), +}); + +export const chatPaymentStateSchema = z.object({ + required: z.boolean(), + provider: z.enum(['credits', 'x402', 'acp', 'openrouter']).optional(), + status: z.enum([ + 'not_required', + 'preflight', + 'required', + 'approved', + 'paid', + 'failed', + ]), + estimatedCredits: z.number().nullable().optional(), + estimatedUsd: z.number().nullable().optional(), +}); + +export const chatReadinessResponseSchema = z.object({ + status: chatReadinessStatusSchema, + routeType: chatRouteTypeSchema, + replyMode: chatReplyModeSchema, + transport: z.string(), + endpoint: z.string().optional(), + checkedAt: z.string(), + cachedUntil: z.string(), + latencyMs: z.number().nullable().optional(), + lastSuccessfulReplyAt: z.string().nullable().optional(), + lastDeliveryConfirmationAt: z.string().nullable().optional(), + lastFailureCode: chatErrorCodeSchema.nullable().optional(), + supportsStreaming: z.boolean(), + supportsHistory: z.boolean(), + supportsEncryption: z.boolean(), + supportsPayments: z.boolean(), + supportsAttachments: z.boolean(), + requiresAuth: z.boolean(), + operatorActionRequired: z.boolean(), + issue: z + .object({ + code: z.string(), + message: z.string(), + details: z.string().optional(), + }) + .optional(), +}); + const metadataFacetSchema = z .record( z.union([ @@ -380,6 +507,15 @@ export const createSessionResponseSchema = z.object({ history: z.array(chatHistoryEntrySchema).optional().default([]), historyTtlSeconds: z.number().nullable().optional(), encryption: sessionEncryptionSummarySchema.nullable().optional(), + route: chatRouteSummarySchema.optional(), + transport: z.string().optional(), + senderUaid: z.string().nullable().optional(), + visibility: z.enum(['private', 'public']).optional(), + payment: chatPaymentStateSchema.optional(), + readiness: chatReadinessResponseSchema.optional(), + state: chatSessionStateSchema.optional(), + traceId: z.string().optional(), + expiresAt: z.string().nullable().optional(), }); export const sendMessageResponseSchema = z.object({ @@ -393,6 +529,20 @@ export const sendMessageResponseSchema = z.object({ history: z.array(chatHistoryEntrySchema).optional(), historyTtlSeconds: z.number().nullable().optional(), encrypted: z.boolean().optional(), + messageId: z.string().optional(), + assistantMessageId: z.string().nullable().optional(), + deliveryState: chatDeliveryStateSchema.optional(), + replyMode: chatReplyModeSchema.optional(), + deliveryConfirmation: z.boolean().optional(), + idempotent: z.boolean().optional(), + metadata: z.record(jsonValueSchema).optional(), + errorCode: chatErrorCodeSchema.optional(), +}); + +export const chatSessionEndResponseSchema = z.object({ + message: z.string(), + sessionId: z.string(), + state: chatSessionStateSchema.optional(), }); export const chatHistorySnapshotResponseSchema = z.object({ diff --git a/src/services/registry-broker/types.ts b/src/services/registry-broker/types.ts index 9f2b3cbc..aaaebe9d 100644 --- a/src/services/registry-broker/types.ts +++ b/src/services/registry-broker/types.ts @@ -91,6 +91,10 @@ import { resolveResponseSchema, searchResponseSchema, sendMessageResponseSchema, + chatReadinessResponseSchema, + chatSessionEndResponseSchema, + chatRouteSummarySchema, + chatPaymentStateSchema, chatHistorySnapshotResponseSchema, chatHistoryCompactionResponseSchema, statsResponseSchema, @@ -614,6 +618,13 @@ export type ResolvedAgentResponse = z.infer; export type CreateSessionResponse = z.infer; export type SendMessageResponse = z.infer; +export type ChatReadinessResponse = z.infer; +export type ChatSessionEndResponse = z.infer< + typeof chatSessionEndResponseSchema +>; +export type ChatRouteSummary = z.infer; +export type ChatPaymentState = z.infer; +export type ChatRetryResponse = SendMessageResponse; export type SkillSecurityBreakdownResponse = z.infer< typeof skillSecurityBreakdownResponseSchema >; @@ -1134,12 +1145,23 @@ type CreateSessionBasePayload = { historyTtlSeconds?: number; encryptionRequested?: boolean; senderUaid?: string; + visibility?: 'private' | 'public'; }; export type CreateSessionRequestPayload = | (CreateSessionBasePayload & { uaid: string }) | (CreateSessionBasePayload & { agentUrl: string }); +export type ChatReadinessRequestPayload = + | { uaid: string; agentUrl?: never } + | { agentUrl: string; uaid?: never }; + +export interface ChatRetryRequestPayload extends SendMessageBasePayload { + sessionId: string; + uaid?: string; + agentUrl?: string; +} + export interface CompactHistoryRequestPayload { sessionId: string; preserveEntries?: number; @@ -1156,6 +1178,9 @@ export interface SendMessageBasePayload { auth?: AgentAuthConfig; cipherEnvelope?: CipherEnvelope; encryption?: SendMessageEncryptionOptions; + idempotencyKey?: string; + senderUaid?: string; + transport?: 'xmtp' | 'moltbook' | 'http' | 'a2a' | 'acp'; } export interface StartEncryptedChatSessionOptions { From 40bf0f5e9115227a3f21223844c00079beb5c312 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Wed, 6 May 2026 10:08:20 -0400 Subject: [PATCH 2/4] fix(sdk): validate chat lifecycle inputs Signed-off-by: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> --- .../services/registry-broker-client.test.ts | 69 +++++++++++++++++ src/services/registry-broker/client/chat.ts | 76 ++++++++++++++----- 2 files changed, 126 insertions(+), 19 deletions(-) diff --git a/__tests__/services/registry-broker-client.test.ts b/__tests__/services/registry-broker-client.test.ts index d42219e3..fdee3d54 100644 --- a/__tests__/services/registry-broker-client.test.ts +++ b/__tests__/services/registry-broker-client.test.ts @@ -1488,6 +1488,7 @@ describe('RegistryBrokerClient', () => { const retry = await client.chat.retryMessage('idem-1', { sessionId: 'session-1', message: 'Hi', + senderUaid: 'uaid:sender', }); expect(retry.idempotent).toBe(true); @@ -1537,6 +1538,13 @@ describe('RegistryBrokerClient', () => { 'https://api.example.com/api/v1/chat/message/idem-1/retry', expect.objectContaining({ method: 'POST' }), ); + const retryRequestInit = fetchImplementation.mock + .calls[3][1] as RequestInit; + expect(JSON.parse(retryRequestInit.body as string)).toEqual({ + message: 'Hi', + senderUaid: 'uaid:sender', + sessionId: 'session-1', + }); expect(fetchImplementation).toHaveBeenNthCalledWith( 5, 'https://api.example.com/api/v1/chat/session/session-1/cancel', @@ -1549,6 +1557,67 @@ describe('RegistryBrokerClient', () => { ); }); + it('supports legacy empty end-session responses', async () => { + fetchImplementation.mockResolvedValueOnce( + createResponse({ + status: 204, + statusText: 'No Content', + json: async () => { + throw new Error('No content'); + }, + text: async () => '', + headers: new Headers(), + }) as unknown as Response, + ); + + const client = new RegistryBrokerClient({ + baseUrl: 'https://api.example.com', + fetchImplementation, + }); + + await expect(client.chat.endSession('session-1')).resolves.toEqual({ + message: 'Session ended', + sessionId: 'session-1', + state: 'ended', + }); + }); + + it('validates required chat lifecycle inputs before making requests', async () => { + const client = new RegistryBrokerClient({ + baseUrl: 'https://api.example.com', + fetchImplementation, + }); + + await expect(client.chat.readiness({ agentUrl: ' ' })).rejects.toThrow( + 'uaid or agentUrl is required', + ); + await expect(client.chat.cancelSession(' ')).rejects.toThrow( + 'sessionId is required', + ); + await expect(client.chat.endSession(' ')).rejects.toThrow( + 'sessionId is required', + ); + await expect( + client.chat.retryMessage(' ', { + sessionId: 'session-1', + message: 'hello', + }), + ).rejects.toThrow('messageId is required'); + await expect( + client.chat.retryMessage('message-1', { + sessionId: ' ', + message: 'hello', + }), + ).rejects.toThrow('sessionId is required'); + await expect( + client.chat.retryMessage('message-1', { + sessionId: 'session-1', + message: ' ', + }), + ).rejects.toThrow('message is required'); + expect(fetchImplementation).not.toHaveBeenCalled(); + }); + it('retrieves chat history snapshot for a session', async () => { fetchImplementation.mockResolvedValueOnce( createResponse({ diff --git a/src/services/registry-broker/client/chat.ts b/src/services/registry-broker/client/chat.ts index f47e4cad..e1c0450d 100644 --- a/src/services/registry-broker/client/chat.ts +++ b/src/services/registry-broker/client/chat.ts @@ -131,11 +131,16 @@ export async function checkChatReadiness( payload: ChatReadinessRequestPayload, ): Promise { const body: JsonObject = {}; - if ('uaid' in payload && payload.uaid) { - body.uaid = payload.uaid; + const uaid = 'uaid' in payload ? payload.uaid?.trim() : undefined; + const agentUrl = 'agentUrl' in payload ? payload.agentUrl?.trim() : undefined; + if (!uaid && !agentUrl) { + throw new Error('uaid or agentUrl is required to check chat readiness'); } - if ('agentUrl' in payload && payload.agentUrl) { - body.agentUrl = payload.agentUrl; + if (uaid) { + body.uaid = uaid; + } + if (agentUrl) { + body.agentUrl = agentUrl; } const raw = await client.requestJson('/chat/readiness', { method: 'POST', @@ -519,12 +524,22 @@ export async function endSession( client: RegistryBrokerClient, sessionId: string, ): Promise { - const raw = await client.requestJson( - `/chat/session/${encodeURIComponent(sessionId)}`, - { - method: 'DELETE', - }, + const normalizedSessionId = sessionId?.trim(); + if (!normalizedSessionId) { + throw new Error('sessionId is required to end a chat session'); + } + const response = await client.request( + `/chat/session/${encodeURIComponent(normalizedSessionId)}`, + { method: 'DELETE' }, ); + if (response.status === 204) { + return { + message: 'Session ended', + sessionId: normalizedSessionId, + state: 'ended', + }; + } + const raw = (await response.json()) as JsonValue; return client.parseWithSchema( raw, chatSessionEndResponseSchema, @@ -536,8 +551,12 @@ export async function cancelSession( client: RegistryBrokerClient, sessionId: string, ): Promise { + const normalizedSessionId = sessionId?.trim(); + if (!normalizedSessionId) { + throw new Error('sessionId is required to cancel a chat session'); + } const raw = await client.requestJson( - `/chat/session/${encodeURIComponent(sessionId)}/cancel`, + `/chat/session/${encodeURIComponent(normalizedSessionId)}/cancel`, { method: 'POST', }, @@ -554,24 +573,43 @@ export async function retryMessage( messageId: string, payload: ChatRetryRequestPayload, ): Promise { + const normalizedMessageId = messageId?.trim(); + const normalizedSessionId = payload.sessionId?.trim(); + const normalizedMessage = payload.message?.trim(); + if (!normalizedMessageId) { + throw new Error('messageId is required to retry a message'); + } + if (!normalizedSessionId) { + throw new Error('sessionId is required to retry a message'); + } + if (!normalizedMessage) { + throw new Error('message is required to retry a message'); + } const body: JsonObject = { - sessionId: payload.sessionId, - message: payload.message, + sessionId: normalizedSessionId, + message: normalizedMessage, }; - if (payload.uaid) { - body.uaid = payload.uaid; + const uaid = payload.uaid?.trim(); + const agentUrl = payload.agentUrl?.trim(); + const idempotencyKey = payload.idempotencyKey?.trim(); + const senderUaid = payload.senderUaid?.trim(); + if (uaid) { + body.uaid = uaid; } - if (payload.agentUrl) { - body.agentUrl = payload.agentUrl; + if (agentUrl) { + body.agentUrl = agentUrl; } - if (payload.idempotencyKey) { - body.idempotencyKey = payload.idempotencyKey; + if (idempotencyKey) { + body.idempotencyKey = idempotencyKey; + } + if (senderUaid) { + body.senderUaid = senderUaid; } if (payload.auth) { body.auth = serialiseAuthConfig(payload.auth); } const raw = await client.requestJson( - `/chat/message/${encodeURIComponent(messageId)}/retry`, + `/chat/message/${encodeURIComponent(normalizedMessageId)}/retry`, { method: 'POST', body, From 720732fe9ff5e84aa7eec9d92a40797c29eaa765 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Wed, 6 May 2026 10:19:22 -0400 Subject: [PATCH 3/4] fix(sdk): preserve retry payload options Signed-off-by: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> --- .../services/registry-broker-client.test.ts | 35 +++++++++++++++++++ src/services/registry-broker/client/chat.ts | 28 +++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/__tests__/services/registry-broker-client.test.ts b/__tests__/services/registry-broker-client.test.ts index fdee3d54..9e7abe90 100644 --- a/__tests__/services/registry-broker-client.test.ts +++ b/__tests__/services/registry-broker-client.test.ts @@ -1489,6 +1489,12 @@ describe('RegistryBrokerClient', () => { sessionId: 'session-1', message: 'Hi', senderUaid: 'uaid:sender', + cipherEnvelope: { + algorithm: 'aes-256-gcm', + ciphertext: 'ciphertext', + nonce: 'nonce', + recipients: [{ uaid: 'uaid:agent', encryptedShare: 'share' }], + }, }); expect(retry.idempotent).toBe(true); @@ -1541,6 +1547,12 @@ describe('RegistryBrokerClient', () => { const retryRequestInit = fetchImplementation.mock .calls[3][1] as RequestInit; expect(JSON.parse(retryRequestInit.body as string)).toEqual({ + cipherEnvelope: { + algorithm: 'aes-256-gcm', + ciphertext: 'ciphertext', + nonce: 'nonce', + recipients: [{ uaid: 'uaid:agent', encryptedShare: 'share' }], + }, message: 'Hi', senderUaid: 'uaid:sender', sessionId: 'session-1', @@ -1582,6 +1594,29 @@ describe('RegistryBrokerClient', () => { }); }); + it('supports legacy non-json end-session responses', async () => { + fetchImplementation.mockResolvedValueOnce( + createResponse({ + json: async () => { + throw new Error('Expected no JSON parsing'); + }, + text: async () => 'OK', + headers: new Headers({ 'content-type': 'text/plain' }), + }) as unknown as Response, + ); + + const client = new RegistryBrokerClient({ + baseUrl: 'https://api.example.com', + fetchImplementation, + }); + + await expect(client.chat.endSession('session-1')).resolves.toEqual({ + message: 'Session ended', + sessionId: 'session-1', + state: 'ended', + }); + }); + it('validates required chat lifecycle inputs before making requests', async () => { const client = new RegistryBrokerClient({ baseUrl: 'https://api.example.com', diff --git a/src/services/registry-broker/client/chat.ts b/src/services/registry-broker/client/chat.ts index e1c0450d..7e97db43 100644 --- a/src/services/registry-broker/client/chat.ts +++ b/src/services/registry-broker/client/chat.ts @@ -539,6 +539,15 @@ export async function endSession( state: 'ended', }; } + const contentType = response.headers?.get('content-type') ?? ''; + if (!contentType.toLowerCase().includes('json')) { + await response.text(); + return { + message: 'Session ended', + sessionId: normalizedSessionId, + state: 'ended', + }; + } const raw = (await response.json()) as JsonValue; return client.parseWithSchema( raw, @@ -589,6 +598,12 @@ export async function retryMessage( sessionId: normalizedSessionId, message: normalizedMessage, }; + if (payload.streaming !== undefined) { + body.streaming = payload.streaming; + } + if (payload.transport) { + body.transport = payload.transport; + } const uaid = payload.uaid?.trim(); const agentUrl = payload.agentUrl?.trim(); const idempotencyKey = payload.idempotencyKey?.trim(); @@ -608,6 +623,19 @@ export async function retryMessage( if (payload.auth) { body.auth = serialiseAuthConfig(payload.auth); } + let cipherEnvelope = payload.cipherEnvelope ?? null; + if (payload.encryption) { + if (!payload.encryption.recipients?.length) { + throw new Error('recipients are required for encrypted chat payloads'); + } + cipherEnvelope = client.encryption.encryptCipherEnvelope({ + ...payload.encryption, + sessionId: payload.encryption.sessionId ?? normalizedSessionId, + }); + } + if (cipherEnvelope) { + body.cipherEnvelope = toJsonObject(cipherEnvelope); + } const raw = await client.requestJson( `/chat/message/${encodeURIComponent(normalizedMessageId)}/retry`, { From a1afbd73e2078d999e052e46d570349fdca75fba Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Wed, 6 May 2026 10:27:00 -0400 Subject: [PATCH 4/4] fix(sdk): handle empty end-session bodies Signed-off-by: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> --- .../services/registry-broker-client.test.ts | 28 +++++++++++++++++-- src/services/registry-broker/client/chat.ts | 12 ++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/__tests__/services/registry-broker-client.test.ts b/__tests__/services/registry-broker-client.test.ts index 9e7abe90..08314251 100644 --- a/__tests__/services/registry-broker-client.test.ts +++ b/__tests__/services/registry-broker-client.test.ts @@ -1452,6 +1452,7 @@ describe('RegistryBrokerClient', () => { .mockResolvedValueOnce( createResponse({ json: async () => mockEndSessionResponse, + text: async () => JSON.stringify(mockEndSessionResponse), }) as unknown as Response, ); @@ -1487,7 +1488,7 @@ describe('RegistryBrokerClient', () => { const retry = await client.chat.retryMessage('idem-1', { sessionId: 'session-1', - message: 'Hi', + message: ' Hi ', senderUaid: 'uaid:sender', cipherEnvelope: { algorithm: 'aes-256-gcm', @@ -1553,7 +1554,7 @@ describe('RegistryBrokerClient', () => { nonce: 'nonce', recipients: [{ uaid: 'uaid:agent', encryptedShare: 'share' }], }, - message: 'Hi', + message: ' Hi ', senderUaid: 'uaid:sender', sessionId: 'session-1', }); @@ -1617,6 +1618,29 @@ describe('RegistryBrokerClient', () => { }); }); + it('supports empty json end-session responses', async () => { + fetchImplementation.mockResolvedValueOnce( + createResponse({ + json: async () => { + throw new Error('Expected no JSON parsing'); + }, + text: async () => '', + headers: new Headers({ 'content-type': 'application/json' }), + }) as unknown as Response, + ); + + const client = new RegistryBrokerClient({ + baseUrl: 'https://api.example.com', + fetchImplementation, + }); + + await expect(client.chat.endSession('session-1')).resolves.toEqual({ + message: 'Session ended', + sessionId: 'session-1', + state: 'ended', + }); + }); + it('validates required chat lifecycle inputs before making requests', async () => { const client = new RegistryBrokerClient({ baseUrl: 'https://api.example.com', diff --git a/src/services/registry-broker/client/chat.ts b/src/services/registry-broker/client/chat.ts index 7e97db43..a1eea425 100644 --- a/src/services/registry-broker/client/chat.ts +++ b/src/services/registry-broker/client/chat.ts @@ -548,7 +548,15 @@ export async function endSession( state: 'ended', }; } - const raw = (await response.json()) as JsonValue; + const responseBody = await response.text(); + if (responseBody.trim().length === 0) { + return { + message: 'Session ended', + sessionId: normalizedSessionId, + state: 'ended', + }; + } + const raw = JSON.parse(responseBody) as JsonValue; return client.parseWithSchema( raw, chatSessionEndResponseSchema, @@ -596,7 +604,7 @@ export async function retryMessage( } const body: JsonObject = { sessionId: normalizedSessionId, - message: normalizedMessage, + message: payload.message, }; if (payload.streaming !== undefined) { body.streaming = payload.streaming;