diff --git a/packages/agents-a365-tooling/src/Utility.ts b/packages/agents-a365-tooling/src/Utility.ts index 32b0084b..f501a721 100644 --- a/packages/agents-a365-tooling/src/Utility.ts +++ b/packages/agents-a365-tooling/src/Utility.ts @@ -14,6 +14,8 @@ export class Utility { public static readonly HEADER_USER_AGENT = 'User-Agent'; /** Header name for sending the agent identifier to MCP platform for logging/analytics. */ public static readonly HEADER_AGENT_ID = 'x-ms-agentid'; + /** Header name for sending the user's original message to MCP servers during tool execution. */ + public static readonly HEADER_USER_MESSAGE = 'x-ms-usermessage'; /** * Compose standard headers for MCP tooling requests. @@ -52,6 +54,11 @@ export class Utility { headers[Utility.HEADER_SUBCHANNEL_ID] = subChannelId; } + const userMessage = turnContext?.activity?.text as string | undefined; + if (userMessage) { + headers[Utility.HEADER_USER_MESSAGE] = Utility.sanitizeTextForHeader(userMessage); + } + if (options?.orchestratorName) { headers[Utility.HEADER_USER_AGENT] = RuntimeUtility.GetUserAgentHeader(options.orchestratorName); } @@ -59,6 +66,40 @@ export class Utility { return headers; } + /** + * Sanitizes text for use in an HTTP header value by normalizing to ASCII-safe characters. + * Matches the .NET implementation in HttpContextHeadersHandler.SanitizeTextForHeader(). + * + * @param input - The text to sanitize. + * @returns ASCII-safe text suitable for HTTP header values. + */ + private static sanitizeTextForHeader(input: string): string { + try { + // Step 1: Replace non-breaking spaces with regular spaces, then trim + let result = input.replace(/[\u00A0\u202F]/g, ' ').trim(); + + // Step 2: Unicode normalize (NFD) and remove combining marks (diacritics) + result = result.normalize('NFD').replace(/\p{M}/gu, ''); + + // Step 3: Convert smart punctuation to ASCII equivalents + result = result + .replace(/[\u2018\u2019]/g, "'") // Smart single quotes → ' + .replace(/[\u201C\u201D]/g, '"') // Smart double quotes → " + .replace(/[\u2013\u2014]/g, '-') // En/em dashes → - + .replace(/\u2026/g, '...'); // Ellipsis → ... + + // Step 4: Keep only printable ASCII (32-126), replace others with space + result = result.replace(/[^\x20-\x7E]/g, ' '); + + // Step 5: Collapse whitespace and trim + result = result.replace(/\s+/g, ' ').trim(); + + return result; + } catch { + return input; + } + } + /** * Resolves the best available agent identifier for the x-ms-agentid header. * Priority: TurnContext.agenticAppBlueprintId > token claims (xms_par_app_azp > appid > azp) > application name diff --git a/tests/tooling/utility.test.ts b/tests/tooling/utility.test.ts index 39bd416a..f952b4d9 100644 --- a/tests/tooling/utility.test.ts +++ b/tests/tooling/utility.test.ts @@ -240,6 +240,117 @@ describe('Utility - GetToolRequestHeaders x-ms-agentid', () => { }); }); +describe('Utility - GetToolRequestHeaders x-ms-usermessage', () => { + it('should add x-ms-usermessage header when turnContext.activity.text is present', () => { + const mockContext = { + activity: { + text: 'What is the weather today?', + }, + } as unknown as TurnContext; + + const headers = Utility.GetToolRequestHeaders(undefined, mockContext); + expect(headers['x-ms-usermessage']).toBe('What is the weather today?'); + }); + + it('should omit x-ms-usermessage header when activity.text is missing', () => { + const mockContext = { + activity: {}, + } as unknown as TurnContext; + + const headers = Utility.GetToolRequestHeaders(undefined, mockContext); + expect(headers['x-ms-usermessage']).toBeUndefined(); + }); + + it('should omit x-ms-usermessage header when activity.text is empty string', () => { + const mockContext = { + activity: { + text: '', + }, + } as unknown as TurnContext; + + const headers = Utility.GetToolRequestHeaders(undefined, mockContext); + expect(headers['x-ms-usermessage']).toBeUndefined(); + }); + + it('should omit x-ms-usermessage header when turnContext is undefined', () => { + const headers = Utility.GetToolRequestHeaders(undefined, undefined); + expect(headers['x-ms-usermessage']).toBeUndefined(); + }); + + it('should sanitize non-breaking spaces to regular spaces', () => { + const mockContext = { + activity: { + text: 'hello\u00A0world\u202Ftest', + }, + } as unknown as TurnContext; + + const headers = Utility.GetToolRequestHeaders(undefined, mockContext); + expect(headers['x-ms-usermessage']).toBe('hello world test'); + }); + + it('should strip diacritics from characters', () => { + const mockContext = { + activity: { + text: 'café résumé señor', + }, + } as unknown as TurnContext; + + const headers = Utility.GetToolRequestHeaders(undefined, mockContext); + expect(headers['x-ms-usermessage']).toBe('cafe resume senor'); + }); + + it('should convert smart quotes and dashes to ASCII equivalents', () => { + const mockContext = { + activity: { + text: '\u201CHello\u201D \u2018world\u2019 foo\u2013bar baz\u2014qux and\u2026more', + }, + } as unknown as TurnContext; + + const headers = Utility.GetToolRequestHeaders(undefined, mockContext); + expect(headers['x-ms-usermessage']).toBe('"Hello" \'world\' foo-bar baz-qux and...more'); + }); + + it('should replace non-ASCII characters with spaces', () => { + const mockContext = { + activity: { + text: 'hello \u4E16\u754C world', + }, + } as unknown as TurnContext; + + const headers = Utility.GetToolRequestHeaders(undefined, mockContext); + expect(headers['x-ms-usermessage']).toBe('hello world'); + }); + + it('should collapse multiple whitespace into single space', () => { + const mockContext = { + activity: { + text: 'hello world test', + }, + } as unknown as TurnContext; + + const headers = Utility.GetToolRequestHeaders(undefined, mockContext); + expect(headers['x-ms-usermessage']).toBe('hello world test'); + }); + + it('should coexist with all other headers', () => { + const mockContext = { + activity: { + channelId: 'msteams', + channelIdSubChannel: 'personal', + text: 'Find my files', + }, + } as unknown as TurnContext; + + const headers = Utility.GetToolRequestHeaders('my-token', mockContext, { orchestratorName: 'Claude' }); + + expect(headers['Authorization']).toBe('Bearer my-token'); + expect(headers['x-ms-channel-id']).toBe('msteams'); + expect(headers['x-ms-subchannel-id']).toBe('personal'); + expect(headers['User-Agent']).toContain('Claude'); + expect(headers['x-ms-usermessage']).toBe('Find my files'); + }); +}); + describe('Utility - GetChatHistoryEndpoint', () => { const originalEnv = process.env;