Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/agents-a365-tooling/src/Utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -52,13 +54,52 @@ 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);
}

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
Expand Down
111 changes: 111 additions & 0 deletions tests/tooling/utility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down