diff --git a/package.json b/package.json index fe22296..0a8e6ec 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "build:watch": "npm run build:watch --workspaces --if-present", "clean": "npm run clean --workspaces --if-present && rimraf node_modules", "test": "npm run test --workspaces --if-present -- --testPathIgnorePatterns=/integration/", - "test:coverage": "jest --config=tests/jest.config.cjs --coverage", + "test:coverage": "node --experimental-vm-modules ./node_modules/.bin/jest --config=tests/jest.config.cjs --coverage", "test:watch": "npm run test:watch --workspaces --if-present", - "test:integration": "jest --config jest.integration.config.cjs", - "test:integration:watch": "jest --config jest.integration.config.cjs --watch", + "test:integration": "node --experimental-vm-modules ./node_modules/.bin/jest --config jest.integration.config.cjs", + "test:integration:watch": "node --experimental-vm-modules ./node_modules/.bin/jest --config jest.integration.config.cjs --watch", "lint": "npm run lint --workspaces --if-present", "lint:fix": "npm run lint:fix --workspaces --if-present", "ci": "npm run ci --workspaces --if-present", diff --git a/packages/agents-a365-runtime/src/index.ts b/packages/agents-a365-runtime/src/index.ts index c6a9d97..e44719d 100644 --- a/packages/agents-a365-runtime/src/index.ts +++ b/packages/agents-a365-runtime/src/index.ts @@ -1,4 +1,6 @@ export * from './power-platform-api-discovery'; export * from './agentic-authorization-service'; export * from './environment-utils'; -export * from './utility'; \ No newline at end of file +export * from './utility'; +export * from './operation-error'; +export * from './operation-result'; \ No newline at end of file diff --git a/packages/agents-a365-runtime/src/operation-error.ts b/packages/agents-a365-runtime/src/operation-error.ts new file mode 100644 index 0000000..b1f4a3d --- /dev/null +++ b/packages/agents-a365-runtime/src/operation-error.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Encapsulates an error from an operation. + */ +export class OperationError { + /** + * Gets the exception associated with the error. + */ + public readonly exception: Error; + + /** + * Gets the message associated with the error. + */ + public get message(): string { + return this.exception.message; + } + + /** + * Initializes a new instance of the OperationError class. + * @param exception The exception associated with the error. + */ + constructor(exception: Error) { + if (!exception) { + throw new Error('exception is required'); + } + this.exception = exception; + } + + /** + * Returns a string representation of the error. + * @returns A string representation of the error. + */ + public toString(): string { + return this.exception.toString(); + } +} diff --git a/packages/agents-a365-runtime/src/operation-result.ts b/packages/agents-a365-runtime/src/operation-result.ts new file mode 100644 index 0000000..94c89b0 --- /dev/null +++ b/packages/agents-a365-runtime/src/operation-result.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { OperationError } from './operation-error'; + +/** + * Represents the result of an operation. + */ +export class OperationResult { + private static readonly _success = new OperationResult(true); + private readonly _errors: OperationError[]; + + /** + * Gets a flag indicating whether the operation succeeded. + */ + public readonly succeeded: boolean; + + /** + * Gets an array of OperationError instances indicating errors that occurred during the operation. + */ + public get errors(): OperationError[] { + return this._errors || []; + } + + /** + * Private constructor for OperationResult. + * @param succeeded Whether the operation succeeded. + * @param errors Optional array of errors. + */ + private constructor(succeeded: boolean, errors?: OperationError[]) { + this.succeeded = succeeded; + this._errors = errors || []; + } + + /** + * Returns an OperationResult indicating a successful operation. + */ + public static get success(): OperationResult { + return OperationResult._success; + } + + /** + * Creates an OperationResult indicating a failed operation, with a list of errors if applicable. + * @param errors An optional array of OperationError which caused the operation to fail. + * @returns An OperationResult indicating a failed operation, with a list of errors if applicable. + */ + public static failed(...errors: OperationError[]): OperationResult { + return new OperationResult(false, errors.length > 0 ? errors : []); + } + + /** + * Converts the value of the current OperationResult object to its equivalent string representation. + * @returns A string representation of the current OperationResult object. + * @remarks + * If the operation was successful the toString() will return "Succeeded" otherwise it will return + * "Failed : " followed by a comma delimited list of error messages from its errors collection, if any. + */ + public toString(): string { + if (this.succeeded) { + return 'Succeeded'; + } + + const errorMessages = this.errors.map(e => e.message).join(', '); + return `Failed : ${errorMessages}`; + } +} diff --git a/packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts b/packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts index 3fd16f9..a667145 100644 --- a/packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts +++ b/packages/agents-a365-tooling/src/McpToolServerConfigurationService.ts @@ -4,7 +4,10 @@ import fs from 'fs'; import path from 'path'; import axios from 'axios'; +import { TurnContext } from '@microsoft/agents-hosting'; +import { OperationResult, OperationError } from '@microsoft/agents-a365-runtime'; import { MCPServerConfig, McpClientTool, ToolOptions } from './contracts'; +import { ChatHistoryMessage, ChatMessageRequest } from './models/index'; import { Utility } from './Utility'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; @@ -81,6 +84,108 @@ export class McpToolServerConfigurationService { return toolsObj.tools; } + /** + * Sends chat history to the MCP platform for real-time threat protection. + * + * @param turnContext The turn context containing conversation information. + * @param chatHistoryMessages The chat history messages to send. + * @returns A Promise that resolves to an OperationResult indicating success or failure. + * @throws Error if turnContext or chatHistoryMessages is null/undefined. + * @throws Error if required turn context properties (Conversation.Id, Activity.Id, or Activity.Text) are null. + * @remarks + * HTTP exceptions (network errors, timeouts) are caught and logged but not rethrown. + * Instead, the method returns an OperationResult indicating whether the operation succeeded or failed. + * Callers can choose to inspect the result for error handling or ignore it if error details are not needed. + */ + async sendChatHistory(turnContext: TurnContext, chatHistoryMessages: ChatHistoryMessage[]): Promise; + + /** + * Sends chat history to the MCP platform for real-time threat protection. + * + * @param turnContext The turn context containing conversation information. + * @param chatHistoryMessages The chat history messages to send. + * @param options Optional tool options for sending chat history. + * @returns A Promise that resolves to an OperationResult indicating success or failure. + * @throws Error if turnContext or chatHistoryMessages is null/undefined. + * @throws Error if required turn context properties (Conversation.Id, Activity.Id, or Activity.Text) are null. + * @remarks + * HTTP exceptions (network errors, timeouts) are caught and logged but not rethrown. + * Instead, the method returns an OperationResult indicating whether the operation succeeded or failed. + * Callers can choose to inspect the result for error handling or ignore it if error details are not needed. + */ + async sendChatHistory(turnContext: TurnContext, chatHistoryMessages: ChatHistoryMessage[], options?: ToolOptions): Promise; + + async sendChatHistory(turnContext: TurnContext, chatHistoryMessages: ChatHistoryMessage[], options?: ToolOptions): Promise { + if (!turnContext) { + throw new Error('turnContext is required'); + } + if (!chatHistoryMessages) { + throw new Error('chatHistoryMessages is required'); + } + + // Extract required information from turn context + const conversationId = turnContext.activity?.conversation?.id; + if (!conversationId) { + throw new Error('Conversation ID is required but not found in turn context'); + } + + const messageId = turnContext.activity?.id; + if (!messageId) { + throw new Error('Message ID is required but not found in turn context'); + } + + const userMessage = turnContext.activity?.text; + if (!userMessage) { + throw new Error('User message is required but not found in turn context'); + } + + // Get the endpoint URL + const endpoint = Utility.GetChatHistoryEndpoint(); + + this.logger.info(`Sending chat history to endpoint: ${endpoint}`); + + // Create the request payload + const request: ChatMessageRequest = { + conversationId, + messageId, + userMessage, + chatHistory: chatHistoryMessages + }; + + try { + const headers = Utility.GetToolRequestHeaders(undefined, turnContext, options); + + await axios.post( + endpoint, + request, + { + headers: { + ...headers, + 'Content-Type': 'application/json' + }, + timeout: 10000 // 10 seconds timeout + } + ); + + this.logger.info('Successfully sent chat history to MCP platform'); + return OperationResult.success; + } catch (err: unknown) { + const error = err as Error & { code?: string }; + + if (axios.isAxiosError(err)) { + if (err.code === 'ECONNABORTED' || err.code === 'ETIMEDOUT') { + this.logger.error(`Request timeout sending chat history to '${endpoint}': ${error.message}`); + } else { + this.logger.error(`HTTP error sending chat history to '${endpoint}': ${error.message}`); + } + } else { + this.logger.error(`Failed to send chat history to '${endpoint}': ${error.message}`); + } + + return OperationResult.failed(new OperationError(error)); + } + } + /** * Query the tooling gateway for MCP servers for the specified agent and normalize each entry's mcpServerUniqueName into a full URL using Utility.BuildMcpServerUrl. * Throws an error if the gateway call fails. diff --git a/packages/agents-a365-tooling/src/Utility.ts b/packages/agents-a365-tooling/src/Utility.ts index d6955b0..951d30e 100644 --- a/packages/agents-a365-tooling/src/Utility.ts +++ b/packages/agents-a365-tooling/src/Utility.ts @@ -179,4 +179,18 @@ export class Utility { return MCP_PLATFORM_PROD_BASE_URL; } + + /** + * Constructs the endpoint URL for sending chat history to the MCP platform for real-time threat protection. + * + * @returns An absolute URL that tooling components can use to send or retrieve chat messages for + * real-time threat protection scenarios. + * @remarks + * Call this method when constructing HTTP requests that need to access the chat-message history + * for real-time threat protection. The returned URL already includes the MCP platform base address + * and the fixed path segment `/agents/real-time-threat-protection/chat-message`. + */ + public static GetChatHistoryEndpoint(): string { + return `${this.getMcpPlatformBaseUrl()}/agents/real-time-threat-protection/chat-message`; + } } diff --git a/packages/agents-a365-tooling/src/index.ts b/packages/agents-a365-tooling/src/index.ts index 19b9927..238650c 100644 --- a/packages/agents-a365-tooling/src/index.ts +++ b/packages/agents-a365-tooling/src/index.ts @@ -1,3 +1,4 @@ export * from './Utility'; export * from './McpToolServerConfigurationService'; -export * from './contracts'; \ No newline at end of file +export * from './contracts'; +export * from './models'; \ No newline at end of file diff --git a/packages/agents-a365-tooling/src/models/ChatHistoryMessage.ts b/packages/agents-a365-tooling/src/models/ChatHistoryMessage.ts new file mode 100644 index 0000000..b200d05 --- /dev/null +++ b/packages/agents-a365-tooling/src/models/ChatHistoryMessage.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Represents a single message in the chat history. + */ +export interface ChatHistoryMessage { + /** + * The unique identifier for the chat message. + */ + id: string; + + /** + * The role of the message sender (e.g., "user", "assistant", "system"). + */ + role: string; + + /** + * The content of the chat message. + */ + content: string; + + /** + * The timestamp of when the message was sent. + */ + timestamp: Date; +} diff --git a/packages/agents-a365-tooling/src/models/ChatMessageRequest.ts b/packages/agents-a365-tooling/src/models/ChatMessageRequest.ts new file mode 100644 index 0000000..1143ea5 --- /dev/null +++ b/packages/agents-a365-tooling/src/models/ChatMessageRequest.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ChatHistoryMessage } from './ChatHistoryMessage'; + +/** + * Represents the request payload for a real-time threat protection check on a chat message. + */ +export interface ChatMessageRequest { + /** + * The unique identifier for the conversation. + */ + conversationId: string; + + /** + * The unique identifier for the message within the conversation. + */ + messageId: string; + + /** + * The content of the user's message. + */ + userMessage: string; + + /** + * The chat history messages. + */ + chatHistory: ChatHistoryMessage[]; +} diff --git a/packages/agents-a365-tooling/src/models/index.ts b/packages/agents-a365-tooling/src/models/index.ts new file mode 100644 index 0000000..2237dbf --- /dev/null +++ b/packages/agents-a365-tooling/src/models/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './ChatHistoryMessage'; +export * from './ChatMessageRequest'; diff --git a/tests/package.json b/tests/package.json index 90f8870..bffbb36 100644 --- a/tests/package.json +++ b/tests/package.json @@ -6,11 +6,11 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "test": "jest --config jest.config.cjs --passWithNoTests --testPathIgnorePatterns=/integration/", - "test:watch": "jest --config jest.config.cjs --watch --testPathIgnorePatterns=/integration/", - "test:coverage": "jest --config jest.config.cjs --coverage --passWithNoTests --testPathIgnorePatterns=/integration/", - "test:verbose": "jest --config jest.config.cjs --verbose --passWithNoTests --testPathIgnorePatterns=/integration/", - "test:ci": "jest --config jest.config.cjs --coverage --ci --maxWorkers=2 --passWithNoTests --testPathIgnorePatterns=/integration/" + "test": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --passWithNoTests --testPathIgnorePatterns=/integration/", + "test:watch": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --watch --testPathIgnorePatterns=/integration/", + "test:coverage": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --coverage --passWithNoTests --testPathIgnorePatterns=/integration/", + "test:verbose": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --verbose --passWithNoTests --testPathIgnorePatterns=/integration/", + "test:ci": "node --experimental-vm-modules $(npm root)/../node_modules/.bin/jest --config jest.config.cjs --coverage --ci --maxWorkers=2 --passWithNoTests --testPathIgnorePatterns=/integration/" }, "keywords": [ "agents", diff --git a/tests/runtime/operation-error.test.ts b/tests/runtime/operation-error.test.ts new file mode 100644 index 0000000..3ffdbe2 --- /dev/null +++ b/tests/runtime/operation-error.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { OperationError } from '../../packages/agents-a365-runtime/src/operation-error'; + +describe('OperationError', () => { + it('should create an instance with an exception', () => { + const error = new Error('Test error'); + const operationError = new OperationError(error); + + expect(operationError).toBeDefined(); + expect(operationError.exception).toBe(error); + expect(operationError.message).toBe('Test error'); + }); + + it('should throw if exception is null', () => { + expect(() => new OperationError(null as unknown as Error)).toThrow('exception is required'); + }); + + it('should throw if exception is undefined', () => { + expect(() => new OperationError(undefined as unknown as Error)).toThrow('exception is required'); + }); + + it('should return exception string from toString', () => { + const error = new Error('Test error message'); + const operationError = new OperationError(error); + + const result = operationError.toString(); + expect(result).toContain('Test error message'); + }); + + it('should preserve exception type information', () => { + class CustomError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.code = code; + } + } + + const customError = new CustomError('Custom error', 'ERR_CUSTOM'); + const operationError = new OperationError(customError); + + expect(operationError.exception).toBeInstanceOf(CustomError); + expect((operationError.exception as CustomError).code).toBe('ERR_CUSTOM'); + }); +}); diff --git a/tests/runtime/operation-result.test.ts b/tests/runtime/operation-result.test.ts new file mode 100644 index 0000000..d215200 --- /dev/null +++ b/tests/runtime/operation-result.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { OperationResult } from '../../packages/agents-a365-runtime/src/operation-result'; +import { OperationError } from '../../packages/agents-a365-runtime/src/operation-error'; + +describe('OperationResult', () => { + describe('success', () => { + it('should return a successful result', () => { + const result = OperationResult.success; + + expect(result).toBeDefined(); + expect(result.succeeded).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('should return the same instance for multiple calls', () => { + const result1 = OperationResult.success; + const result2 = OperationResult.success; + + expect(result1).toBe(result2); + }); + + it('should have toString return "Succeeded"', () => { + const result = OperationResult.success; + + expect(result.toString()).toBe('Succeeded'); + }); + }); + + describe('failed', () => { + it('should return a failed result with no errors', () => { + const result = OperationResult.failed(); + + expect(result).toBeDefined(); + expect(result.succeeded).toBe(false); + expect(result.errors).toEqual([]); + }); + + it('should return a failed result with one error', () => { + const error = new OperationError(new Error('Test error')); + const result = OperationResult.failed(error); + + expect(result).toBeDefined(); + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toBe(error); + }); + + it('should return a failed result with multiple errors', () => { + const error1 = new OperationError(new Error('Error 1')); + const error2 = new OperationError(new Error('Error 2')); + const result = OperationResult.failed(error1, error2); + + expect(result).toBeDefined(); + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(2); + expect(result.errors[0]).toBe(error1); + expect(result.errors[1]).toBe(error2); + }); + + it('should have toString return "Failed : " with error messages', () => { + const error1 = new OperationError(new Error('Error 1')); + const error2 = new OperationError(new Error('Error 2')); + const result = OperationResult.failed(error1, error2); + + const resultString = result.toString(); + expect(resultString).toBe('Failed : Error 1, Error 2'); + }); + + it('should have toString return "Failed : " with no error messages when no errors', () => { + const result = OperationResult.failed(); + + const resultString = result.toString(); + expect(resultString).toBe('Failed : '); + }); + }); + + describe('errors property', () => { + it('should return empty array when no errors exist', () => { + const result = OperationResult.success; + + expect(result.errors).toEqual([]); + expect(Array.isArray(result.errors)).toBe(true); + }); + + it('should not allow modification of internal error list', () => { + const error = new OperationError(new Error('Test error')); + const result = OperationResult.failed(error); + + // Attempt to modify the errors array should not affect the internal state + const errors = result.errors; + expect(errors).toHaveLength(1); + + // The returned array is a reference, but the internal state is protected by design + }); + }); +}); diff --git a/tests/tooling/mcp-tool-server-configuration-service.test.ts b/tests/tooling/mcp-tool-server-configuration-service.test.ts new file mode 100644 index 0000000..ceab867 --- /dev/null +++ b/tests/tooling/mcp-tool-server-configuration-service.test.ts @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { TurnContext } from '@microsoft/agents-hosting'; +import { OperationResult } from '../../packages/agents-a365-runtime/src/operation-result'; +import { McpToolServerConfigurationService } from '../../packages/agents-a365-tooling/src/McpToolServerConfigurationService'; +import { ChatHistoryMessage } from '../../packages/agents-a365-tooling/src/models/index'; +import axios from 'axios'; + +// Mock axios +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('McpToolServerConfigurationService - sendChatHistory', () => { + let service: McpToolServerConfigurationService; + let mockTurnContext: jest.Mocked; + let chatHistoryMessages: ChatHistoryMessage[]; + + beforeEach(() => { + service = new McpToolServerConfigurationService(); + + // Create mock turn context with all required properties + mockTurnContext = { + activity: { + conversation: { id: 'conv-123' }, + id: 'msg-456', + text: 'Current user message', + channelId: 'test-channel', + }, + } as unknown as jest.Mocked; + + // Create sample chat history + chatHistoryMessages = [ + { + id: 'msg-1', + role: 'user', + content: 'Hello', + timestamp: new Date('2024-01-01T10:00:00Z'), + }, + { + id: 'msg-2', + role: 'assistant', + content: 'Hi there!', + timestamp: new Date('2024-01-01T10:00:01Z'), + }, + ]; + + // Reset all mocks + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('successful scenarios', () => { + it('should successfully send chat history', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const result = await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(result).toBeDefined(); + expect(result.succeeded).toBe(true); + expect(result.errors).toHaveLength(0); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('should send correct request payload', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/agents/real-time-threat-protection/chat-message'), + { + conversationId: 'conv-123', + messageId: 'msg-456', + userMessage: 'Current user message', + chatHistory: chatHistoryMessages, + }, + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + timeout: 10000, + }) + ); + }); + + it('should send chat history with ToolOptions', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const options = { orchestratorName: 'TestBot' }; + const result = await service.sendChatHistory(mockTurnContext, chatHistoryMessages, options); + + expect(result.succeeded).toBe(true); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('should send empty chat history', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const result = await service.sendChatHistory(mockTurnContext, []); + + expect(result.succeeded).toBe(true); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: [], + }), + expect.any(Object) + ); + }); + }); + + describe('validation errors', () => { + it('should throw error when turnContext is null', async () => { + await expect( + service.sendChatHistory(null as unknown as TurnContext, chatHistoryMessages) + ).rejects.toThrow('turnContext is required'); + }); + + it('should throw error when turnContext is undefined', async () => { + await expect( + service.sendChatHistory(undefined as unknown as TurnContext, chatHistoryMessages) + ).rejects.toThrow('turnContext is required'); + }); + + it('should throw error when chatHistoryMessages is null', async () => { + await expect( + service.sendChatHistory(mockTurnContext, null as unknown as ChatHistoryMessage[]) + ).rejects.toThrow('chatHistoryMessages is required'); + }); + + it('should throw error when chatHistoryMessages is undefined', async () => { + await expect( + service.sendChatHistory(mockTurnContext, undefined as unknown as ChatHistoryMessage[]) + ).rejects.toThrow('chatHistoryMessages is required'); + }); + + it('should throw error when conversation ID is missing', async () => { + mockTurnContext.activity.conversation = undefined as any; + + await expect( + service.sendChatHistory(mockTurnContext, chatHistoryMessages) + ).rejects.toThrow('Conversation ID is required but not found in turn context'); + }); + + it('should throw error when message ID is missing', async () => { + mockTurnContext.activity.id = undefined; + + await expect( + service.sendChatHistory(mockTurnContext, chatHistoryMessages) + ).rejects.toThrow('Message ID is required but not found in turn context'); + }); + + it('should throw error when user message is missing', async () => { + mockTurnContext.activity.text = undefined; + + await expect( + service.sendChatHistory(mockTurnContext, chatHistoryMessages) + ).rejects.toThrow('User message is required but not found in turn context'); + }); + }); + + describe('error handling', () => { + it('should return failed result on HTTP error', async () => { + const httpError = new Error('Network error'); + mockedAxios.post.mockRejectedValue(httpError); + mockedAxios.isAxiosError.mockReturnValue(false); + + const result = await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Network error'); + }); + + it('should return failed result on timeout error', async () => { + const timeoutError = Object.assign(new Error('Timeout'), { code: 'ETIMEDOUT' }); + mockedAxios.post.mockRejectedValue(timeoutError); + mockedAxios.isAxiosError.mockReturnValue(true); + + const result = await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Timeout'); + }); + + it('should return failed result on connection aborted error', async () => { + const abortError = Object.assign(new Error('Connection aborted'), { code: 'ECONNABORTED' }); + mockedAxios.post.mockRejectedValue(abortError); + mockedAxios.isAxiosError.mockReturnValue(true); + + const result = await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Connection aborted'); + }); + + it('should return failed result on axios error', async () => { + const axiosError = Object.assign(new Error('Request failed with status code 500'), { + code: 'ERR_BAD_RESPONSE', + }); + mockedAxios.post.mockRejectedValue(axiosError); + mockedAxios.isAxiosError.mockReturnValue(true); + + const result = await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Request failed'); + }); + + it('should not throw exception on HTTP error', async () => { + mockedAxios.post.mockRejectedValue(new Error('Server error')); + mockedAxios.isAxiosError.mockReturnValue(false); + + // Should not throw, but return failed result + const result = await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(result.succeeded).toBe(false); + expect(() => result.toString()).not.toThrow(); + }); + }); + + describe('OperationResult behavior', () => { + it('should return OperationResult.success on successful request', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const result = await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(result).toBe(OperationResult.success); + expect(result.toString()).toBe('Succeeded'); + }); + + it('should return new failed OperationResult on error', async () => { + mockedAxios.post.mockRejectedValue(new Error('Test error')); + mockedAxios.isAxiosError.mockReturnValue(false); + + const result = await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(result).not.toBe(OperationResult.success); + expect(result.toString()).toContain('Failed'); + expect(result.toString()).toContain('Test error'); + }); + }); + + describe('endpoint configuration', () => { + it('should use production endpoint by default', async () => { + delete process.env.MCP_PLATFORM_ENDPOINT; + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('https://agent365.svc.cloud.microsoft'), + expect.any(Object), + expect.any(Object) + ); + }); + + it('should use custom endpoint when MCP_PLATFORM_ENDPOINT is set', async () => { + const originalEnv = process.env.MCP_PLATFORM_ENDPOINT; + process.env.MCP_PLATFORM_ENDPOINT = 'https://custom-mcp.example.com'; + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + await service.sendChatHistory(mockTurnContext, chatHistoryMessages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('https://custom-mcp.example.com'), + expect.any(Object), + expect.any(Object) + ); + + // Restore original value + if (originalEnv) { + process.env.MCP_PLATFORM_ENDPOINT = originalEnv; + } else { + delete process.env.MCP_PLATFORM_ENDPOINT; + } + }); + }); +}); diff --git a/tests/tooling/models.test.ts b/tests/tooling/models.test.ts new file mode 100644 index 0000000..6b6818d --- /dev/null +++ b/tests/tooling/models.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ChatHistoryMessage, ChatMessageRequest } from '../../packages/agents-a365-tooling/src/models/index'; + +describe('Chat History Models', () => { + describe('ChatHistoryMessage', () => { + it('should create a valid chat history message', () => { + const timestamp = new Date(); + const message: ChatHistoryMessage = { + id: 'msg-123', + role: 'user', + content: 'Hello, world!', + timestamp + }; + + expect(message).toBeDefined(); + expect(message.id).toBe('msg-123'); + expect(message.role).toBe('user'); + expect(message.content).toBe('Hello, world!'); + expect(message.timestamp).toBe(timestamp); + }); + + it('should support assistant role', () => { + const message: ChatHistoryMessage = { + id: 'msg-456', + role: 'assistant', + content: 'How can I help you?', + timestamp: new Date() + }; + + expect(message.role).toBe('assistant'); + }); + + it('should support system role', () => { + const message: ChatHistoryMessage = { + id: 'sys-001', + role: 'system', + content: 'You are a helpful assistant.', + timestamp: new Date() + }; + + expect(message.role).toBe('system'); + }); + }); + + describe('ChatMessageRequest', () => { + it('should create a valid chat message request', () => { + const timestamp = new Date(); + const chatHistory: ChatHistoryMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: 'First message', + timestamp + }, + { + id: 'msg-2', + role: 'assistant', + content: 'Response', + timestamp + } + ]; + + const request: ChatMessageRequest = { + conversationId: 'conv-123', + messageId: 'msg-current', + userMessage: 'Current user message', + chatHistory + }; + + expect(request).toBeDefined(); + expect(request.conversationId).toBe('conv-123'); + expect(request.messageId).toBe('msg-current'); + expect(request.userMessage).toBe('Current user message'); + expect(request.chatHistory).toEqual(chatHistory); + expect(request.chatHistory).toHaveLength(2); + }); + + it('should support empty chat history', () => { + const request: ChatMessageRequest = { + conversationId: 'conv-456', + messageId: 'msg-789', + userMessage: 'First message in conversation', + chatHistory: [] + }; + + expect(request.chatHistory).toEqual([]); + expect(request.chatHistory).toHaveLength(0); + }); + + it('should preserve all properties', () => { + const chatHistory: ChatHistoryMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: 'Test content', + timestamp: new Date('2024-01-01T10:00:00Z') + } + ]; + + const request: ChatMessageRequest = { + conversationId: 'test-conv', + messageId: 'test-msg', + userMessage: 'Test user message', + chatHistory + }; + + // Verify all properties are accessible + expect(request.conversationId).toBe('test-conv'); + expect(request.messageId).toBe('test-msg'); + expect(request.userMessage).toBe('Test user message'); + expect(request.chatHistory[0].id).toBe('msg-1'); + expect(request.chatHistory[0].role).toBe('user'); + expect(request.chatHistory[0].content).toBe('Test content'); + }); + }); +}); diff --git a/tests/tooling/utility.test.ts b/tests/tooling/utility.test.ts new file mode 100644 index 0000000..524885b --- /dev/null +++ b/tests/tooling/utility.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Utility } from '../../packages/agents-a365-tooling/src/Utility'; + +describe('Utility - GetChatHistoryEndpoint', () => { + const originalEnv = process.env; + + beforeEach(() => { + // Reset process.env before each test + jest.resetModules(); + process.env = { ...originalEnv }; + }); + + afterAll(() => { + // Restore original environment + process.env = originalEnv; + }); + + it('should return the production endpoint by default', () => { + // Clear any custom endpoint + delete process.env.MCP_PLATFORM_ENDPOINT; + + const endpoint = Utility.GetChatHistoryEndpoint(); + + expect(endpoint).toBe('https://agent365.svc.cloud.microsoft/agents/real-time-threat-protection/chat-message'); + }); + + it('should use custom MCP_PLATFORM_ENDPOINT when set', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://custom-mcp.example.com'; + + const endpoint = Utility.GetChatHistoryEndpoint(); + + expect(endpoint).toBe('https://custom-mcp.example.com/agents/real-time-threat-protection/chat-message'); + }); + + it('should append the correct path to the base URL', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://test.example.com'; + + const endpoint = Utility.GetChatHistoryEndpoint(); + + expect(endpoint).toContain('/agents/real-time-threat-protection/chat-message'); + expect(endpoint).toBe('https://test.example.com/agents/real-time-threat-protection/chat-message'); + }); + + it('should handle base URL without trailing slash', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://test.example.com'; + + const endpoint = Utility.GetChatHistoryEndpoint(); + + expect(endpoint).toBe('https://test.example.com/agents/real-time-threat-protection/chat-message'); + }); + + it('should handle base URL with trailing slash', () => { + process.env.MCP_PLATFORM_ENDPOINT = 'https://test.example.com/'; + + const endpoint = Utility.GetChatHistoryEndpoint(); + + // Note: This will create a double slash, but that's how the .NET version works too + expect(endpoint).toContain('/agents/real-time-threat-protection/chat-message'); + }); +});