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
249 changes: 239 additions & 10 deletions __tests__/services/registry-broker-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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: {
Expand All @@ -179,6 +219,12 @@ const mockMessageResponse = {
},
};

const mockEndSessionResponse = {
message: 'Session ended',
sessionId: 'session-1',
state: 'ended',
};

const mockStatsResponse = {
totalAgents: 1,
registries: {
Expand Down Expand Up @@ -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,
Expand All @@ -1387,10 +1438,21 @@ 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,
text: async () => JSON.stringify(mockEndSessionResponse),
}) as unknown as Response,
);

Expand All @@ -1400,54 +1462,221 @@ 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');

const retry = await client.chat.retryMessage('idem-1', {
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);

await expect(client.chat.cancelSession('session-1')).resolves.toMatchObject(
{
state: 'ended',
},
);

await expect(client.chat.endSession('session-1')).resolves.toBeUndefined();
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' }),
);
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',
});
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' }),
);
});

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('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('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',
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({
Expand Down
27 changes: 26 additions & 1 deletion src/services/registry-broker/client/base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ import type {
ChatHistoryFetchOptions,
ChatHistorySnapshotResponse,
ChatHistorySnapshotWithDecryptedEntries,
ChatReadinessRequestPayload,
ChatReadinessResponse,
ChatRetryRequestPayload,
ChatRetryResponse,
ChatSessionEndResponse,
CipherEnvelope,
CompactHistoryRequestPayload,
CreateAdapterRegistryCategoryRequest,
Expand Down Expand Up @@ -215,13 +220,16 @@ import {
import type { RegistryBrokerChatApi } from './chat';
import {
acceptConversation as acceptConversationImpl,
cancelSession as cancelSessionImpl,
checkChatReadiness as checkChatReadinessImpl,
compactHistory as compactHistoryImpl,
createChatApi,
createPlaintextConversationHandle as createPlaintextConversationHandleImpl,
createSession as createSessionImpl,
endSession as endSessionImpl,
fetchEncryptionStatus as fetchEncryptionStatusImpl,
postEncryptionHandshake as postEncryptionHandshakeImpl,
retryMessage as retryMessageImpl,
sendMessage as sendMessageImpl,
startChat as startChatImpl,
startConversation as startConversationImpl,
Expand Down Expand Up @@ -1706,6 +1714,12 @@ export class RegistryBrokerClient {
return createSessionImpl(this, payload, allowHistoryAutoTopUp);
}

async checkChatReadiness(
payload: ChatReadinessRequestPayload,
): Promise<ChatReadinessResponse> {
return checkChatReadinessImpl(this, payload);
}

async startChat(options: StartChatOptions): Promise<ChatConversationHandle> {
return startChatImpl(this, this.getEncryptedChatManager(), options);
}
Expand Down Expand Up @@ -1751,7 +1765,18 @@ export class RegistryBrokerClient {
return sendMessageImpl(this, payload);
}

endSession(sessionId: string): Promise<void> {
retryMessage(
messageId: string,
payload: ChatRetryRequestPayload,
): Promise<ChatRetryResponse> {
return retryMessageImpl(this, messageId, payload);
}

cancelSession(sessionId: string): Promise<ChatSessionEndResponse> {
return cancelSessionImpl(this, sessionId);
}

endSession(sessionId: string): Promise<ChatSessionEndResponse> {
return endSessionImpl(this, sessionId);
}

Expand Down
Loading
Loading