diff --git a/docs/prd/prd-openai-chat-history-api.md b/docs/prd/prd-openai-chat-history-api.md new file mode 100644 index 00000000..13f49ce1 --- /dev/null +++ b/docs/prd/prd-openai-chat-history-api.md @@ -0,0 +1,741 @@ +# Product Requirements Document (PRD) + +## OpenAI `sendChatHistoryAsync` API for Agent365-nodejs SDK + +| **Document Information** | | +|--------------------------|----------------------------------------------| +| **Version** | 1.4 | +| **Status** | Draft | +| **Author** | Agent365 Node.js SDK Team | +| **Created** | January 22, 2026 | +| **Last Updated** | January 22, 2026 | +| **Target Package** | `@microsoft/agents-a365-tooling-extensions-openai` | + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Problem Statement](#2-problem-statement) +3. [Goals and Non-Goals](#3-goals-and-non-goals) +4. [User Stories](#4-user-stories) +5. [Technical Requirements](#5-technical-requirements) +6. [Implementation Details](#6-implementation-details) +7. [Dependencies](#7-dependencies) +8. [Testing Strategy](#8-testing-strategy) +9. [Success Metrics](#9-success-metrics) +10. [Open Questions](#10-open-questions) + +--- + +## 1. Executive Summary + +### 1.1 Overview + +This PRD defines the requirements for implementing a `sendChatHistoryAsync` API in the `@microsoft/agents-a365-tooling-extensions-openai` package. This API will enable developers using the OpenAI Agents SDK to send conversation history to the MCP (Model Context Protocol) platform for real-time threat protection, without the need to manually convert OpenAI-native types to the SDK's `ChatHistoryMessage` format. + +### 1.2 Background + +The Agent365-nodejs SDK provides a core `sendChatHistory` method in `McpToolServerConfigurationService` (in the `@microsoft/agents-a365-tooling` package) that sends conversation history to the MCP platform. However, this method requires developers to manually construct `ChatHistoryMessage` objects, which creates friction for developers using the OpenAI Agents SDK. + +The OpenAI Agents SDK provides conversation management through: +- **`OpenAIConversationsSession`**: A session class with a `getItems(limit?)` method that returns `Promise` +- **`AgentInputItem`**: Represents individual conversation messages/interactions + +### 1.3 Reference Implementations + +This feature maintains parity with implementations in other Agent365 SDKs: +- **.NET SDK**: PR #171 (Agent Framework) and PR #173 (Semantic Kernel) - Implements `SendChatHistoryAsync` methods for different orchestrators +- **Python SDK**: PR #127 - Implements `send_chat_history_async` and `send_chat_history_messages_async` methods for the OpenAI orchestrator + +--- + +## 2. Problem Statement + +### 2.1 Current State + +Currently, developers using the OpenAI Agents SDK with Agent365-nodejs must: + +1. Extract messages from their OpenAI session using `session.getItems()` +2. Manually convert each `AgentInputItem` to the `ChatHistoryMessage` interface +3. Handle role mapping (e.g., mapping OpenAI roles to "user", "assistant", "system") +4. Generate UUIDs for messages without IDs +5. Generate timestamps for messages without timestamps +6. Call `McpToolServerConfigurationService.sendChatHistory()` with the converted messages + +This manual process creates: +- **Developer friction**: Extra boilerplate code (approximately 20+ lines) +- **Inconsistency risk**: Different developers may implement conversion logic differently +- **Error-prone integrations**: Missing ID or timestamp handling may vary +- **Maintenance burden**: Changes to OpenAI SDK types require updates across multiple applications + +### 2.2 Desired State + +Developers should be able to send chat history with a single method call: + +```typescript +// Using OpenAI Session directly (most common use case) +const result = await mcpToolRegistrationService.sendChatHistoryAsync( + turnContext, + session, // OpenAI Session instance + 50, // Optional: limit number of messages + toolOptions // Optional: custom tool options +); + +// Or using a list of items directly +const items = await session.getItems(); +const result = await mcpToolRegistrationService.sendChatHistoryMessagesAsync( + turnContext, + items, + toolOptions // Optional: custom tool options +); +``` + +--- + +## 3. Goals and Non-Goals + +### 3.1 Goals + +| ID | Goal | Priority | +|----|------|----------| +| **G1** | Provide OpenAI-native API for sending chat history to the MCP platform | P0 | +| **G2** | Support OpenAI Session's `getItems()` method for automatic message extraction | P0 | +| **G3** | Support direct list of `AgentInputItem` messages | P0 | +| **G4** | Pass through role from OpenAI message types | P0 | +| **G5** | Auto-generate UUIDs for messages without IDs | P0 | +| **G6** | Auto-generate timestamps for messages without timestamps | P0 | +| **G7** | Maintain backward compatibility with existing `McpToolServerConfigurationService` | P0 | +| **G8** | Achieve feature parity with .NET and Python SDK implementations | P0 | + +### 3.2 Non-Goals + +| ID | Non-Goal | Rationale | +|----|----------|-----------| +| **NG1** | Modifications to the core `McpToolServerConfigurationService` | Out of scope; changes should be additive | +| **NG2** | Support for other orchestrator SDKs (Claude, LangChain) | Each orchestrator has its own extension package | +| **NG3** | Persistent storage of chat history | Handled by MCP platform | +| **NG4** | Chat history retrieval APIs (read operations) | Not part of current threat protection feature | +| **NG5** | Synchronous API variants | Node.js SDK follows async patterns throughout | + +--- + +## 4. User Stories + +### 4.1 Primary User Stories + +| ID | User Story | Priority | Acceptance Criteria | +|----|------------|----------|---------------------| +| **US-01** | As an OpenAI agent developer, I want to send my agent's Session history to the MCP platform so that my conversations are protected by real-time threat detection | P0 | Session items are converted and sent successfully | +| **US-02** | As an OpenAI agent developer, I want to send a list of messages to the MCP platform without manual conversion so that I can focus on agent logic | P0 | List of OpenAI messages converts and sends correctly | +| **US-03** | As an OpenAI agent developer, I want missing message IDs to be auto-generated so that I don't need to track IDs manually | P0 | UUIDs generated for messages without IDs | +| **US-04** | As an OpenAI agent developer, I want missing timestamps to use current UTC time so that all messages have valid timestamps | P0 | Current UTC timestamp used when not provided | +| **US-05** | As an OpenAI agent developer, I want to receive clear success/failure results so that I can handle errors appropriately | P0 | `OperationResult` returned with error details on failure | +| **US-06** | As an OpenAI agent developer, I want to limit the number of messages sent so that I can control API payload size | P1 | Limit parameter respected when extracting from session | + +### 4.2 Secondary User Stories + +| ID | User Story | Priority | Acceptance Criteria | +|----|------------|----------|---------------------| +| **US-07** | As an OpenAI agent developer, I want to pass custom `ToolOptions` so that I can customize orchestrator identification | P1 | ToolOptions parameter accepted and applied | +| **US-08** | As an OpenAI agent developer, I want detailed logging of conversion operations so that I can debug issues | P1 | Debug-level logs for conversion operations | +| **US-09** | As an OpenAI agent developer, I want my message roles to be preserved so that system, user, and assistant messages are sent as-is | P1 | Roles passed through without modification | + +--- + +## 5. Technical Requirements + +### 5.1 API Design + +#### 5.1.1 TypeScript Interfaces + +```typescript +import { TurnContext } from '@microsoft/agents-hosting'; +import { OperationResult, ToolOptions } from '@microsoft/agents-a365-tooling'; + +// From OpenAI Agents SDK +import { AgentInputItem } from '@openai/agents'; +import { OpenAIConversationsSession } from '@openai/agents-openai'; +``` + +#### 5.1.2 Method Signatures + +```typescript +/** + * Extended McpToolRegistrationService with chat history support. + */ +export class McpToolRegistrationService { + // ... existing methods ... + + /** + * Sends chat history from an OpenAI Session to the MCP platform for real-time threat protection. + * + * This method extracts messages from the provided OpenAI Session using `getItems()`, + * converts them to the `ChatHistoryMessage` format, and sends them to the MCP platform. + * + * @param turnContext - The turn context containing conversation information. + * @param session - The OpenAI Session instance to extract messages from. + * @param limit - Optional limit on the number of messages to retrieve from the session. + * @param toolOptions - Optional tool options for customization. + * @returns A Promise resolving to an OperationResult indicating success or failure. + * @throws Error if turnContext is null/undefined. + * @throws Error if session is null/undefined. + * @throws Error if required turn context properties are missing. + * + * @example + * ```typescript + * const session = new OpenAIConversationsSession(sessionOptions); + * const result = await service.sendChatHistoryAsync(turnContext, session, 50); + * if (result.succeeded) { + * console.log('Chat history sent successfully'); + * } else { + * console.error('Failed to send chat history:', result.errors); + * } + * ``` + */ + async sendChatHistoryAsync( + turnContext: TurnContext, + session: OpenAIConversationsSession, + limit?: number, + toolOptions?: ToolOptions + ): Promise; + + /** + * Sends a list of OpenAI messages to the MCP platform for real-time threat protection. + * + * This method converts the provided AgentInputItem messages to `ChatHistoryMessage` format + * and sends them to the MCP platform. + * + * @param turnContext - The turn context containing conversation information. + * @param messages - Array of AgentInputItem messages to send. + * @param options - Optional ToolOptions for customization. + * @returns A Promise resolving to an OperationResult indicating success or failure. + * @throws Error if turnContext is null/undefined. + * @throws Error if messages is null/undefined. + * @throws Error if required turn context properties are missing. + * + * @example + * ```typescript + * const items = await session.getItems(); + * const result = await service.sendChatHistoryMessagesAsync(turnContext, items); + * ``` + */ + async sendChatHistoryMessagesAsync( + turnContext: TurnContext, + messages: AgentInputItem[], + options?: ToolOptions + ): Promise; +} +``` + +### 5.2 Data Contracts + +#### 5.2.1 Existing Contracts (No Changes Required) + +The following contracts from `@microsoft/agents-a365-tooling` remain unchanged: + +```typescript +// From packages/agents-a365-tooling/src/models/ChatHistoryMessage.ts +interface ChatHistoryMessage { + id: string; + role: string; // "user" | "assistant" | "system" + content: string; + timestamp: Date; +} + +// From packages/agents-a365-tooling/src/contracts.ts +interface ToolOptions { + orchestratorName?: string; +} +``` + +#### 5.2.2 OpenAI SDK Types (External Reference) + +The following types are from the OpenAI Agents SDK: + +```typescript +// OpenAI Session (from @openai/agents-openai) +// See: https://github.com/openai/openai-agents-js/blob/main/packages/agents-openai/src/index.ts +import { OpenAIConversationsSession } from '@openai/agents-openai'; + +class OpenAIConversationsSession { + sessionId: string | undefined; + getItems(limit?: number): Promise; + addItems(...items: AgentInputItem[]): Promise; + popItem(): Promise; + clearSession(): Promise; +} + +// AgentInputItem is a union type representing various input items +// See: https://openai.github.io/openai-agents-js/openai/agents/type-aliases/agentinputitem/ +type AgentInputItem = + | UserMessageItem + | AssistantMessageItem + | SystemMessageItem + | HostedToolCallItem + | FunctionCallItem + | ComputerUseCallItem + | ShellCallItem + | ApplyPatchCallItem + | FunctionCallResultItem + | ComputerCallResultItem + | ShellCallResultItem + | ApplyPatchCallResultItem + | ReasoningItem + | CompactionItem + | UnknownItem; + +// Individual message item types contain: +// - type: string (discriminator, e.g., "user_message", "assistant_message") +// - role: string (e.g., "user", "assistant", "system") +// - content: string | ContentPart[] +// - id?: string +// Note: Timestamp property availability needs runtime verification +``` + +### 5.3 Role Handling + +The `role` property from `AgentInputItem` is used directly without transformation. We do not validate or map roles - whatever role is provided by the OpenAI SDK is passed through as-is to the `ChatHistoryMessage`. + +### 5.4 Content Extraction Priority + +1. If message has `.content` as `string` -> use directly (reject if empty) +2. If message has `.content` as `ContentPart[]` -> concatenate all text parts (reject if result is empty) +3. If message has `.text` attribute -> use directly (reject if empty) +4. If content is empty/None -> **reject the message** (skip with warning log) + +### 5.5 Error Handling + +| Error Condition | Expected Behavior | +|-----------------|-------------------| +| `turnContext` is null/undefined | Throw `Error('turnContext is required')` | +| `session` is null/undefined | Throw `Error('session is required')` | +| `messages` is null/undefined | Throw `Error('messages is required')` | +| `messages` is empty array | Return `OperationResult.success` (no-op) | +| `turnContext.activity` is null | Throw `Error('Activity is required...')` | +| Missing conversation ID | Throw `Error('Conversation ID is required...')` | +| Missing message ID | Throw `Error('Message ID is required...')` | +| Missing user message text | Throw `Error('User message is required...')` | +| HTTP error from MCP platform | Return `OperationResult.failed()` with error | +| Network timeout | Return `OperationResult.failed()` with error | +| Conversion error | Return `OperationResult.failed()` with error | + +--- + +## 6. Implementation Details + +### 6.1 File Changes + +| File | Change Type | Description | +|------|-------------|-------------| +| `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` | Modified | Add `sendChatHistoryAsync` and `sendChatHistoryMessagesAsync` methods | +| `packages/agents-a365-tooling-extensions-openai/package.json` | Possibly Modified | Verify `@openai/agents` dependency version, add `uuid` dependency | + +### 6.2 New Files + +| File | Purpose | +|------|---------| +| `tests/tooling-extensions-openai/sendChatHistoryAsync.test.ts` | Unit tests for session-based method | +| `tests/tooling-extensions-openai/sendChatHistoryMessagesAsync.test.ts` | Unit tests for direct messages method | +| `tests/tooling-extensions-openai/messageConversion.test.ts` | Unit tests for conversion logic | + +### 6.3 Implementation Pseudocode + +```typescript +// packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts + +import { v4 as uuidv4 } from 'uuid'; +import { ChatHistoryMessage, ToolOptions } from '@microsoft/agents-a365-tooling'; +import { OperationResult, OperationError } from '@microsoft/agents-a365-runtime'; + +export class McpToolRegistrationService { + private configService: McpToolServerConfigurationService = new McpToolServerConfigurationService(); + private readonly orchestratorName: string = "OpenAI"; + private readonly logger = console; + + // ... existing methods ... + + /** + * Sends chat history from an OpenAI Session. + */ + async sendChatHistoryAsync( + turnContext: TurnContext, + session: OpenAIConversationsSession, + limit?: number, + toolOptions?: ToolOptions + ): Promise { + // Validate inputs + if (!turnContext) { + throw new Error('turnContext is required'); + } + if (!session) { + throw new Error('session is required'); + } + + try { + // Extract messages from session + const items = await session.getItems(limit); + + // Delegate to the list-based method + return await this.sendChatHistoryMessagesAsync( + turnContext, + items, + toolOptions + ); + } catch (err) { + if (err instanceof Error && err.message.includes('is required')) { + throw err; // Re-throw validation errors + } + this.logger.error(`Failed to send chat history from session: ${err}`); + return OperationResult.failed(new OperationError(err as Error)); + } + } + + /** + * Sends a list of OpenAI messages. + */ + async sendChatHistoryMessagesAsync( + turnContext: TurnContext, + messages: AgentInputItem[], + options?: ToolOptions + ): Promise { + // Validate inputs + if (!turnContext) { + throw new Error('turnContext is required'); + } + if (!messages) { + throw new Error('messages is required'); + } + + // Handle empty list as no-op + if (messages.length === 0) { + this.logger.info('Empty message list provided, returning success'); + return OperationResult.success; + } + + // Set default options + const effectiveOptions: ToolOptions = { + orchestratorName: options?.orchestratorName ?? this.orchestratorName + }; + + try { + // Convert OpenAI messages to ChatHistoryMessage format + const chatHistoryMessages = this.convertToChatHistoryMessages(messages); + + this.logger.info(`Converted ${chatHistoryMessages.length} OpenAI messages to chat history format`); + + // Delegate to core service + return await this.configService.sendChatHistory( + turnContext, + chatHistoryMessages, + effectiveOptions + ); + } catch (err) { + if (err instanceof Error && err.message.includes('is required')) { + throw err; // Re-throw validation errors + } + this.logger.error(`Failed to send chat history messages: ${err}`); + return OperationResult.failed(new OperationError(err as Error)); + } + } + + /** + * Converts OpenAI AgentInputItem messages to ChatHistoryMessage format. + */ + private convertToChatHistoryMessages(messages: AgentInputItem[]): ChatHistoryMessage[] { + return messages + .map(msg => this.convertSingleMessage(msg)) + .filter((msg): msg is ChatHistoryMessage => msg !== null); + } + + /** + * Converts a single OpenAI message to ChatHistoryMessage format. + */ + private convertSingleMessage(message: AgentInputItem): ChatHistoryMessage | null { + try { + return { + id: this.extractId(message), + role: this.extractRole(message), + content: this.extractContent(message), + timestamp: this.extractTimestamp(message) + }; + } catch (err) { + this.logger.error(`Failed to convert message: ${err}`); + return null; + } + } + + /** + * Extracts the role from an OpenAI message. + */ + private extractRole(message: AgentInputItem): string { + return message.role; + } + + /** + * Extracts content from an OpenAI message. + * @throws Error if content is empty (empty strings are rejected). + */ + private extractContent(message: AgentInputItem): string { + let content: string | undefined; + + // Handle string content + if (typeof message.content === 'string') { + content = message.content; + } + // Handle array content (ContentPart[]) + else if (Array.isArray(message.content)) { + const textParts = message.content + .filter(part => part.type === 'text' || typeof part === 'string') + .map(part => typeof part === 'string' ? part : (part as { text?: string }).text || '') + .filter(text => text.length > 0); + + if (textParts.length > 0) { + content = textParts.join(' '); + } + } + // Try text property + else if ('text' in message && typeof message.text === 'string') { + content = message.text; + } + + // Reject empty content + if (!content || content.trim().length === 0) { + throw new Error('Message content cannot be empty'); + } + + return content; + } + + /** + * Extracts or generates an ID for a message. + */ + private extractId(message: AgentInputItem): string { + if (message.id) { + return message.id; + } + + const generatedId = uuidv4(); + this.logger.debug(`Generated UUID ${generatedId} for message without ID`); + return generatedId; + } + + /** + * Extracts or generates a timestamp for a message. + * Note: AgentInputItem types do not have a standard timestamp property, + * so we always generate the current timestamp. + */ + private extractTimestamp(_message: AgentInputItem): Date { + // AgentInputItem types do not include timestamp properties. + // Always use current UTC time. + return new Date(); + } +} +``` + +### 6.4 Sequence Diagram + +``` +Developer McpToolRegistrationService McpToolServerConfigurationService MCP Platform + | | | | + | sendChatHistoryAsync | | | + |──────────────────────>| | | + | | | | + | | validate inputs | | + | |──────────────┐ | | + | | | | | + | |<─────────────┘ | | + | | | | + | | session.getItems(limit) | | + | |──────────────┐ | | + | | | | | + | |<─────────────┘ | | + | | | | + | | convertToChatHistoryMessages | | + | |──────────────┐ | | + | | | for each message: | + | | | - extractRole | + | | | - extractContent | + | | | - extractId (or generate UUID) | + | | | - extractTimestamp (or use now) | + | |<─────────────┘ | | + | | | | + | | sendChatHistory | | + | |─────────────────────────────>| | + | | | | + | | | POST /chat-message | + | | |──────────────────────────────>| + | | | | + | | | HTTP 200 / Error | + | | |<──────────────────────────────| + | | | | + | | OperationResult | | + | |<─────────────────────────────| | + | | | | + | OperationResult | | | + |<──────────────────────| | | +``` + +--- + +## 7. Dependencies + +### 7.1 Internal Dependencies + +| Package | Required Version | Purpose | +|---------|-----------------|---------| +| `@microsoft/agents-a365-tooling` | Current | Core `McpToolServerConfigurationService`, `ChatHistoryMessage`, `ToolOptions` | +| `@microsoft/agents-a365-runtime` | Current | `OperationResult`, `OperationError` | +| `@microsoft/agents-hosting` | Current | `TurnContext`, `Authorization` | + +### 7.2 External Dependencies + +| Package | Required Version | Purpose | +|---------|-----------------|---------| +| `@openai/agents` | `>=0.1.0` | OpenAI Agents SDK types (`OpenAIConversationsSession`, `AgentInputItem`) | +| `uuid` | `>=9.0.0` | UUID generation for messages without IDs | + +### 7.3 Dependency Changes Required + +Verify in `packages/agents-a365-tooling-extensions-openai/package.json`: + +```json +{ + "dependencies": { + "@openai/agents": ">=0.1.0", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/uuid": "^9.0.0" + } +} +``` + +--- + +## 8. Testing Strategy + +### 8.1 Test Categories + +| Category | Coverage Target | Focus | +|----------|-----------------|-------| +| Unit Tests | >=95% lines | Method logic, conversion, validation | +| Integration Tests | Key flows | End-to-end with mocked HTTP | +| Edge Case Tests | 100% identified cases | Null handling, empty content, unknown types | + +### 8.2 Unit Test Cases + +#### 8.2.1 Input Validation Tests + +| Test ID | Test Name | Description | +|---------|-----------|-------------| +| UV-01 | `sendChatHistoryAsync_throws_when_turnContext_null` | Verify Error when turnContext is null | +| UV-02 | `sendChatHistoryAsync_throws_when_session_null` | Verify Error when session is null | +| UV-03 | `sendChatHistoryMessagesAsync_throws_when_turnContext_null` | Verify Error when turnContext is null | +| UV-04 | `sendChatHistoryMessagesAsync_throws_when_messages_null` | Verify Error when messages is null | +| UV-05 | `sendChatHistoryMessagesAsync_returns_success_for_empty_array` | Verify empty array returns success (no-op) | + +#### 8.2.2 Conversion Tests + +| Test ID | Test Name | Description | +|---------|-----------|-------------| +| CV-01 | `extractRole_returns_role_directly` | Role property is passed through as-is | +| CV-02 | `extractContent_extracts_string_content` | String content extracted directly | +| CV-03 | `extractContent_concatenates_array_content` | Array content concatenated | +| CV-04 | `extractContent_throws_for_empty_content` | Empty/null content throws error, message skipped | +| CV-05 | `extractId_uses_existing_id` | Existing ID preserved | +| CV-06 | `extractId_generates_uuid_when_missing` | UUID generated for missing ID | +| CV-07 | `extractTimestamp_always_uses_current_time` | Current UTC time always used (no timestamp on AgentInputItem) | + +#### 8.2.3 Success Path Tests + +| Test ID | Test Name | Description | +|---------|-----------|-------------| +| SP-01 | `sendChatHistoryAsync_extracts_and_sends_session_items` | Session items extracted and sent | +| SP-02 | `sendChatHistoryAsync_respects_limit_parameter` | Limit parameter passed to getItems | +| SP-03 | `sendChatHistoryMessagesAsync_returns_success` | Messages sent successfully | +| SP-04 | `sendChatHistoryMessagesAsync_uses_default_orchestrator_name` | Default orchestrator name applied | +| SP-05 | `sendChatHistoryMessagesAsync_uses_custom_tool_options` | Custom ToolOptions applied | + +#### 8.2.4 Error Handling Tests + +| Test ID | Test Name | Description | +|---------|-----------|-------------| +| EH-01 | `sendChatHistoryAsync_returns_failed_on_http_error` | HTTP error returns OperationResult.failed | +| EH-02 | `sendChatHistoryAsync_returns_failed_on_timeout` | Timeout returns OperationResult.failed | +| EH-03 | `sendChatHistoryAsync_returns_failed_on_conversion_error` | Conversion error returns OperationResult.failed | +| EH-04 | `sendChatHistoryAsync_returns_failed_on_session_error` | Session.getItems error returns OperationResult.failed | + +### 8.3 Test File Structure + +``` +tests/ +└── tooling-extensions-openai/ + ├── sendChatHistoryAsync.test.ts # Session-based method tests + ├── sendChatHistoryMessagesAsync.test.ts # Direct messages method tests + ├── messageConversion.test.ts # Conversion logic tests + └── fixtures/ + └── mockOpenAITypes.ts # Mock OpenAI SDK types +``` + +--- + +## 9. Success Metrics + +### 9.1 Quality Metrics + +| Metric | Target | +|--------|--------| +| Unit test line coverage | >=95% | +| Unit test branch coverage | >=90% | +| Integration test success rate | 100% | +| Zero regression in existing functionality | Verified | + +--- + +## 10. Open Questions + +### 10.1 Resolved Questions + +| Question | Resolution | +|----------|------------| +| Should we support synchronous APIs? | No - Node.js SDK follows async patterns | +| Should filtering of message types occur? | No - include all item types (per FR-14 from Python PRD) | +| What is the exact structure of `AgentInputItem` in the OpenAI Agents SDK? | `AgentInputItem` is a union of 15 types including `UserMessageItem`, `AssistantMessageItem`, `SystemMessageItem`, tool call items, result items, and metadata items. See [AgentInputItem docs](https://openai.github.io/openai-agents-js/openai/agents/type-aliases/agentinputitem/). | +| Should we expose conversion methods publicly for advanced use cases? | No - keep conversion methods private | +| Should empty content strings be allowed or rejected? | **Rejected** - messages with empty content should be skipped with a warning log | +| Is `created_at` in seconds or milliseconds? | N/A - `AgentInputItem` types do not have a standard timestamp property. Always generate current UTC timestamp. | + +### 10.2 Unresolved Questions + +*No unresolved questions at this time.* + +### 10.3 Assumptions + +1. The `@openai/agents` package is available via npm and provides stable type definitions +2. `OpenAIConversationsSession.getItems()` returns `Promise` +3. The core `McpToolServerConfigurationService.sendChatHistory()` remains unchanged +4. Messages with empty content are rejected (skipped with warning) + +--- + +## Appendix A: Related Documentation + +- [Microsoft Agent 365 Developer Docs](https://learn.microsoft.com/microsoft-agent-365/developer/) +- [OpenAI Agents JS SDK](https://openai.github.io/openai-agents-js/) +- [Model Context Protocol](https://modelcontextprotocol.io/) +- [Agent365 .NET SDK PR #171](https://github.com/microsoft/Agent365-dotnet/pull/171) - Agent Framework implementation +- [Agent365 .NET SDK PR #173](https://github.com/microsoft/Agent365-dotnet/pull/173) - Semantic Kernel implementation +- [Agent365 Python SDK PR #127](https://github.com/microsoft/Agent365-python/pull/127) - OpenAI orchestrator implementation + +--- + +## Document History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | January 22, 2026 | Agent365 Node.js SDK Team | Initial PRD | +| 1.1 | January 22, 2026 | Agent365 Node.js SDK Team | Resolved open questions: Updated AgentInputItem type definition to reflect actual union type from OpenAI docs; marked conversion methods as private; changed empty content handling to reject instead of allow; removed timestamp extraction (AgentInputItem has no timestamp property) | +| 1.2 | January 22, 2026 | Agent365 Node.js SDK Team | Removed `SendChatHistoryOptions` interface; `sendChatHistoryAsync` now takes `limit` and `toolOptions` as separate optional parameters | +| 1.3 | January 22, 2026 | Agent365 Node.js SDK Team | Simplified `extractRole` to pass through the role property directly without mapping or validation | +| 1.4 | January 22, 2026 | Agent365 Node.js SDK Team | Updated to use `OpenAIConversationsSession` from `@openai/agents-openai` package (v0.4.0+) instead of defining a local interface | diff --git a/docs/tasks/prd-openai-chat-history-api.tasks.md b/docs/tasks/prd-openai-chat-history-api.tasks.md new file mode 100644 index 00000000..35c05ba2 --- /dev/null +++ b/docs/tasks/prd-openai-chat-history-api.tasks.md @@ -0,0 +1,361 @@ +# Implementation Tasks: OpenAI Chat History API + +## Overview + +This document outlines the implementation tasks for adding `sendChatHistoryAsync` and `sendChatHistoryMessagesAsync` APIs to the `@microsoft/agents-a365-tooling-extensions-openai` package. These APIs enable developers using the OpenAI Agents SDK to send conversation history to the MCP platform for real-time threat protection without manual type conversion. + +**Target Package:** `@microsoft/agents-a365-tooling-extensions-openai` + +**Estimated Total Tasks:** 18 tasks across 4 phases + +**Key Features:** +- Session-based chat history extraction via `sendChatHistoryAsync` +- Direct message list support via `sendChatHistoryMessagesAsync` +- Pass-through of role from OpenAI message types +- Auto-generation of UUIDs for messages without IDs +- Auto-generation of timestamps (always current UTC) + +--- + +## Task List + +### Phase 1: Setup and Dependencies + +- [ ] Task 1.1: Verify and update package dependencies + - **File(s):** `packages/agents-a365-tooling-extensions-openai/package.json`, `pnpm-workspace.yaml` + - **Details:** + - Update pnpm catalog to use `@openai/agents`, `@openai/agents-core`, and `@openai/agents-openai` with version `^0.4.0` (required for `OpenAIConversationsSession`) + - Add `uuid` dependency with version `^9.0.0` for UUID generation + - Add `@types/uuid` to devDependencies with version `^9.0.0` + - **Acceptance Criteria:** + - `pnpm install` completes successfully + - `uuid` package is available for import in the source file + - `OpenAIConversationsSession` is importable from `@openai/agents-openai` + - No version conflicts reported + +--- + +### Phase 2: Core Implementation + +- [ ] Task 2.1: Add required imports to McpToolRegistrationService + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add import for `v4 as uuidv4` from `uuid` + - Add import for `OperationResult`, `OperationError` from `@microsoft/agents-a365-runtime` + - Add import for `ChatHistoryMessage` from `@microsoft/agents-a365-tooling` + - Add import for `AgentInputItem` from `@openai/agents` + - Add import for `OpenAIConversationsSession` from `@openai/agents-openai` + - **Acceptance Criteria:** + - All imports resolve correctly + - No TypeScript errors on import statements + +- [ ] Task 2.2: Implement extractRole private method + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add private method `extractRole(message: AgentInputItem): string` + - Simply return `message.role` directly without any transformation or validation + - **Acceptance Criteria:** + - Method returns the role property as-is + - No role mapping or validation logic + +- [ ] Task 2.3: Implement extractContent private method + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add private method `extractContent(message: AgentInputItem): string` + - Implement content extraction logic: + 1. If `message.content` is string, use directly + 2. If `message.content` is array (ContentPart[]), concatenate text parts + 3. Check for `message.text` property as fallback + 4. Throw Error if content is empty or undefined (message will be skipped) + - **Acceptance Criteria:** + - String content extracted directly + - Array content properly concatenated + - Error thrown for empty content (with descriptive message) + +- [ ] Task 2.4: Implement extractId private method + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add private method `extractId(message: AgentInputItem): string` + - If `message.id` exists, return it + - Otherwise, generate UUID using `uuidv4()` + - Log debug message when generating UUID + - **Acceptance Criteria:** + - Existing IDs preserved + - Valid UUIDs generated for missing IDs + - Debug logging present + +- [ ] Task 2.5: Implement extractTimestamp private method + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add private method `extractTimestamp(_message: AgentInputItem): Date` + - Always return `new Date()` (current UTC time) + - Note: AgentInputItem types do not have timestamp properties + - **Acceptance Criteria:** + - Returns current Date object + - Method signature uses underscore prefix for unused parameter + +- [ ] Task 2.6: Implement convertSingleMessage private method + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add private method `convertSingleMessage(message: AgentInputItem): ChatHistoryMessage | null` + - Use try-catch to handle conversion errors + - Call extractId, extractRole, extractContent, extractTimestamp + - Return null and log error if conversion fails (message will be filtered out) + - **Acceptance Criteria:** + - Returns valid ChatHistoryMessage on success + - Returns null on failure + - Errors logged appropriately + +- [ ] Task 2.7: Implement convertToChatHistoryMessages private method + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add private method `convertToChatHistoryMessages(messages: AgentInputItem[]): ChatHistoryMessage[]` + - Map messages through `convertSingleMessage` + - Filter out null values using type guard + - **Acceptance Criteria:** + - All messages converted or skipped + - Null values filtered from result + +- [ ] Task 2.8: Implement sendChatHistoryMessagesAsync method + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add public async method with JSDoc: + ```typescript + async sendChatHistoryMessagesAsync( + turnContext: TurnContext, + messages: AgentInputItem[], + toolOptions?: ToolOptions + ): Promise + ``` + - Validate turnContext (throw if null/undefined) + - Validate messages (throw if null/undefined) + - Return `OperationResult.success` for empty array (no-op) + - Set default orchestratorName to "OpenAI" + - Convert messages and delegate to `this.configService.sendChatHistory()` + - Handle errors: re-throw validation errors, return OperationResult.failed for others + - **Acceptance Criteria:** + - Method signature matches PRD specification + - Input validation works correctly + - Empty array handled as no-op + - Delegates to core service correctly + +- [ ] Task 2.9: Implement sendChatHistoryAsync method + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add public async method with JSDoc: + ```typescript + async sendChatHistoryAsync( + turnContext: TurnContext, + session: OpenAIConversationsSession, + limit?: number, + toolOptions?: ToolOptions + ): Promise + ``` + - Validate turnContext (throw if null/undefined) + - Validate session (throw if null/undefined) + - Extract messages using `session.getItems(limit)` + - Delegate to `sendChatHistoryMessagesAsync()` + - Handle errors: re-throw validation errors, return OperationResult.failed for others + - **Acceptance Criteria:** + - Method signature matches PRD specification + - Session validation works + - Limit parameter passed to getItems + - Delegates correctly to sendChatHistoryMessagesAsync + +- [ ] Task 2.10: Add logger property to McpToolRegistrationService + - **File(s):** `packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts` + - **Details:** + - Add private readonly logger property: `private readonly logger = console;` + - Ensure consistent logging throughout new methods (info, warn, error, debug levels) + - **Acceptance Criteria:** + - Logger property exists + - All new methods use logger for appropriate log levels + +--- + +### Phase 3: Testing + +- [ ] Task 3.1: Create test fixtures for mock OpenAI types + - **File(s):** `tests/tooling-extensions-openai/fixtures/mockOpenAITypes.ts` (new file) + - **Details:** + - Create mock `AgentInputItem` objects for different message types: + - UserMessageItem with string content + - AssistantMessageItem with string content + - SystemMessageItem with string content + - Message with array content (ContentPart[]) + - Message without ID + - Message with empty content + - Message with unknown type/role + - Create mock `OpenAIConversationsSession` class with controllable `getItems()` behavior + - **Acceptance Criteria:** + - All fixture types defined + - Mock session class supports configurable returns + - Copyright header present + +- [ ] Task 3.2: Create unit tests for sendChatHistoryAsync + - **File(s):** `tests/tooling-extensions-openai/sendChatHistoryAsync.test.ts` (new file) + - **Details:** + - Test cases per PRD section 8.2: + - UV-01: throws when turnContext is null + - UV-02: throws when session is null + - SP-01: extracts and sends session items successfully + - SP-02: respects limit parameter + - EH-01: returns failed on HTTP error + - EH-02: returns failed on timeout + - EH-04: returns failed on session.getItems error + - Use Jest mocking for axios and session + - **Acceptance Criteria:** + - All test cases pass + - Code coverage >= 95% for sendChatHistoryAsync + - Copyright header present + +- [ ] Task 3.3: Create unit tests for sendChatHistoryMessagesAsync + - **File(s):** `tests/tooling-extensions-openai/sendChatHistoryMessagesAsync.test.ts` (new file) + - **Details:** + - Test cases per PRD section 8.2: + - UV-03: throws when turnContext is null + - UV-04: throws when messages is null + - UV-05: returns success for empty array (no-op) + - SP-03: returns success on successful send + - SP-04: uses default orchestrator name "OpenAI" + - SP-05: uses custom ToolOptions when provided + - EH-03: returns failed on conversion error + - **Acceptance Criteria:** + - All test cases pass + - Code coverage >= 95% for sendChatHistoryMessagesAsync + - Copyright header present + +- [ ] Task 3.4: Create unit tests for message conversion logic + - **File(s):** `tests/tooling-extensions-openai/messageConversion.test.ts` (new file) + - **Details:** + - Test cases per PRD section 8.2.2: + - CV-01: extractRole returns role directly (pass-through) + - CV-02: extractContent extracts string content + - CV-03: extractContent concatenates array content + - CV-04: extractContent throws for empty content (message skipped) + - CV-05: extractId uses existing ID + - CV-06: extractId generates UUID when missing + - CV-07: extractTimestamp always uses current time + - Test edge cases: null/undefined properties, mixed content arrays + - **Acceptance Criteria:** + - All test cases pass + - Content extraction handles all formats + - Copyright header present + +- [ ] Task 3.5: Verify test coverage meets requirements + - **File(s):** N/A (run command) + - **Details:** + - Run `pnpm test:coverage` from repository root + - Verify line coverage >= 95% for McpToolRegistrationService.ts + - Verify branch coverage >= 90% + - Address any coverage gaps + - **Acceptance Criteria:** + - Line coverage >= 95% + - Branch coverage >= 90% + - All tests pass + +--- + +### Phase 4: Documentation and Cleanup + +- [ ] Task 4.1: Update package design documentation + - **File(s):** `packages/agents-a365-tooling-extensions-openai/docs/design.md` + - **Details:** + - Add section documenting the new chat history APIs + - Include usage examples from PRD section 2.2 + - Document the conversion logic (role pass-through, content extraction, ID/timestamp generation) + - Reference the core `sendChatHistory` method in tooling package + - **Acceptance Criteria:** + - New APIs documented with examples + - Conversion behavior explained + - Links to related documentation + +- [ ] Task 4.2: Run linting and fix any issues + - **File(s):** All modified files + - **Details:** + - Run `pnpm lint` to check for linting errors + - Run `pnpm lint:fix` to auto-fix issues + - Manually fix any remaining issues + - **Acceptance Criteria:** + - `pnpm lint` passes with no errors + - Code follows project style guidelines + +- [ ] Task 4.3: Code review and resolve comments + - **File(s):** All modified and new files + - **Details:** + - Use the `code-review-manager` subagent to review all changes made for this feature + - Use the `pr-comment-resolver` subagent to address any issues discovered during code review + - Iterate until all code review comments are resolved + - Re-run linting after any changes made during review resolution + - **Acceptance Criteria:** + - Code review completed with no outstanding issues + - All review comments addressed and resolved + - Code still passes linting after review changes + +- [ ] Task 4.4: Build and verify package + - **File(s):** N/A (run command) + - **Details:** + - Run `pnpm build` from repository root + - Verify both CJS and ESM builds succeed + - Check that exports are correct in dist/ output + - **Acceptance Criteria:** + - Build completes without errors + - Both dist/cjs and dist/esm directories created + - New types visible in dist/esm/index.d.ts + +--- + +## Dependencies Between Tasks + +``` +Phase 1 (Setup) + Task 1.1 ──────────────────────► Phase 2 (Implementation) + │ + ▼ + Task 2.1 ──► Tasks 2.2-2.5 (can be parallel) + │ + ▼ + Task 2.6 ──► Task 2.7 + │ + ▼ + Task 2.8 ──► Task 2.9 + │ + Task 2.10 (can be done anytime in Phase 2) + │ + ▼ +Phase 3 (Testing) + Task 3.1 (fixtures) ──► Tasks 3.2, 3.3, 3.4 (can be parallel) + │ + ▼ + Task 3.5 (coverage verification) + │ + ▼ +Phase 4 (Documentation & Cleanup) + Tasks 4.1, 4.2 (can be parallel) ──► Task 4.3 (code review loop) ──► Task 4.4 +``` + +--- + +## Estimated Effort + +| Phase | Tasks | Estimated Hours | Notes | +|-------|-------|-----------------|-------| +| **Phase 1: Setup** | 1 | 0.25 | Dependency updates | +| **Phase 2: Core Implementation** | 10 | 4-6 | Main development work | +| **Phase 3: Testing** | 5 | 3-4 | Comprehensive test coverage | +| **Phase 4: Documentation & Cleanup** | 4 | 2-4 | Final polish, code review iteration | +| **Total** | **18** | **9.25-14.25** | | + +--- + +## Notes + +1. **OpenAI SDK Types**: The `AgentInputItem` type is a union of 15+ types. The implementation should handle all message-like types gracefully and skip non-message types (tool calls, results) with appropriate logging. + +2. **Error Handling Strategy**: Validation errors (null inputs) should throw immediately. Runtime errors (HTTP failures, conversion issues) should return `OperationResult.failed()` to allow callers to handle gracefully. + +3. **Backward Compatibility**: The existing `addToolServersToAgent` method must remain unchanged. All new code is additive. + +4. **Testing Strategy**: Follow existing test patterns in `tests/tooling/mcp-tool-server-configuration-service.test.ts` for consistency. + +5. **Copyright Headers**: All new `.ts` files must include the Microsoft copyright header as per CLAUDE.md instructions. diff --git a/packages/agents-a365-tooling-extensions-openai/docs/design.md b/packages/agents-a365-tooling-extensions-openai/docs/design.md index c340ef3d..e15e12e7 100644 --- a/packages/agents-a365-tooling-extensions-openai/docs/design.md +++ b/packages/agents-a365-tooling-extensions-openai/docs/design.md @@ -198,6 +198,152 @@ private readonly orchestratorName: string = "OpenAI"; // "Agent365SDK/1.0.0 (Windows_NT; Node.js v18.0.0; OpenAI)" ``` +## Chat History API + +The service provides methods to send conversation history to the MCP platform for real-time threat protection. These methods handle the conversion from OpenAI SDK types (`AgentInputItem`) to the platform-native `ChatHistoryMessage` format. + +### sendChatHistoryAsync + +Sends chat history from an OpenAI Session to the MCP platform: + +```typescript +import { McpToolRegistrationService } from '@microsoft/agents-a365-tooling-extensions-openai'; + +const registrationService = new McpToolRegistrationService(); + +// Using OpenAI Session directly (most common use case) +const result = await registrationService.sendChatHistoryAsync( + turnContext, + session, // OpenAI Session instance + 50, // Optional: limit number of messages + toolOptions // Optional: custom tool options +); + +if (result.succeeded) { + console.log('Chat history sent successfully'); +} else { + console.error('Failed to send chat history:', result.errors); +} +``` + +**Method Signature:** + +```typescript +async sendChatHistoryAsync( + turnContext: TurnContext, // Current turn context + session: OpenAIConversationsSession, // OpenAI session to extract messages from + limit?: number, // Optional limit on messages + toolOptions?: ToolOptions // Optional tool options +): Promise // Returns operation result +``` + +### sendChatHistoryMessagesAsync + +Sends a list of messages directly to the MCP platform: + +```typescript +// Or using a list of items directly +const items = await session.getItems(); +const result = await registrationService.sendChatHistoryMessagesAsync( + turnContext, + items, + toolOptions // Optional: custom tool options +); +``` + +**Method Signature:** + +```typescript +async sendChatHistoryMessagesAsync( + turnContext: TurnContext, // Current turn context + messages: AgentInputItem[], // Array of OpenAI messages + toolOptions?: ToolOptions // Optional tool options +): Promise // Returns operation result +``` + +### Message Conversion + +The service handles automatic conversion of OpenAI message types: + +| OpenAI Property | ChatHistoryMessage Property | Conversion Logic | +|-----------------|----------------------------|------------------| +| `role` | `role` | Pass-through (no transformation) | +| `content` (string) | `content` | Direct use | +| `content` (array) | `content` | Concatenate text parts | +| `text` | `content` | Fallback for text property | +| `id` | `id` | Use existing or generate UUID | +| N/A | `timestamp` | Always current UTC time | + +**Content Extraction Priority:** +1. If `message.content` is a string, use it directly +2. If `message.content` is an array (ContentPart[]), concatenate all text parts +3. If `message.text` exists, use it as fallback +4. If content is empty or undefined, the message is skipped with a warning + +**ID Generation:** +- If `message.id` exists, it is preserved +- Otherwise, a UUID v4 is generated automatically + +**Timestamp Generation:** +- AgentInputItem types do not have a standard timestamp property +- Current UTC timestamp is always generated + +### Error Handling + +| Error Condition | Behavior | +|-----------------|----------| +| `turnContext` is null/undefined | Throws `Error('turnContext is required')` | +| `session` is null/undefined | Throws `Error('session is required')` | +| `messages` is null/undefined | Throws `Error('messages is required')` | +| `messages` is empty array | Makes MCP platform call with empty chat history | +| Message conversion fails | Message is skipped, error logged | +| HTTP error from MCP platform | Returns `OperationResult.failed()` with error | +| Network timeout | Returns `OperationResult.failed()` with error | + +### Complete Example + +```typescript +import { Agent, run } from '@openai/agents'; +import { McpToolRegistrationService } from '@microsoft/agents-a365-tooling-extensions-openai'; + +async function onMessage(turnContext: TurnContext, authorization: Authorization) { + const registrationService = new McpToolRegistrationService(); + + // Create OpenAI agent with session + let agent = new Agent({ + name: 'MyAssistant', + model: 'gpt-4o', + instructions: 'You are a helpful assistant.' + }); + + // Register MCP tools + agent = await registrationService.addToolServersToAgent( + agent, + authorization, + 'myAuthHandler', + turnContext, + authToken + ); + + // Run the agent with conversation session + const session = new OpenAIConversationsSession(); + const result = await run(agent, turnContext.activity.text, { session }); + + // Send chat history for threat protection + const historyResult = await registrationService.sendChatHistoryAsync( + turnContext, + session, + 100 // Send up to 100 messages + ); + + if (!historyResult.succeeded) { + console.warn('Failed to send chat history:', historyResult.errors); + } + + await turnContext.sendActivity(result.finalOutput); +} +``` + ## Comparison with Other Extensions | Feature | OpenAI Extension | Claude Extension | LangChain Extension | @@ -206,3 +352,4 @@ private readonly orchestratorName: string = "OpenAI"; | Tool Discovery | Automatic via SDK | Manual via client | Via MCP adapters | | Tool Naming | Native MCP names | `mcp__server__tool` | Native MCP names | | Return Type | Updated `Agent` | `void` (modifies options) | New `ReactAgent` | +| Chat History API | `sendChatHistoryAsync` | N/A | N/A | diff --git a/packages/agents-a365-tooling-extensions-openai/package.json b/packages/agents-a365-tooling-extensions-openai/package.json index 05de6206..65cc1eb5 100644 --- a/packages/agents-a365-tooling-extensions-openai/package.json +++ b/packages/agents-a365-tooling-extensions-openai/package.json @@ -38,9 +38,11 @@ "@microsoft/agents-a365-tooling": "workspace:*", "@microsoft/agents-hosting": "catalog:", "@openai/agents": "catalog:", - "hono": "catalog:" + "hono": "catalog:", + "uuid": "catalog:" }, "devDependencies": { + "@types/uuid": "catalog:", "@eslint/js": "catalog:", "@types/jest": "catalog:", "@types/node": "catalog:", diff --git a/packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts b/packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts index 720a73de..40624fd4 100644 --- a/packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts +++ b/packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService.ts @@ -1,21 +1,23 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpToolServerConfigurationService, Utility, ToolOptions } from '@microsoft/agents-a365-tooling'; -import { AgenticAuthenticationService, Utility as RuntimeUtility } from '@microsoft/agents-a365-runtime'; +import { v4 as uuidv4 } from 'uuid'; +import { McpToolServerConfigurationService, Utility, ToolOptions, ChatHistoryMessage } from '@microsoft/agents-a365-tooling'; +import { AgenticAuthenticationService, Utility as RuntimeUtility, OperationResult, OperationError } from '@microsoft/agents-a365-runtime'; // Agents SDK import { TurnContext, Authorization } from '@microsoft/agents-hosting'; // OpenAI Agents SDK -import { Agent, MCPServerStreamableHttp } from '@openai/agents'; +import { Agent, MCPServerStreamableHttp, AgentInputItem } from '@openai/agents'; +import { OpenAIConversationsSession } from '@openai/agents-openai'; /** * Discover MCP servers and list tools formatted for the OpenAI Agents SDK. * Uses listToolServers to fetch server configs. */ export class McpToolRegistrationService { - private configService: McpToolServerConfigurationService = new McpToolServerConfigurationService(); + private configService: McpToolServerConfigurationService = new McpToolServerConfigurationService(); private readonly orchestratorName: string = "OpenAI"; @@ -74,4 +76,232 @@ export class McpToolRegistrationService { return agent; } + + /** + * Sends chat history from an OpenAI Session to the MCP platform for real-time threat protection. + * + * This method extracts messages from the provided OpenAI Session using `getItems()`, + * converts them to the `ChatHistoryMessage` format, and sends them to the MCP platform. + * + * @param turnContext - The turn context containing conversation information. + * @param session - The OpenAI Session instance to extract messages from. + * @param limit - Optional limit on the number of messages to retrieve from the session. + * @param toolOptions - Optional tool options for customization. + * @returns A Promise resolving to an OperationResult indicating success or failure. + * @throws Error if turnContext is null/undefined. + * @throws Error if session is null/undefined. + * @throws Error if required turn context properties are missing. + * + * @example + * ```typescript + * const session = new OpenAIConversationsSession(sessionOptions); + * const result = await service.sendChatHistoryAsync(turnContext, session, 50); + * if (result.succeeded) { + * console.log('Chat history sent successfully'); + * } else { + * console.error('Failed to send chat history:', result.errors); + * } + * ``` + */ + async sendChatHistoryAsync( + turnContext: TurnContext, + session: OpenAIConversationsSession, + limit?: number, + toolOptions?: ToolOptions + ): Promise { + // Validate inputs + if (!turnContext) { + throw new Error('turnContext is required'); + } + if (!session) { + throw new Error('session is required'); + } + + let items: AgentInputItem[]; + try { + // Extract messages from session + items = await session.getItems(limit); + } catch (err: unknown) { + // Convert errors from session.getItems() into a failed OperationResult + const error = err as Error; + return OperationResult.failed(new OperationError(error)); + } + + // Delegate to the list-based method + // Validation errors from this method will propagate + return await this.sendChatHistoryMessagesAsync( + turnContext, + items, + toolOptions + ); + } + + /** + * Sends a list of OpenAI messages to the MCP platform for real-time threat protection. + * + * This method converts the provided AgentInputItem messages to `ChatHistoryMessage` format + * and sends them to the MCP platform. + * + * @param turnContext - The turn context containing conversation information. + * @param messages - Array of AgentInputItem messages to send. + * @param toolOptions - Optional ToolOptions for customization. + * @returns A Promise resolving to an OperationResult indicating success or failure. + * @throws Error if turnContext is null/undefined. + * @throws Error if messages is null/undefined. + * @throws Error if required turn context properties are missing. + * + * @example + * ```typescript + * const items = await session.getItems(); + * const result = await service.sendChatHistoryMessagesAsync(turnContext, items); + * ``` + */ + async sendChatHistoryMessagesAsync( + turnContext: TurnContext, + messages: AgentInputItem[], + toolOptions?: ToolOptions + ): Promise { + // Validate inputs + if (!turnContext) { + throw new Error('turnContext is required'); + } + if (!messages) { + throw new Error('messages is required'); + } + + // Set default options + const effectiveOptions: ToolOptions = { + orchestratorName: toolOptions?.orchestratorName ?? this.orchestratorName + }; + + let chatHistoryMessages: ChatHistoryMessage[]; + try { + // Convert OpenAI messages to ChatHistoryMessage format + chatHistoryMessages = this.convertToChatHistoryMessages(messages); + } catch (err: unknown) { + // Convert errors from message conversion into a failed OperationResult + const error = err as Error; + return OperationResult.failed(new OperationError(error)); + } + + // Delegate to core service + return await this.configService.sendChatHistory( + turnContext, + chatHistoryMessages, + effectiveOptions + ); + } + + /** + * Converts OpenAI AgentInputItem messages to ChatHistoryMessage format. + * @param messages - Array of AgentInputItem messages to convert. + * @returns Array of successfully converted ChatHistoryMessage objects. + */ + private convertToChatHistoryMessages(messages: AgentInputItem[]): ChatHistoryMessage[] { + return messages + .map(msg => this.convertSingleMessage(msg)) + .filter((msg): msg is ChatHistoryMessage => msg !== null); + } + + /** + * Converts a single OpenAI message to ChatHistoryMessage format. + * @param message - The AgentInputItem to convert. + * @returns A ChatHistoryMessage object, or null if conversion fails. + */ + private convertSingleMessage(message: AgentInputItem): ChatHistoryMessage | null { + try { + return { + id: this.extractId(message), + role: this.extractRole(message), + content: this.extractContent(message), + timestamp: this.extractTimestamp(message) + }; + } catch { + return null; + } + } + + /** + * Extracts the role from an OpenAI message. + * Simply returns message.role directly without any transformation or validation. + * @param message - The AgentInputItem to extract the role from. + * @returns The role string from the message. + */ + private extractRole(message: AgentInputItem): string { + const { role } = message as { role?: unknown }; + return role as string; + } + + /** + * Extracts content from an OpenAI message. + * @param message - The AgentInputItem to extract content from. + * @returns The extracted content string. + * @throws Error if content is empty or cannot be extracted. + */ + private extractContent(message: AgentInputItem): string { + let content: string | undefined; + + const messageWithContent = message as { content?: string | Array<{ type?: string; text?: string }> }; + const messageWithText = message as { text?: string }; + + // Handle string content + if (typeof messageWithContent.content === 'string') { + content = messageWithContent.content; + } + // Handle array content (ContentPart[]) + else if (Array.isArray(messageWithContent.content)) { + const textParts = messageWithContent.content + .filter((part): part is { type?: string; text?: string } => { + if (typeof part === 'string') return true; + return part.type === 'text' || part.type === 'input_text' || (typeof part === 'object' && 'text' in part); + }) + .map(part => { + if (typeof part === 'string') return part; + return part.text || ''; + }) + .filter(text => text.length > 0); + + if (textParts.length > 0) { + content = textParts.join(' '); + } + } + // Try text property as fallback + else if (typeof messageWithText.text === 'string') { + content = messageWithText.text; + } + + // Reject empty content + if (!content || content.trim().length === 0) { + throw new Error('Message content cannot be empty'); + } + + return content; + } + + /** + * Extracts or generates an ID for a message. + * @param message - The AgentInputItem to extract or generate an ID for. + * @returns The message ID, either existing or newly generated UUID. + */ + private extractId(message: AgentInputItem): string { + const messageWithId = message as { id?: string }; + if (messageWithId.id) { + return messageWithId.id; + } + + return uuidv4(); + } + + /** + * Extracts or generates a timestamp for a message. + * Note: AgentInputItem types do not have a standard timestamp property, + * so we always generate the current timestamp. + * @param _message - The AgentInputItem (unused, as timestamps are always generated). + * @returns The current Date. + */ + private extractTimestamp(_message: AgentInputItem): Date { + // AgentInputItem types do not include timestamp properties. + // Always use current UTC time. + return new Date(); + } } diff --git a/packages/agents-a365-tooling-extensions-openai/src/index.ts b/packages/agents-a365-tooling-extensions-openai/src/index.ts index f9134a94..5dda0fef 100644 --- a/packages/agents-a365-tooling-extensions-openai/src/index.ts +++ b/packages/agents-a365-tooling-extensions-openai/src/index.ts @@ -1 +1,7 @@ -export * from './McpToolRegistrationService'; \ No newline at end of file +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './McpToolRegistrationService'; + +// Re-export OpenAI types for convenience +export { OpenAIConversationsSession } from '@openai/agents-openai'; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4009579d..da3bada4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,14 +40,14 @@ catalogs: specifier: ^1.25.2 version: 1.25.2 '@openai/agents': - specifier: ^0.1.5 - version: 0.1.11 + specifier: ^0.4.0 + version: 0.4.2 '@openai/agents-core': - specifier: ^0.1.5 - version: 0.1.11 + specifier: ^0.4.0 + version: 0.4.2 '@openai/agents-openai': - specifier: ^0.1.5 - version: 0.1.11 + specifier: ^0.4.0 + version: 0.4.2 '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -292,7 +292,7 @@ importers: version: link:../agents-a365-observability '@openai/agents': specifier: 'catalog:' - version: 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + version: 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) '@opentelemetry/api': specifier: 'catalog:' version: 1.9.0 @@ -604,10 +604,13 @@ importers: version: 1.1.0-alpha.85 '@openai/agents': specifier: 'catalog:' - version: 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + version: 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) hono: specifier: ^4.11.7 version: 4.11.7 + uuid: + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@eslint/js': specifier: 'catalog:' @@ -618,6 +621,9 @@ importers: '@types/node': specifier: 'catalog:' version: 20.19.25 + '@types/uuid': + specifier: ^9.0.8 + version: 9.0.8 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' version: 8.47.0(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) @@ -668,10 +674,10 @@ importers: version: 1.25.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@4.1.13) '@openai/agents': specifier: 'catalog:' - version: 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + version: 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) '@openai/agents-openai': specifier: 'catalog:' - version: 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + version: 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) '@opentelemetry/api': specifier: 'catalog:' version: 1.9.0 @@ -823,10 +829,10 @@ importers: version: 1.1.0-alpha.85 '@openai/agents': specifier: 'catalog:' - version: 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + version: 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) '@openai/agents-core': specifier: 'catalog:' - version: 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + version: 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) dotenv: specifier: 'catalog:' version: 17.2.3 @@ -1655,26 +1661,26 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@openai/agents-core@0.1.11': - resolution: {integrity: sha512-ye8VIAO2wPIg1zClldIj8We/1R55VmdgnMyn0g4YGbp6RD5Wpv9yfH5kPNWxmHvw8ji+XehyggGoklY4FGQoBQ==} + '@openai/agents-core@0.4.2': + resolution: {integrity: sha512-dVfeyTyqK7ym/WQrCKnnYDJ1gOY2/uhUDUt+yzkSWrhXfa8oXAzirHzlXh41LCiFmA4bM6V/LhEzH95XqC83TA==} peerDependencies: zod: ^4.1.12 peerDependenciesMeta: zod: optional: true - '@openai/agents-openai@0.1.11': - resolution: {integrity: sha512-TYYbY7o1cNxtOIO4F1a20qkqDT1Iwr9ZEi0MO2sEaeioK8rGB/Ux54iFvsA3IblUYyK+5fD25vr4vXFasvg2Kg==} + '@openai/agents-openai@0.4.2': + resolution: {integrity: sha512-WIAbSvc/XvRggIorcOjy0RIxMnlkAN9JrJXdcuVEgDQFnb+d0rxpMLotrOMycE/KCf4LKhO3WyQT/W28ZPT5wA==} peerDependencies: zod: ^4.1.12 - '@openai/agents-realtime@0.1.11': - resolution: {integrity: sha512-8jaNuYU1acra28i7bYrZIPubI6s2ziY2ZudqAVK2ad+giopXcrNSiJTuZ2S3z+ESnIejwMiYLfnY2Le8W0SJ7A==} + '@openai/agents-realtime@0.4.2': + resolution: {integrity: sha512-J7BZqMi+HhuDIMYvIgZNS5tUZFFvDByTGIkaCkCYYPAMSPD+G5rAEds2K+AbzPjwZPxFW3lBGak360GEJuTI4Q==} peerDependencies: zod: ^4.1.12 - '@openai/agents@0.1.11': - resolution: {integrity: sha512-jnaFt54iP71vYDXvpG3EGX2kVRYIU2xBdCT3uFqdXm4KqFAP9JQFNGiKKBEeE5rbXARpqAQpKH+5HfoANndpcQ==} + '@openai/agents@0.4.2': + resolution: {integrity: sha512-6UgM3OrwlW8+zUZdRhc3Olwh4mJy4goMJ3ayAPVBHfXdgUL4rmK+4wXX5cuiN49XCXEqu3Q54OHywgKUzxsLeA==} peerDependencies: zod: ^4.1.12 @@ -3533,18 +3539,6 @@ packages: zod: optional: true - openai@5.23.2: - resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==} - hasBin: true - peerDependencies: - ws: ^8.18.0 - zod: ^4.1.12 - peerDependenciesMeta: - ws: - optional: true - zod: - optional: true - openai@6.9.1: resolution: {integrity: sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg==} hasBin: true @@ -5098,10 +5092,10 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@openai/agents-core@0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13)': + '@openai/agents-core@0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13)': dependencies: debug: 4.4.3(supports-color@5.5.0) - openai: 5.23.2(ws@8.18.3)(zod@4.1.13) + openai: 6.9.1(ws@8.18.3)(zod@4.1.13) optionalDependencies: '@modelcontextprotocol/sdk': 1.25.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@4.1.13) zod: 4.1.13 @@ -5111,11 +5105,11 @@ snapshots: - supports-color - ws - '@openai/agents-openai@0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13)': + '@openai/agents-openai@0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13)': dependencies: - '@openai/agents-core': 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + '@openai/agents-core': 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) debug: 4.4.3(supports-color@5.5.0) - openai: 5.23.2(ws@8.18.3)(zod@4.1.13) + openai: 6.9.1(ws@8.18.3)(zod@4.1.13) zod: 4.1.13 transitivePeerDependencies: - '@cfworker/json-schema' @@ -5123,9 +5117,9 @@ snapshots: - supports-color - ws - '@openai/agents-realtime@0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@4.1.13)': + '@openai/agents-realtime@0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@4.1.13)': dependencies: - '@openai/agents-core': 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + '@openai/agents-core': 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) '@types/ws': 8.18.1 debug: 4.4.3(supports-color@5.5.0) ws: 8.18.3 @@ -5137,13 +5131,13 @@ snapshots: - supports-color - utf-8-validate - '@openai/agents@0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13)': + '@openai/agents@0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13)': dependencies: - '@openai/agents-core': 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) - '@openai/agents-openai': 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) - '@openai/agents-realtime': 0.1.11(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@4.1.13) + '@openai/agents-core': 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + '@openai/agents-openai': 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(ws@8.18.3)(zod@4.1.13) + '@openai/agents-realtime': 0.4.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@4.1.13) debug: 4.4.3(supports-color@5.5.0) - openai: 5.23.2(ws@8.18.3)(zod@4.1.13) + openai: 6.9.1(ws@8.18.3)(zod@4.1.13) zod: 4.1.13 transitivePeerDependencies: - '@cfworker/json-schema' @@ -7324,16 +7318,10 @@ snapshots: transitivePeerDependencies: - encoding - openai@5.23.2(ws@8.18.3)(zod@4.1.13): - optionalDependencies: - ws: 8.18.3 - zod: 4.1.13 - openai@6.9.1(ws@8.18.3)(zod@4.1.13): optionalDependencies: ws: 8.18.3 zod: 4.1.13 - optional: true optionator@0.9.4: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f56d8ce2..f9673d1e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,9 +33,9 @@ catalog: "@modelcontextprotocol/sdk": "^1.25.2" # OpenAI Agents packages - "@openai/agents": "^0.1.5" - "@openai/agents-core": "^0.1.5" - "@openai/agents-openai": "^0.1.5" + "@openai/agents": "^0.4.0" + "@openai/agents-core": "^0.4.0" + "@openai/agents-openai": "^0.4.0" "openai": "^4.8.0" # OpenTelemetry packages - align versions @@ -57,6 +57,7 @@ catalog: "@types/jest": "^30.0.0" "@types/jsonwebtoken": "^9.0.10" "@types/node": "^20.17.0" + "@types/uuid": "^9.0.8" "@typescript-eslint/eslint-plugin": "^8.47.0" "@typescript-eslint/parser": "^8.47.0" "cross-env": "^7.0.3" @@ -70,6 +71,7 @@ catalog: "tsx": "^4.21.0" "typescript": "^5.9.3" "typescript-eslint": "^8.47.0" + "uuid": "^9.0.1" overrides: # Zod @@ -101,4 +103,4 @@ virtualStoreDir: "node_modules/.pnpm" # Security settings auditConfig: ignoreCves: [] - ignoreGhsas: [] \ No newline at end of file + ignoreGhsas: [] diff --git a/tests/tooling-extensions-openai/fixtures/mockOpenAITypes.ts b/tests/tooling-extensions-openai/fixtures/mockOpenAITypes.ts new file mode 100644 index 00000000..8379b720 --- /dev/null +++ b/tests/tooling-extensions-openai/fixtures/mockOpenAITypes.ts @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { AgentInputItem } from '@openai/agents'; + +/** + * Creates a mock UserMessageItem with string content. + * Note: We use `as unknown as AgentInputItem` because the SDK types are strict unions. + */ +export function createUserMessage(content: string, id?: string): AgentInputItem { + return { + type: 'message', + role: 'user', + content: content, + id: id + } as unknown as AgentInputItem; +} + +/** + * Creates a mock AssistantMessageItem with string content. + * Note: We use `as unknown as AgentInputItem` because the SDK types require additional properties like `status`. + */ +export function createAssistantMessage(content: string, id?: string): AgentInputItem { + return { + type: 'message', + role: 'assistant', + content: content, + id: id + } as unknown as AgentInputItem; +} + +/** + * Creates a mock SystemMessageItem with string content. + * Note: We use `as unknown as AgentInputItem` because the SDK types are strict unions. + */ +export function createSystemMessage(content: string, id?: string): AgentInputItem { + return { + type: 'message', + role: 'system', + content: content, + id: id + } as unknown as AgentInputItem; +} + +/** + * Creates a mock message with array content (ContentPart[]). + * Note: We use `as unknown as AgentInputItem` because the SDK types are strict unions. + */ +export function createMessageWithArrayContent( + role: string, + contentParts: Array<{ type: string; text?: string }>, + id?: string +): AgentInputItem { + return { + type: 'message', + role: role, + content: contentParts, + id: id + } as unknown as AgentInputItem; +} + +/** + * Creates a mock message without an ID. + * Note: We use `as unknown as AgentInputItem` because the SDK types are strict unions. + */ +export function createMessageWithoutId(role: string, content: string): AgentInputItem { + return { + type: 'message', + role: role, + content: content + } as unknown as AgentInputItem; +} + +/** + * Creates a mock message with empty content. + * Note: We use `as unknown as AgentInputItem` because the SDK types are strict unions. + */ +export function createMessageWithEmptyContent(role: string, id?: string): AgentInputItem { + return { + type: 'message', + role: role, + content: '', + id: id + } as unknown as AgentInputItem; +} + +/** + * Creates a mock message with null content. + */ +export function createMessageWithNullContent(role: string, id?: string): AgentInputItem { + return { + type: 'message', + role: role, + content: null, + id: id + } as unknown as AgentInputItem; +} + +/** + * Creates a mock message with undefined content. + */ +export function createMessageWithUndefinedContent(role: string, id?: string): AgentInputItem { + return { + type: 'message', + role: role, + content: undefined, + id: id + } as unknown as AgentInputItem; +} + +/** + * Creates a mock message with whitespace-only content. + * Note: We use `as unknown as AgentInputItem` because the SDK types are strict unions. + */ +export function createMessageWithWhitespaceContent(role: string, id?: string): AgentInputItem { + return { + type: 'message', + role: role, + content: ' ', + id: id + } as unknown as AgentInputItem; +} + +/** + * Creates a mock message with unknown role. + * Note: We use `as unknown as AgentInputItem` because the SDK types don't allow custom roles. + */ +export function createMessageWithUnknownRole(content: string, id?: string): AgentInputItem { + return { + type: 'message', + role: 'custom_role', + content: content, + id: id + } as unknown as AgentInputItem; +} + +/** + * Creates a mock message with text property instead of content. + */ +export function createMessageWithTextProperty(role: string, text: string, id?: string): AgentInputItem { + return { + type: 'message', + role: role, + text: text, + id: id + } as unknown as AgentInputItem; +} + +/** + * Mock OpenAIConversationsSession class with controllable getItems() behavior. + * Note: We cannot directly implement OpenAIConversationsSession as it has private members. + * Use `as unknown as OpenAIConversationsSession` when passing to methods that expect the real type. + */ +export class MockOpenAIConversationsSession { + public sessionId?: string; + private items: AgentInputItem[]; + private shouldThrow: boolean; + private throwError: Error | null; + + constructor(items: AgentInputItem[] = [], sessionId?: string) { + this.items = items; + this.sessionId = sessionId; + this.shouldThrow = false; + this.throwError = null; + } + + /** + * Configure the session to throw an error on getItems(). + */ + setThrowOnGetItems(error: Error): void { + this.shouldThrow = true; + this.throwError = error; + } + + /** + * Set the items to be returned by getItems(). + */ + setItems(items: AgentInputItem[]): void { + this.items = items; + } + + /** + * Retrieves items from the session. + * @param limit - Optional limit on the number of items to retrieve. + * @returns A Promise resolving to an array of AgentInputItem objects. + */ + async getItems(limit?: number): Promise { + if (this.shouldThrow && this.throwError) { + throw this.throwError; + } + + if (limit !== undefined && limit > 0) { + return this.items.slice(0, limit); + } + + return this.items; + } +} + +/** + * Creates a standard set of mixed messages for testing. + */ +export function createMixedMessages(): AgentInputItem[] { + return [ + createUserMessage('Hello, how are you?', 'msg-1'), + createAssistantMessage('I am doing well, thank you!', 'msg-2'), + createUserMessage('What is the weather today?', 'msg-3'), + createAssistantMessage('I cannot check the weather directly.', 'msg-4'), + ]; +} + +/** + * Creates messages with various content types for testing content extraction. + */ +export function createMessagesWithVariousContentTypes(): AgentInputItem[] { + return [ + // String content + createUserMessage('Simple text message', 'msg-1'), + // Array content with text parts + createMessageWithArrayContent('user', [ + { type: 'text', text: 'Part 1' }, + { type: 'text', text: 'Part 2' }, + ], 'msg-2'), + // Array content with input_text type + createMessageWithArrayContent('user', [ + { type: 'input_text', text: 'Input text content' }, + ], 'msg-3'), + // Message without ID + createMessageWithoutId('assistant', 'Response without ID'), + ]; +} diff --git a/tests/tooling-extensions-openai/messageConversion.test.ts b/tests/tooling-extensions-openai/messageConversion.test.ts new file mode 100644 index 00000000..09ccf26f --- /dev/null +++ b/tests/tooling-extensions-openai/messageConversion.test.ts @@ -0,0 +1,459 @@ +// 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 { McpToolRegistrationService } from '../../packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService'; +import { + createUserMessage, + createAssistantMessage, + createSystemMessage, + createMessageWithArrayContent, + createMessageWithoutId, + createMessageWithEmptyContent, + createMessageWithNullContent, + createMessageWithUndefinedContent, + createMessageWithWhitespaceContent, + createMessageWithUnknownRole, + createMessageWithTextProperty, +} from './fixtures/mockOpenAITypes'; +import axios from 'axios'; + +// Mock axios +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('McpToolRegistrationService - Message Conversion', () => { + let service: McpToolRegistrationService; + let mockTurnContext: jest.Mocked; + + beforeEach(() => { + service = new McpToolRegistrationService(); + + // 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; + + // Reset all mocks + jest.clearAllMocks(); + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('extractRole', () => { + it('CV-01: should return role directly (pass-through) for user role', async () => { + const messages = [createUserMessage('Test content', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ role: 'user' }), + ]), + }), + expect.any(Object) + ); + }); + + it('CV-01: should return role directly (pass-through) for assistant role', async () => { + const messages = [createAssistantMessage('Test content', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ role: 'assistant' }), + ]), + }), + expect.any(Object) + ); + }); + + it('CV-01: should return role directly (pass-through) for system role', async () => { + const messages = [createSystemMessage('Test content', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ role: 'system' }), + ]), + }), + expect.any(Object) + ); + }); + + it('CV-01: should pass through unknown/custom roles without modification', async () => { + const messages = [createMessageWithUnknownRole('Test content', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ role: 'custom_role' }), + ]), + }), + expect.any(Object) + ); + }); + }); + + describe('extractContent', () => { + it('CV-02: should extract string content directly', async () => { + const messages = [createUserMessage('Hello, world!', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ content: 'Hello, world!' }), + ]), + }), + expect.any(Object) + ); + }); + + it('CV-03: should concatenate array content (text type)', async () => { + const messages = [ + createMessageWithArrayContent( + 'user', + [ + { type: 'text', text: 'Part 1' }, + { type: 'text', text: 'Part 2' }, + { type: 'text', text: 'Part 3' }, + ], + 'msg-1' + ), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ content: 'Part 1 Part 2 Part 3' }), + ]), + }), + expect.any(Object) + ); + }); + + it('CV-03: should concatenate array content (input_text type)', async () => { + const messages = [ + createMessageWithArrayContent( + 'user', + [ + { type: 'input_text', text: 'Input 1' }, + { type: 'input_text', text: 'Input 2' }, + ], + 'msg-1' + ), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ content: 'Input 1 Input 2' }), + ]), + }), + expect.any(Object) + ); + }); + + it('CV-03: should filter out non-text parts from array content', async () => { + const messages = [ + createMessageWithArrayContent( + 'user', + [ + { type: 'text', text: 'Valid text' }, + { type: 'image' } as { type: string; text?: string }, // Image part without text + { type: 'text', text: 'More text' }, + ], + 'msg-1' + ), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ content: 'Valid text More text' }), + ]), + }), + expect.any(Object) + ); + }); + + it('CV-04: should skip message with empty content (filters out)', async () => { + const messages = [ + createUserMessage('Valid message', 'msg-1'), + createMessageWithEmptyContent('user', 'msg-2'), + createUserMessage('Another valid message', 'msg-3'), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: [ + expect.objectContaining({ id: 'msg-1', content: 'Valid message' }), + expect.objectContaining({ id: 'msg-3', content: 'Another valid message' }), + ], + }), + expect.any(Object) + ); + }); + + it('CV-04: should skip message with null content', async () => { + const messages = [ + createUserMessage('Valid message', 'msg-1'), + createMessageWithNullContent('user', 'msg-2'), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: [ + expect.objectContaining({ id: 'msg-1' }), + ], + }), + expect.any(Object) + ); + }); + + it('CV-04: should skip message with undefined content', async () => { + const messages = [ + createUserMessage('Valid message', 'msg-1'), + createMessageWithUndefinedContent('user', 'msg-2'), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: [ + expect.objectContaining({ id: 'msg-1' }), + ], + }), + expect.any(Object) + ); + }); + + it('CV-04: should skip message with whitespace-only content', async () => { + const messages = [ + createUserMessage('Valid message', 'msg-1'), + createMessageWithWhitespaceContent('user', 'msg-2'), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: [ + expect.objectContaining({ id: 'msg-1' }), + ], + }), + expect.any(Object) + ); + }); + + it('should extract content from text property as fallback', async () => { + const messages = [createMessageWithTextProperty('user', 'Fallback text content', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ content: 'Fallback text content' }), + ]), + }), + expect.any(Object) + ); + }); + }); + + describe('extractId', () => { + it('CV-05: should use existing ID when present', async () => { + const messages = [createUserMessage('Test content', 'existing-id-123')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ id: 'existing-id-123' }), + ]), + }), + expect.any(Object) + ); + }); + + it('CV-06: should generate UUID when ID is missing', async () => { + const messages = [createMessageWithoutId('user', 'Test content')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + // Verify that an ID was generated (should be a valid UUID format) + const callArgs = mockedAxios.post.mock.calls[0]; + const requestBody = callArgs[1] as { chatHistory: Array<{ id: string }> }; + const generatedId = requestBody.chatHistory[0].id; + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + expect(generatedId).toMatch(uuidV4Regex); + }); + }); + + describe('extractTimestamp', () => { + it('CV-07: should always use current time', async () => { + const beforeTime = new Date(); + const messages = [createUserMessage('Test content', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const afterTime = new Date(); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ + timestamp: expect.any(Date), + }), + ]), + }), + expect.any(Object) + ); + + // Get the actual timestamp from the call + const callArgs = mockedAxios.post.mock.calls[0]; + const requestBody = callArgs[1] as { chatHistory: Array<{ timestamp: Date }> }; + const timestamp = requestBody.chatHistory[0].timestamp; + + // Verify timestamp is within expected range + expect(timestamp.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime()); + expect(timestamp.getTime()).toBeLessThanOrEqual(afterTime.getTime()); + }); + + it('CV-07: should generate different timestamps for different messages', async () => { + const messages = [ + createUserMessage('Test 1', 'msg-1'), + createUserMessage('Test 2', 'msg-2'), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const requestBody = callArgs[1] as { chatHistory: Array<{ timestamp: Date }> }; + + // Both should have Date objects + expect(requestBody.chatHistory[0].timestamp).toBeInstanceOf(Date); + expect(requestBody.chatHistory[1].timestamp).toBeInstanceOf(Date); + }); + }); + + describe('edge cases', () => { + it('should handle mixed valid and invalid messages', async () => { + const messages = [ + createUserMessage('Valid 1', 'msg-1'), + createMessageWithEmptyContent('user', 'msg-2'), + createAssistantMessage('Valid 2', 'msg-3'), + createMessageWithNullContent('assistant', 'msg-4'), + createSystemMessage('Valid 3', 'msg-5'), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: [ + expect.objectContaining({ id: 'msg-1', role: 'user', content: 'Valid 1' }), + expect.objectContaining({ id: 'msg-3', role: 'assistant', content: 'Valid 2' }), + expect.objectContaining({ id: 'msg-5', role: 'system', content: 'Valid 3' }), + ], + }), + expect.any(Object) + ); + }); + + it('should handle messages with empty array content', async () => { + const messages = [ + createUserMessage('Valid message', 'msg-1'), + createMessageWithArrayContent('user', [], 'msg-2'), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + // Message with empty array content should be filtered out + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: [ + expect.objectContaining({ id: 'msg-1' }), + ], + }), + expect.any(Object) + ); + }); + + it('should handle array content with empty text parts', async () => { + const messages = [ + createMessageWithArrayContent( + 'user', + [ + { type: 'text', text: '' }, + { type: 'text', text: 'Valid' }, + { type: 'text', text: '' }, + ], + 'msg-1' + ), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ content: 'Valid' }), + ]), + }), + expect.any(Object) + ); + }); + }); +}); diff --git a/tests/tooling-extensions-openai/sendChatHistoryAsync.test.ts b/tests/tooling-extensions-openai/sendChatHistoryAsync.test.ts new file mode 100644 index 00000000..ad7aecf3 --- /dev/null +++ b/tests/tooling-extensions-openai/sendChatHistoryAsync.test.ts @@ -0,0 +1,248 @@ +// 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 { McpToolRegistrationService } from '../../packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService'; +import { OpenAIConversationsSession } from '@openai/agents-openai'; +import { + createMixedMessages, + MockOpenAIConversationsSession, + createUserMessage, +} from './fixtures/mockOpenAITypes'; +import axios from 'axios'; + +// Mock axios +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('McpToolRegistrationService - sendChatHistoryAsync', () => { + let service: McpToolRegistrationService; + let mockTurnContext: jest.Mocked; + let mockSession: MockOpenAIConversationsSession; + + beforeEach(() => { + service = new McpToolRegistrationService(); + + // 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 mock session with sample messages + mockSession = new MockOpenAIConversationsSession(createMixedMessages(), 'session-123'); + + // Reset all mocks + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('input validation', () => { + it('UV-01: should throw when turnContext is null', async () => { + await expect( + service.sendChatHistoryAsync(null as unknown as TurnContext, mockSession as unknown as OpenAIConversationsSession) + ).rejects.toThrow('turnContext is required'); + }); + + it('UV-01: should throw when turnContext is undefined', async () => { + await expect( + service.sendChatHistoryAsync(undefined as unknown as TurnContext, mockSession as unknown as OpenAIConversationsSession) + ).rejects.toThrow('turnContext is required'); + }); + + it('UV-02: should throw when session is null', async () => { + await expect( + service.sendChatHistoryAsync(mockTurnContext, null as unknown as OpenAIConversationsSession) + ).rejects.toThrow('session is required'); + }); + + it('UV-02: should throw when session is undefined', async () => { + await expect( + service.sendChatHistoryAsync(mockTurnContext, undefined as unknown as OpenAIConversationsSession) + ).rejects.toThrow('session is required'); + }); + }); + + describe('successful scenarios', () => { + it('SP-01: should extract and send session items successfully', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const result = await service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession); + + expect(result).toBeDefined(); + expect(result.succeeded).toBe(true); + expect(result.errors).toHaveLength(0); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('SP-02: should respect limit parameter', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = createMixedMessages(); + mockSession.setItems(messages); + + // Call with limit of 2 + await service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession, 2); + + // Verify the request was made with only 2 messages + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ role: 'user' }), + expect.objectContaining({ role: 'assistant' }), + ]), + }), + expect.any(Object) + ); + }); + + it('should return success for empty session', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + mockSession.setItems([]); + + const result = await service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession); + + expect(result.succeeded).toBe(true); + // Even with empty array, API call should be made to MCP platform + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + const callArgs = mockedAxios.post.mock.calls[0]; + expect(callArgs[1]).toEqual({ + conversationId: 'conv-123', + messageId: 'msg-456', + userMessage: 'Current user message', + chatHistory: [] + }); + }); + + it('should pass toolOptions to the underlying service', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const toolOptions = { orchestratorName: 'CustomBot' }; + + await service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession, undefined, toolOptions); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': expect.stringContaining('CustomBot'), + }), + }) + ); + }); + }); + + describe('error handling', () => { + it('EH-01: should return failed on HTTP error', async () => { + const httpError = new Error('Network error'); + mockedAxios.post.mockRejectedValue(httpError); + mockedAxios.isAxiosError.mockReturnValue(false); + + const result = await service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Network error'); + }); + + it('EH-02: should return failed on timeout', async () => { + const timeoutError = Object.assign(new Error('Timeout'), { code: 'ETIMEDOUT' }); + mockedAxios.post.mockRejectedValue(timeoutError); + mockedAxios.isAxiosError.mockReturnValue(true); + + const result = await service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Timeout'); + }); + + it('EH-04: should return failed on session.getItems error', async () => { + const sessionError = new Error('Session error'); + mockSession.setThrowOnGetItems(sessionError); + + const result = await service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Session error'); + }); + + it('should re-throw validation errors from nested call', async () => { + // Create a session that returns messages but triggers validation error + // by having missing conversation ID + mockTurnContext.activity.conversation = undefined as unknown as { id: string }; + + await expect( + service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession) + ).rejects.toThrow('Conversation ID is required'); + }); + }); + + describe('OperationResult behavior', () => { + it('should return OperationResult.success on successful request', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const result = await service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession); + + 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.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession); + + expect(result).not.toBe(OperationResult.success); + expect(result.toString()).toContain('Failed'); + expect(result.toString()).toContain('Test error'); + }); + }); + + describe('integration with sendChatHistoryMessagesAsync', () => { + it('should correctly delegate to sendChatHistoryMessagesAsync', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [ + createUserMessage('Test message 1', 'id-1'), + createUserMessage('Test message 2', 'id-2'), + ]; + mockSession.setItems(messages); + + const result = await service.sendChatHistoryAsync(mockTurnContext, mockSession as unknown as OpenAIConversationsSession); + + expect(result.succeeded).toBe(true); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + conversationId: 'conv-123', + messageId: 'msg-456', + userMessage: 'Current user message', + chatHistory: expect.arrayContaining([ + expect.objectContaining({ + id: 'id-1', + role: 'user', + content: 'Test message 1', + }), + expect.objectContaining({ + id: 'id-2', + role: 'user', + content: 'Test message 2', + }), + ]), + }), + expect.any(Object) + ); + }); + }); +}); diff --git a/tests/tooling-extensions-openai/sendChatHistoryMessagesAsync.test.ts b/tests/tooling-extensions-openai/sendChatHistoryMessagesAsync.test.ts new file mode 100644 index 00000000..700e77dd --- /dev/null +++ b/tests/tooling-extensions-openai/sendChatHistoryMessagesAsync.test.ts @@ -0,0 +1,312 @@ +// 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 { McpToolRegistrationService } from '../../packages/agents-a365-tooling-extensions-openai/src/McpToolRegistrationService'; +import { AgentInputItem } from '@openai/agents'; +import { + createMixedMessages, + createUserMessage, + createMessageWithEmptyContent, + createMessageWithNullContent, +} from './fixtures/mockOpenAITypes'; +import axios from 'axios'; + +// Mock axios +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('McpToolRegistrationService - sendChatHistoryMessagesAsync', () => { + let service: McpToolRegistrationService; + let mockTurnContext: jest.Mocked; + + beforeEach(() => { + service = new McpToolRegistrationService(); + + // 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; + + // Reset all mocks + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('input validation', () => { + it('UV-03: should throw when turnContext is null', async () => { + const messages = createMixedMessages(); + await expect( + service.sendChatHistoryMessagesAsync(null as unknown as TurnContext, messages) + ).rejects.toThrow('turnContext is required'); + }); + + it('UV-03: should throw when turnContext is undefined', async () => { + const messages = createMixedMessages(); + await expect( + service.sendChatHistoryMessagesAsync(undefined as unknown as TurnContext, messages) + ).rejects.toThrow('turnContext is required'); + }); + + it('UV-04: should throw when messages is null', async () => { + await expect( + service.sendChatHistoryMessagesAsync(mockTurnContext, null as unknown as AgentInputItem[]) + ).rejects.toThrow('messages is required'); + }); + + it('UV-04: should throw when messages is undefined', async () => { + await expect( + service.sendChatHistoryMessagesAsync(mockTurnContext, undefined as unknown as AgentInputItem[]) + ).rejects.toThrow('messages is required'); + }); + + it('UV-05: should make MCP platform call even with empty array', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const result = await service.sendChatHistoryMessagesAsync(mockTurnContext, []); + + expect(result.succeeded).toBe(true); + expect(result).toBe(OperationResult.success); + // Verify that HTTP call was made even with empty messages + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + conversationId: 'conv-123', + messageId: 'msg-456', + userMessage: 'Current user message', + chatHistory: [], // Empty chat history + }), + expect.any(Object) + ); + }); + }); + + describe('successful scenarios', () => { + it('SP-03: should return success on successful send', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = createMixedMessages(); + + const result = await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(result).toBeDefined(); + expect(result.succeeded).toBe(true); + expect(result.errors).toHaveLength(0); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('SP-04: should use default orchestrator name "OpenAI"', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = createMixedMessages(); + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': expect.stringContaining('OpenAI'), + }), + }) + ); + }); + + it('SP-05: should use custom ToolOptions when provided', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = createMixedMessages(); + const toolOptions = { orchestratorName: 'CustomOrchestrator' }; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages, toolOptions); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + headers: expect.objectContaining({ + 'User-Agent': expect.stringContaining('CustomOrchestrator'), + }), + }) + ); + }); + + it('should send correct request payload', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [ + createUserMessage('Hello', 'msg-1'), + createUserMessage('World', 'msg-2'), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/agents/real-time-threat-protection/chat-message'), + { + conversationId: 'conv-123', + messageId: 'msg-456', + userMessage: 'Current user message', + chatHistory: expect.arrayContaining([ + expect.objectContaining({ + id: 'msg-1', + role: 'user', + content: 'Hello', + timestamp: expect.any(Date), + }), + expect.objectContaining({ + id: 'msg-2', + role: 'user', + content: 'World', + timestamp: expect.any(Date), + }), + ]), + }, + expect.objectContaining({ + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + timeout: 10000, + }) + ); + }); + }); + + describe('error handling', () => { + it('EH-03: should filter out messages that fail conversion', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [ + createUserMessage('Valid message', 'msg-1'), + createMessageWithEmptyContent('user', 'msg-2'), // Will fail conversion + createUserMessage('Another valid message', 'msg-3'), + ]; + + const result = await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(result.succeeded).toBe(true); + // Should only have 2 messages in the request (the invalid one filtered out) + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + chatHistory: expect.arrayContaining([ + expect.objectContaining({ id: 'msg-1' }), + expect.objectContaining({ id: 'msg-3' }), + ]), + }), + expect.any(Object) + ); + }); + + it('should handle all messages failing conversion gracefully', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [ + createMessageWithEmptyContent('user', 'msg-1'), + createMessageWithNullContent('assistant', 'msg-2'), + ]; + + const result = await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + // Should still succeed but with empty chat history + expect(result.succeeded).toBe(true); + }); + + it('should return failed on HTTP error', async () => { + const httpError = new Error('Network error'); + mockedAxios.post.mockRejectedValue(httpError); + mockedAxios.isAxiosError.mockReturnValue(false); + const messages = createMixedMessages(); + + const result = await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Network error'); + }); + + it('should return failed on timeout', async () => { + const timeoutError = Object.assign(new Error('Connection timed out'), { code: 'ETIMEDOUT' }); + mockedAxios.post.mockRejectedValue(timeoutError); + mockedAxios.isAxiosError.mockReturnValue(true); + const messages = createMixedMessages(); + + const result = await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + }); + + it('EH-05: should return failed when message conversion throws unexpected error', async () => { + const messages = createMixedMessages(); + + // Mock convertToChatHistoryMessages to throw an error + jest.spyOn(service as any, 'convertToChatHistoryMessages').mockImplementation(() => { + throw new Error('Conversion error'); + }); + + const result = await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Conversion error'); + }); + + it('should re-throw validation errors from core service', async () => { + const messages = createMixedMessages(); + // Remove conversation ID to trigger validation error + mockTurnContext.activity.conversation = undefined as unknown as { id: string }; + + await expect( + service.sendChatHistoryMessagesAsync(mockTurnContext, messages) + ).rejects.toThrow('Conversation ID is required'); + }); + + it('should re-throw validation error when message ID is missing', async () => { + const messages = createMixedMessages(); + mockTurnContext.activity.id = undefined; + + await expect( + service.sendChatHistoryMessagesAsync(mockTurnContext, messages) + ).rejects.toThrow('Message ID is required'); + }); + + it('should re-throw validation error when user message is missing', async () => { + const messages = createMixedMessages(); + mockTurnContext.activity.text = undefined; + + await expect( + service.sendChatHistoryMessagesAsync(mockTurnContext, messages) + ).rejects.toThrow('User message is required'); + }); + }); + + describe('OperationResult behavior', () => { + it('should return OperationResult.success on successful request', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = createMixedMessages(); + + const result = await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(result).toBe(OperationResult.success); + expect(result.toString()).toBe('Succeeded'); + }); + + it('should return new failed OperationResult on error', async () => { + mockedAxios.post.mockRejectedValue(new Error('Server error')); + mockedAxios.isAxiosError.mockReturnValue(false); + const messages = createMixedMessages(); + + const result = await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + expect(result).not.toBe(OperationResult.success); + expect(result.succeeded).toBe(false); + expect(result.toString()).toContain('Failed'); + }); + }); +});