diff --git a/packages/agents-a365-tooling-extensions-claude/docs/design.md b/packages/agents-a365-tooling-extensions-claude/docs/design.md index fd2fc08e..0c96445b 100644 --- a/packages/agents-a365-tooling-extensions-claude/docs/design.md +++ b/packages/agents-a365-tooling-extensions-claude/docs/design.md @@ -204,77 +204,88 @@ private readonly orchestratorName: string = "Claude"; ## Chat History API -> **Last Assessed:** January 2026 -> **Claude SDK Version:** ^0.1.30 (workspace), `unstable_v2` APIs added in v0.1.54 -> **Tracking Issue:** [#164 - Claude SDK: Monitor for chat history API availability](https://github.com/microsoft/Agent365-nodejs/issues/164) +> **Last Assessed:** June 2026 +> **Claude SDK Version:** ^0.2.59 (workspace, `getSessionMessages` added in v0.2.59) +> **Tracking Issue:** [#164 - Claude SDK: Monitor for chat history API availability](https://github.com/microsoft/Agent365-nodejs/issues/164) — **Resolved** ### Current State -Unlike the OpenAI extension which provides `sendChatHistoryAsync` via `OpenAIConversationsSession.getItems()`, the Claude Agent SDK (`@anthropic-ai/claude-agent-sdk`) **does not expose programmatic access to conversation history**. +The Claude Agent SDK (`@anthropic-ai/claude-agent-sdk` v0.2.59+) now exposes `getSessionMessages(sessionId, options?)` which returns `SessionMessage[]`. This package provides a Claude-specific `sendChatHistoryAsync` method that mirrors the OpenAI extension's implementation from PR #157. -The SDK includes experimental `unstable_v2_*` session APIs (added in v0.1.54): -- `unstable_v2_createSession` - Creates a new session for multi-turn conversations -- `unstable_v2_resumeSession` - Resumes an existing session by ID -- `unstable_v2_prompt` - One-shot convenience function for single-turn queries +### Usage -However, these APIs only provide: -- `session.send(message)` - Send a message to Claude -- `session.stream()` - Stream back response messages -- `session.close()` - Close the session +```typescript +import { McpToolRegistrationService } from '@microsoft/agents-a365-tooling-extensions-claude'; -**There is no `session.getHistory()` or equivalent method** to retrieve past messages from a session. Sessions maintain context internally for Claude to reference, but this history is opaque to SDK consumers. +const registrationService = new McpToolRegistrationService(); -### Recommended Approach +// Send chat history from a Claude session to MCP platform for real-time threat protection +const result = await registrationService.sendChatHistoryAsync( + turnContext, + 'session-abc-123', // Claude session ID + 50 // Optional: limit to most recent N messages +); -Developers should use the generic `sendChatHistory` method from `@microsoft/agents-a365-tooling` by manually constructing `ChatHistoryMessage[]`: +if (result.succeeded) { + console.log('Chat history sent successfully'); +} else { + console.error('Failed to send chat history:', result.errors); +} +``` + +### Method Signatures + +**`sendChatHistoryAsync`** — Retrieves messages from a session and sends them: ```typescript -import { McpToolServerConfigurationService, ChatHistoryMessage } from '@microsoft/agents-a365-tooling'; -import { TurnContext } from '@microsoft/agents-hosting'; - -// Build chat history from your conversation tracking -const chatHistory: ChatHistoryMessage[] = [ - { - id: 'msg-001', - role: 'user', - content: 'Can you help me find my recent emails?', - timestamp: new Date('2026-01-27T10:00:00Z') - }, - { - id: 'msg-002', - role: 'assistant', - content: 'I\'d be happy to help you find your recent emails. Let me search for them now.', - timestamp: new Date('2026-01-27T10:00:05Z') - }, - { - id: 'msg-003', - role: 'user', - content: 'Great, show me emails from the last week.', - timestamp: new Date('2026-01-27T10:00:30Z') - } -]; +async sendChatHistoryAsync( + turnContext: TurnContext, + sessionId: string, + limit?: number, + toolOptions?: ToolOptions +): Promise +``` -// Send to MCP platform for real-time threat protection -const configService = new McpToolServerConfigurationService(); -const result = await configService.sendChatHistory(turnContext, chatHistory); +**`sendChatHistoryMessagesAsync`** — Sends pre-fetched messages: -if (!result.success) { - console.error('Failed to send chat history:', result.error); -} +```typescript +async sendChatHistoryMessagesAsync( + turnContext: TurnContext, + messages: SessionMessage[], + toolOptions?: ToolOptions +): Promise ``` -### Revisit Criteria +### Message Conversion + +SessionMessage fields are converted to `ChatHistoryMessage` as follows: -This limitation should be re-evaluated when any of the following occur: +| SessionMessage field | ChatHistoryMessage field | Notes | +|---------------------|------------------------|-------| +| `uuid` | `id` | Falls back to generated UUID if empty | +| `type` | `role` | Normalized: `user`→`user`, `assistant`→`assistant`, `system`→`system` | +| `message.content` | `content` | Extracts text from string content or `text` content blocks; non-text blocks (e.g., `tool_use`, `tool_result`) are ignored | +| *(not available)* | `timestamp` | Generated as current UTC time | -1. **Claude SDK adds history retrieval API** - Monitor for `session.getHistory()`, `session.getMessages()`, or similar methods -2. **`unstable_v2` APIs stabilize** - When APIs lose the `unstable_` prefix, review for new capabilities -3. **SDK version upgrade** - When upgrading `@anthropic-ai/claude-agent-sdk`, check changelog for history-related features -4. **Anthropic documentation updates** - Monitor [TypeScript V2 Preview docs](https://platform.claude.com/docs/en/agent-sdk/typescript-v2-preview) and [GitHub repo](https://github.com/anthropics/claude-agent-sdk-typescript) +Messages with empty extractable content are filtered out. + +### Advanced Usage with Pre-fetched Messages + +```typescript +import { getSessionMessages } from '@anthropic-ai/claude-agent-sdk'; +import { McpToolRegistrationService } from '@microsoft/agents-a365-tooling-extensions-claude'; + +// Fetch messages manually for custom processing +const messages = await getSessionMessages(sessionId, { limit: 100 }); + +// Send to MCP platform +const registrationService = new McpToolRegistrationService(); +const result = await registrationService.sendChatHistoryMessagesAsync(turnContext, messages); +``` ### References -- [Claude Agent SDK - TypeScript V2 Preview](https://platform.claude.com/docs/en/agent-sdk/typescript-v2-preview) - [Claude Agent SDK - GitHub Repository](https://github.com/anthropics/claude-agent-sdk-typescript) - [Claude Agent SDK - npm Package](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) - [OpenAI Extension sendChatHistory PR #157](https://github.com/microsoft/Agent365-nodejs/pull/157) +- [Issue #164 - Claude SDK: Monitor for chat history API availability](https://github.com/microsoft/Agent365-nodejs/issues/164) diff --git a/packages/agents-a365-tooling-extensions-claude/package.json b/packages/agents-a365-tooling-extensions-claude/package.json index fab45372..c1ad3df6 100644 --- a/packages/agents-a365-tooling-extensions-claude/package.json +++ b/packages/agents-a365-tooling-extensions-claude/package.json @@ -38,9 +38,11 @@ "@microsoft/agents-a365-runtime": "workspace:*", "@microsoft/agents-a365-tooling": "workspace:*", "@microsoft/agents-hosting": "catalog:", - "@modelcontextprotocol/sdk": "catalog:" + "@modelcontextprotocol/sdk": "catalog:", + "uuid": "catalog:" }, "devDependencies": { + "@types/uuid": "catalog:", "@eslint/js": "catalog:", "@types/jest": "catalog:", "@types/node": "catalog:", diff --git a/packages/agents-a365-tooling-extensions-claude/src/McpToolRegistrationService.ts b/packages/agents-a365-tooling-extensions-claude/src/McpToolRegistrationService.ts index 4c63730f..b577ee94 100644 --- a/packages/agents-a365-tooling-extensions-claude/src/McpToolRegistrationService.ts +++ b/packages/agents-a365-tooling-extensions-claude/src/McpToolRegistrationService.ts @@ -1,15 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { McpToolServerConfigurationService, McpClientTool, Utility, MCPServerConfig, ToolOptions } from '@microsoft/agents-a365-tooling'; -import { AgenticAuthenticationService, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import { v4 as uuidv4 } from 'uuid'; +import { McpToolServerConfigurationService, McpClientTool, Utility, MCPServerConfig, ToolOptions, ChatHistoryMessage } from '@microsoft/agents-a365-tooling'; +import { AgenticAuthenticationService, OperationResult, OperationError, IConfigurationProvider } from '@microsoft/agents-a365-runtime'; import { ClaudeToolingConfiguration, defaultClaudeToolingConfigurationProvider } from './configuration'; // Agents SDK import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -// Claude SDK expects a different shape for MCP server configs -import type { McpServerConfig, Options } from '@anthropic-ai/claude-agent-sdk'; +// Claude SDK +import type { McpServerConfig, Options, SessionMessage, GetSessionMessagesOptions } from '@anthropic-ai/claude-agent-sdk'; +import { getSessionMessages } from '@anthropic-ai/claude-agent-sdk'; + +/** + * Represents a content block within a Claude message payload. + */ +interface ContentBlock { + type: string; + text?: string; +} /** * Discover MCP servers and list tools formatted for the Claude SDK. @@ -96,4 +106,195 @@ export class McpToolRegistrationService { agentOptions.mcpServers = Object.assign(agentOptions.mcpServers ?? {}, mcpServers); } + + /** + * Sends chat history from a Claude session to the MCP platform for real-time threat protection. + * + * This method retrieves messages from the specified Claude session using `getSessionMessages()`, + * converts them to the `ChatHistoryMessage` format, and sends them to the MCP platform. + * + * @param turnContext - The turn context containing conversation information. + * @param sessionId - The Claude session ID to retrieve messages from. + * @param limit - Optional limit on the number of messages to send. When specified, the most recent N messages are used. + * @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 sessionId is null/undefined/empty. + * @throws Error if required turn context properties are missing. + * + * @example + * ```typescript + * const result = await service.sendChatHistoryAsync(turnContext, 'session-abc-123', 50); + * if (result.succeeded) { + * console.log('Chat history sent successfully'); + * } else { + * console.error('Failed to send chat history:', result.errors); + * } + * ``` + */ + async sendChatHistoryAsync( + turnContext: TurnContext, + sessionId: string, + limit?: number, + toolOptions?: ToolOptions + ): Promise { + if (!turnContext) { + throw new Error('turnContext is required'); + } + if (!sessionId || sessionId.trim().length === 0) { + throw new Error('sessionId is required'); + } + + let messages: SessionMessage[]; + try { + const options: GetSessionMessagesOptions = {}; + if (limit !== undefined && limit >= 0) { + options.limit = limit; + } + messages = await getSessionMessages(sessionId, options); + } catch (err: unknown) { + const error = err as Error; + return OperationResult.failed(new OperationError(error)); + } + + return await this.sendChatHistoryMessagesAsync( + turnContext, + messages, + toolOptions + ); + } + + /** + * Sends a list of Claude session messages to the MCP platform for real-time threat protection. + * + * This method converts the provided SessionMessage array to `ChatHistoryMessage` format + * and sends them to the MCP platform. + * + * @param turnContext - The turn context containing conversation information. + * @param messages - Array of SessionMessage objects 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 + * import { getSessionMessages } from '@anthropic-ai/claude-agent-sdk'; + * const messages = await getSessionMessages(sessionId); + * const result = await service.sendChatHistoryMessagesAsync(turnContext, messages); + * ``` + */ + async sendChatHistoryMessagesAsync( + turnContext: TurnContext, + messages: SessionMessage[], + toolOptions?: ToolOptions + ): Promise { + if (!turnContext) { + throw new Error('turnContext is required'); + } + if (!messages) { + throw new Error('messages is required'); + } + + const effectiveOptions: ToolOptions = { + orchestratorName: toolOptions?.orchestratorName ?? this.orchestratorName + }; + + let chatHistoryMessages: ChatHistoryMessage[]; + try { + chatHistoryMessages = this.convertToChatHistoryMessages(messages); + } catch (err: unknown) { + const error = err as Error; + return OperationResult.failed(new OperationError(error)); + } + + return await this.configService.sendChatHistory( + turnContext, + chatHistoryMessages, + effectiveOptions + ); + } + + /** + * Converts Claude SessionMessage array to ChatHistoryMessage format. + * @param messages - Array of SessionMessage objects to convert. + * @returns Array of successfully converted ChatHistoryMessage objects. + */ + private convertToChatHistoryMessages(messages: SessionMessage[]): ChatHistoryMessage[] { + return messages + .map(msg => this.convertSingleMessage(msg)) + .filter((msg): msg is ChatHistoryMessage => msg !== null); + } + + /** + * Converts a single Claude SessionMessage to ChatHistoryMessage format. + * @param message - The SessionMessage to convert. + * @returns A ChatHistoryMessage object, or null if conversion fails or message has no extractable content. + */ + private convertSingleMessage(message: SessionMessage): ChatHistoryMessage | null { + try { + const content = this.extractContent(message); + if (!content || content.trim().length === 0) { + return null; + } + + return { + id: this.extractId(message), + role: message.type === 'assistant' ? 'assistant' : 'user', + content: content, + timestamp: this.extractTimestamp(message) + }; + } catch { + return null; + } + } + + /** + * Extracts text content from a Claude SessionMessage. + * The message field contains the raw Anthropic API message payload with a `content` + * property that is either a string (user shorthand) or an array of content blocks. + * @param sessionMsg - The SessionMessage to extract content from. + * @returns The extracted content string, or empty string if no text content found. + */ + private extractContent(sessionMsg: SessionMessage): string { + const payload = sessionMsg.message as { content: string | ContentBlock[] }; + const { content } = payload; + + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + return content + .filter(block => block.type === 'text' && block.text) + .map(block => block.text!) + .filter(text => text.length > 0) + .join(' '); + } + + return ''; + } + + /** + * Extracts or generates an ID for a message. + * @param message - The SessionMessage to extract or generate an ID for. + * @returns The message UUID, or a newly generated UUID if not present. + */ + private extractId(message: SessionMessage): string { + if (message.uuid) { + return message.uuid; + } + return uuidv4(); + } + + /** + * Extracts or generates a timestamp for a message. + * Claude SessionMessage does not include timestamps, so current UTC time is used. + * @param _message - The SessionMessage (unused, as timestamps are always generated). + * @returns The current Date. + */ + private extractTimestamp(_message: SessionMessage): Date { + return new Date(); + } } diff --git a/packages/agents-a365-tooling-extensions-claude/src/index.ts b/packages/agents-a365-tooling-extensions-claude/src/index.ts index 2038ad49..5efb0e9c 100644 --- a/packages/agents-a365-tooling-extensions-claude/src/index.ts +++ b/packages/agents-a365-tooling-extensions-claude/src/index.ts @@ -3,3 +3,7 @@ export * from './McpToolRegistrationService'; export * from './configuration'; + +// Re-export Claude SDK session utilities for convenience +export { getSessionMessages } from '@anthropic-ai/claude-agent-sdk'; +export type { SessionMessage, GetSessionMessagesOptions } from '@anthropic-ai/claude-agent-sdk'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67ccbf6a..90b1e3ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.1.30 - version: 0.1.47 + specifier: ^0.2.59 + version: 0.2.59 '@azure/identity': specifier: ^4.12.1 version: 4.13.0 @@ -534,7 +534,7 @@ importers: dependencies: '@anthropic-ai/claude-agent-sdk': specifier: 'catalog:' - version: 0.1.47(zod@4.1.13) + version: 0.2.59(zod@4.1.13) '@microsoft/agents-a365-runtime': specifier: workspace:* version: link:../agents-a365-runtime @@ -547,6 +547,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.25.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@4.1.13) + uuid: + specifier: ^10.0.0 + version: 10.0.0 devDependencies: '@eslint/js': specifier: 'catalog:' @@ -557,6 +560,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) @@ -824,8 +830,8 @@ importers: packages: - '@anthropic-ai/claude-agent-sdk@0.1.47': - resolution: {integrity: sha512-0LAXuqp2AsvJcvrpVrJKANbmkqp3ZMpEfm03vRL6DrLc8JIQ5n25aCagIBDMwIVJscDwjd9cn4uAHvdI2PMLvw==} + '@anthropic-ai/claude-agent-sdk@0.2.59': + resolution: {integrity: sha512-xPOUZZimZI5ChaO791olWGXqaRvCwOfj9/1micu42EL9czdcwiDm0WK1OGsqb2mZ7LSCoYWBB0ZHVKOxehemDA==} engines: {node: '>=18.0.0'} peerDependencies: zod: ^4.1.12 @@ -1184,63 +1190,91 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@img/sharp-darwin-arm64@0.33.5': - resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.33.5': - resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] - '@img/sharp-libvips-darwin-arm64@1.0.4': - resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.0.4': - resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.0.4': - resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.0.5': - resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-x64@1.0.4': - resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.33.5': - resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.33.5': - resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - '@img/sharp-linux-x64@0.33.5': - resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - '@img/sharp-win32-x64@0.33.5': - resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] @@ -3666,6 +3700,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -3771,16 +3806,19 @@ packages: snapshots: - '@anthropic-ai/claude-agent-sdk@0.1.47(zod@4.1.13)': + '@anthropic-ai/claude-agent-sdk@0.2.59(zod@4.1.13)': dependencies: zod: 4.1.13 optionalDependencies: - '@img/sharp-darwin-arm64': 0.33.5 - '@img/sharp-darwin-x64': 0.33.5 - '@img/sharp-linux-arm': 0.33.5 - '@img/sharp-linux-arm64': 0.33.5 - '@img/sharp-linux-x64': 0.33.5 - '@img/sharp-win32-x64': 0.33.5 + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 '@azure/abort-controller@2.1.2': dependencies: @@ -4236,47 +4274,66 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/sharp-darwin-arm64@0.33.5': + '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true - '@img/sharp-darwin-x64@0.33.5': + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true - '@img/sharp-libvips-darwin-arm64@1.0.4': + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true - '@img/sharp-libvips-darwin-x64@1.0.4': + '@img/sharp-libvips-linux-arm@1.2.4': optional: true - '@img/sharp-libvips-linux-arm64@1.0.4': + '@img/sharp-libvips-linux-x64@1.2.4': optional: true - '@img/sharp-libvips-linux-arm@1.0.5': + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true - '@img/sharp-libvips-linux-x64@1.0.4': + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true - '@img/sharp-linux-arm64@0.33.5': + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true - '@img/sharp-linux-arm@0.33.5': + '@img/sharp-linux-arm@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm': 1.2.4 optional: true - '@img/sharp-linux-x64@0.33.5': + '@img/sharp-linux-x64@0.34.5': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-win32-arm64@0.34.5': optional: true - '@img/sharp-win32-x64@0.33.5': + '@img/sharp-win32-x64@0.34.5': optional: true '@isaacs/balanced-match@4.0.1': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 352b3ee7..be8b61e0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,7 @@ packages: catalog: # Claude AI package - "@anthropic-ai/claude-agent-sdk": "^0.1.30" + "@anthropic-ai/claude-agent-sdk": "^0.2.59" # Azure packages "@azure/identity": "^4.12.1" diff --git a/tests/__mocks__/@anthropic-ai/claude-agent-sdk.ts b/tests/__mocks__/@anthropic-ai/claude-agent-sdk.ts new file mode 100644 index 00000000..2dd0a915 --- /dev/null +++ b/tests/__mocks__/@anthropic-ai/claude-agent-sdk.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Manual mock for @anthropic-ai/claude-agent-sdk (ESM-only module) +// Jest cannot load ESM .mjs files in CJS test mode, so we provide this mock. + +export const getSessionMessages = jest.fn(); + +export interface SessionMessage { + type: 'user' | 'assistant' | 'system'; + uuid: string; + session_id: string; + message: unknown; + parent_tool_use_id: string | null; +} + +export interface GetSessionMessagesOptions { + dir?: string; + limit?: number; + offset?: number; +} + +export interface McpServerConfig { + name: string; + url: string; + apiKey?: string; +} + +export interface Options { + serverConfigs?: McpServerConfig[]; +} diff --git a/tests/jest.config.cjs b/tests/jest.config.cjs index 3627b5ff..ecf34723 100644 --- a/tests/jest.config.cjs +++ b/tests/jest.config.cjs @@ -77,6 +77,7 @@ module.exports = { '^@microsoft/agents-a365-tooling-extensions-langchain$': '/packages/agents-a365-tooling-extensions-langchain/src', '^@microsoft/agents-a365-tooling-extensions-openai$': '/packages/agents-a365-tooling-extensions-openai/src', '^@microsoft/agents-a365-notifications$': '/packages/agents-a365-notifications/src', + '^@anthropic-ai/claude-agent-sdk$': '/tests/__mocks__/@anthropic-ai/claude-agent-sdk.ts', '^@opentelemetry/api$': '/node_modules/@opentelemetry/api' }, diff --git a/tests/tooling-extensions-claude/fixtures/mockClaudeTypes.ts b/tests/tooling-extensions-claude/fixtures/mockClaudeTypes.ts new file mode 100644 index 00000000..9d1876f2 --- /dev/null +++ b/tests/tooling-extensions-claude/fixtures/mockClaudeTypes.ts @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; + +/** + * Local type matching the SessionMessage shape from @anthropic-ai/claude-agent-sdk. + * Defined here to avoid ESM resolution issues in Jest. + */ +export interface MockSessionMessage { + type: 'user' | 'assistant' | 'system'; + uuid: string; + session_id: string; + message: unknown; + parent_tool_use_id: string | null; +} + +/** + * Creates a mock user message with string content. + */ +export function createUserMessage(content: string, uuid?: string): MockSessionMessage { + return { + type: 'user', + uuid: uuid ?? `msg-${randomUUID()}`, + session_id: 'session-123', + message: { role: 'user', content: content }, + parent_tool_use_id: null + } as MockSessionMessage; +} + +/** + * Creates a mock assistant message with string content. + */ +export function createAssistantMessage(content: string, uuid?: string): MockSessionMessage { + return { + type: 'assistant', + uuid: uuid ?? `msg-${randomUUID()}`, + session_id: 'session-123', + message: { role: 'assistant', content: content }, + parent_tool_use_id: null + } as MockSessionMessage; +} + +/** + * Creates a mock system message with string content. + */ +export function createSystemMessage(content: string, uuid?: string): MockSessionMessage { + return { + type: 'system', + uuid: uuid ?? `msg-${randomUUID()}`, + session_id: 'session-123', + message: { role: 'system', content: content }, + parent_tool_use_id: null + } as MockSessionMessage; +} + +/** + * Creates a mock assistant message with structured content blocks. + */ +export function createMessageWithContentBlocks( + type: 'user' | 'assistant' | 'system', + blocks: Array<{ type: string; text?: string; name?: string; input?: unknown; content?: unknown }>, + uuid?: string +): MockSessionMessage { + return { + type: type, + uuid: uuid ?? `msg-${randomUUID()}`, + session_id: 'session-123', + message: { role: type, content: blocks }, + parent_tool_use_id: null + } as MockSessionMessage; +} + +/** + * Creates a mock message with tool_use content block. + */ +export function createToolUseMessage(toolName: string, input: unknown, uuid?: string): MockSessionMessage { + return { + type: 'assistant', + uuid: uuid ?? `msg-${randomUUID()}`, + session_id: 'session-123', + message: { + role: 'assistant', + content: [ + { type: 'text', text: `I'll use the ${toolName} tool.` }, + { type: 'tool_use', name: toolName, input: input } + ] + }, + parent_tool_use_id: null + } as MockSessionMessage; +} + +/** + * Creates a mock tool_result message. + */ +export function createToolResultMessage(result: string, uuid?: string): MockSessionMessage { + return { + type: 'user', + uuid: uuid ?? `msg-${randomUUID()}`, + session_id: 'session-123', + message: { + role: 'user', + content: [ + { type: 'tool_result', content: result } + ] + }, + parent_tool_use_id: null + } as MockSessionMessage; +} + +/** + * Creates a mock message with empty content. + */ +export function createMessageWithEmptyContent(type: 'user' | 'assistant' | 'system', uuid?: string): MockSessionMessage { + return { + type: type, + uuid: uuid ?? `msg-${Math.random().toString(36).slice(2, 10)}`, + session_id: 'session-123', + message: { role: type, content: '' }, + parent_tool_use_id: null + } as MockSessionMessage; +} + +/** + * Creates a mock message without a UUID. + */ +export function createMessageWithoutUuid(type: 'user' | 'assistant' | 'system', content: string): MockSessionMessage { + return { + type: type, + uuid: '', + session_id: 'session-123', + message: { role: type, content: content }, + parent_tool_use_id: null + } as MockSessionMessage; +} + +/** + * Creates a standard set of mixed messages for testing. + */ +export function createMixedMessages(): MockSessionMessage[] { + 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(): MockSessionMessage[] { + return [ + createUserMessage('Simple text message', 'msg-1'), + createMessageWithContentBlocks('assistant', [ + { type: 'text', text: 'Here is my response.' }, + { type: 'text', text: 'With multiple blocks.' }, + ], 'msg-2'), + createToolUseMessage('search', { query: 'weather' }, 'msg-3'), + createToolResultMessage('Sunny, 72\u00B0F', 'msg-4'), + createMessageWithEmptyContent('user', 'msg-5'), + ]; +} diff --git a/tests/tooling-extensions-claude/messageConversion.test.ts b/tests/tooling-extensions-claude/messageConversion.test.ts new file mode 100644 index 00000000..142944e3 --- /dev/null +++ b/tests/tooling-extensions-claude/messageConversion.test.ts @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @ts-nocheck - MockSessionMessage type doesn't structurally match SDK's SessionMessage in strict mode +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { TurnContext } from '@microsoft/agents-hosting'; +import { McpToolRegistrationService } from '../../packages/agents-a365-tooling-extensions-claude/src/McpToolRegistrationService'; +import { + createUserMessage, + createAssistantMessage, + createMessageWithContentBlocks, + createToolUseMessage, + createToolResultMessage, + createMessageWithEmptyContent, + createMessageWithoutUuid, + createMessagesWithVariousContentTypes, +} from './fixtures/mockClaudeTypes'; +import axios from 'axios'; + +// Mock axios +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +// Mock getSessionMessages +jest.mock('@anthropic-ai/claude-agent-sdk', () => ({ + getSessionMessages: jest.fn(), +})); + +describe('McpToolRegistrationService - message conversion', () => { + let service: McpToolRegistrationService; + let mockTurnContext: jest.Mocked; + + beforeEach(() => { + service = new McpToolRegistrationService(); + mockTurnContext = { + activity: { + conversation: { id: 'conv-123' }, + id: 'msg-456', + text: 'Current user message', + channelId: 'test-channel', + }, + } as unknown as jest.Mocked; + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('role normalization', () => { + it('should map "user" type to "user" role', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [createUserMessage('Hello', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ role: string }> }; + expect(payload.chatHistory[0].role).toBe('user'); + }); + + it('should map "assistant" type to "assistant" role', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [createAssistantMessage('Hi there', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ role: string }> }; + expect(payload.chatHistory[0].role).toBe('assistant'); + }); + }); + + describe('content extraction', () => { + it('should extract string content from message payload', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [createUserMessage('Hello world', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ content: string }> }; + expect(payload.chatHistory[0].content).toBe('Hello world'); + }); + + it('should extract text from content blocks', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [createMessageWithContentBlocks('assistant', [ + { type: 'text', text: 'Part one.' }, + { type: 'text', text: 'Part two.' }, + ], 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ content: string }> }; + expect(payload.chatHistory[0].content).toBe('Part one. Part two.'); + }); + + it('should only extract text blocks from tool_use messages', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [createToolUseMessage('search', { query: 'test' }, 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ content: string }> }; + expect(payload.chatHistory[0].content).toBe("I'll use the search tool."); + }); + + it('should skip tool_result messages with no text blocks', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [createToolResultMessage('Search results here', 'msg-1')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ content: string }> }; + expect(payload.chatHistory).toHaveLength(0); + }); + + it('should skip messages with empty content', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [ + createUserMessage('Valid message', 'msg-1'), + createMessageWithEmptyContent('user', 'msg-2'), + createAssistantMessage('Also valid', 'msg-3'), + ]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ id: string }> }; + expect(payload.chatHistory).toHaveLength(2); + expect(payload.chatHistory[0].id).toBe('msg-1'); + expect(payload.chatHistory[1].id).toBe('msg-3'); + }); + }); + + describe('ID extraction', () => { + it('should use message uuid when present', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [createUserMessage('Hello', 'specific-uuid-123')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ id: string }> }; + expect(payload.chatHistory[0].id).toBe('specific-uuid-123'); + }); + + it('should generate UUID when message uuid is empty', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = [createMessageWithoutUuid('user', 'Hello')]; + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ id: string }> }; + expect(payload.chatHistory[0].id).toBeTruthy(); + expect(payload.chatHistory[0].id.length).toBeGreaterThan(0); + }); + }); + + describe('mixed content handling', () => { + it('should handle various content types in a single batch', async () => { + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const messages = createMessagesWithVariousContentTypes(); + + await service.sendChatHistoryMessagesAsync(mockTurnContext, messages); + + const callArgs = mockedAxios.post.mock.calls[0]; + const payload = callArgs[1] as { chatHistory: Array<{ id: string; role: string; content: string }> }; + // msg-4 (tool_result with no text blocks) and msg-5 (empty content) are filtered out + expect(payload.chatHistory).toHaveLength(3); + expect(payload.chatHistory[0].content).toBe('Simple text message'); + expect(payload.chatHistory[1].content).toBe('Here is my response. With multiple blocks.'); + expect(payload.chatHistory[2].content).toBe("I'll use the search tool."); + }); + }); +}); diff --git a/tests/tooling-extensions-claude/sendChatHistoryAsync.test.ts b/tests/tooling-extensions-claude/sendChatHistoryAsync.test.ts new file mode 100644 index 00000000..b365bc79 --- /dev/null +++ b/tests/tooling-extensions-claude/sendChatHistoryAsync.test.ts @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// @ts-nocheck - jest.requireMock types don't propagate properly for ESM modules +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-claude/src/McpToolRegistrationService'; +import { + createMixedMessages, + createUserMessage, + MockSessionMessage, +} from './fixtures/mockClaudeTypes'; +import axios from 'axios'; + +// Mock axios +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +// Mock getSessionMessages from Claude SDK (ESM-only module, use requireMock) +jest.mock('@anthropic-ai/claude-agent-sdk', () => ({ + getSessionMessages: jest.fn(), +})); + +const { getSessionMessages: mockedGetSessionMessages } = jest.requireMock( + '@anthropic-ai/claude-agent-sdk' +) as { getSessionMessages: jest.Mock }; + +describe('McpToolRegistrationService - sendChatHistoryAsync', () => { + let service: McpToolRegistrationService; + let mockTurnContext: jest.Mocked; + + beforeEach(() => { + service = new McpToolRegistrationService(); + + mockTurnContext = { + activity: { + conversation: { id: 'conv-123' }, + id: 'msg-456', + text: 'Current user message', + channelId: 'test-channel', + }, + } as unknown as jest.Mocked; + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('input validation', () => { + it('should throw when turnContext is null', async () => { + await expect( + service.sendChatHistoryAsync(null as unknown as TurnContext, 'session-123') + ).rejects.toThrow('turnContext is required'); + }); + + it('should throw when turnContext is undefined', async () => { + await expect( + service.sendChatHistoryAsync(undefined as unknown as TurnContext, 'session-123') + ).rejects.toThrow('turnContext is required'); + }); + + it('should throw when sessionId is null', async () => { + await expect( + service.sendChatHistoryAsync(mockTurnContext, null as unknown as string) + ).rejects.toThrow('sessionId is required'); + }); + + it('should throw when sessionId is undefined', async () => { + await expect( + service.sendChatHistoryAsync(mockTurnContext, undefined as unknown as string) + ).rejects.toThrow('sessionId is required'); + }); + + it('should throw when sessionId is empty string', async () => { + await expect( + service.sendChatHistoryAsync(mockTurnContext, '') + ).rejects.toThrow('sessionId is required'); + }); + + it('should throw when sessionId is whitespace only', async () => { + await expect( + service.sendChatHistoryAsync(mockTurnContext, ' ') + ).rejects.toThrow('sessionId is required'); + }); + }); + + describe('successful scenarios', () => { + it('should retrieve and send session messages successfully', async () => { + const messages = createMixedMessages(); + mockedGetSessionMessages.mockResolvedValue(messages); + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const result = await service.sendChatHistoryAsync(mockTurnContext, 'session-123'); + + expect(result).toBeDefined(); + expect(result.succeeded).toBe(true); + expect(result.errors).toHaveLength(0); + expect(mockedGetSessionMessages).toHaveBeenCalledWith('session-123', {}); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('should pass limit to getSessionMessages options', async () => { + const messages = createMixedMessages(); + mockedGetSessionMessages.mockResolvedValue(messages); + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + await service.sendChatHistoryAsync(mockTurnContext, 'session-123', 2); + + expect(mockedGetSessionMessages).toHaveBeenCalledWith('session-123', { limit: 2 }); + }); + + it('should return success for empty session', async () => { + mockedGetSessionMessages.mockResolvedValue([]); + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const result = await service.sendChatHistoryAsync(mockTurnContext, 'session-123'); + + expect(result.succeeded).toBe(true); + 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 () => { + const messages = createMixedMessages(); + mockedGetSessionMessages.mockResolvedValue(messages); + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + const toolOptions = { orchestratorName: 'CustomBot' }; + + await service.sendChatHistoryAsync(mockTurnContext, 'session-123', 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('should return failed on HTTP error', async () => { + const messages = createMixedMessages(); + mockedGetSessionMessages.mockResolvedValue(messages); + const httpError = new Error('Network error'); + mockedAxios.post.mockRejectedValue(httpError); + mockedAxios.isAxiosError.mockReturnValue(false); + + const result = await service.sendChatHistoryAsync(mockTurnContext, 'session-123'); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Network error'); + }); + + it('should return failed when getSessionMessages throws', async () => { + const sdkError = new Error('Session not found'); + mockedGetSessionMessages.mockRejectedValue(sdkError); + + const result = await service.sendChatHistoryAsync(mockTurnContext, 'session-123'); + + expect(result.succeeded).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toBe('Session not found'); + }); + + it('should re-throw validation errors from nested call', async () => { + const messages = createMixedMessages(); + mockedGetSessionMessages.mockResolvedValue(messages); + mockTurnContext.activity.conversation = undefined as unknown as { id: string }; + + await expect( + service.sendChatHistoryAsync(mockTurnContext, 'session-123') + ).rejects.toThrow('Conversation ID is required'); + }); + }); + + describe('OperationResult behavior', () => { + it('should return OperationResult.success on successful request', async () => { + const messages = createMixedMessages(); + mockedGetSessionMessages.mockResolvedValue(messages); + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const result = await service.sendChatHistoryAsync(mockTurnContext, 'session-123'); + + expect(result).toBe(OperationResult.success); + expect(result.toString()).toBe('Succeeded'); + }); + + it('should return new failed OperationResult on error', async () => { + mockedGetSessionMessages.mockRejectedValue(new Error('Test error')); + + const result = await service.sendChatHistoryAsync(mockTurnContext, 'session-123'); + + 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 () => { + const messages = [ + createUserMessage('Test message 1', 'id-1'), + createUserMessage('Test message 2', 'id-2'), + ]; + mockedGetSessionMessages.mockResolvedValue(messages); + mockedAxios.post.mockResolvedValue({ status: 200, data: {} }); + + const result = await service.sendChatHistoryAsync(mockTurnContext, 'session-123'); + + 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) + ); + }); + }); +});