Skip to content
Open
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);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetToolRequestHeaders sets x-ms-usermessage whenever activity.text is truthy, but sanitizeTextForHeader() can return an empty string (e.g., whitespace-only input or strings that sanitize down to nothing). This will send an empty header value; instead, compute the sanitized value first and only add the header when the sanitized result is non-empty.

Suggested change
headers[Utility.HEADER_USER_MESSAGE] = Utility.sanitizeTextForHeader(userMessage);
const sanitizedUserMessage = Utility.sanitizeTextForHeader(userMessage);
if (sanitizedUserMessage) {
headers[Utility.HEADER_USER_MESSAGE] = sanitizedUserMessage;
}

Copilot uses AI. Check for mistakes.
}
Comment on lines +57 to +60
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says the header is added to MCP tools/call requests, but this implementation adds x-ms-usermessage for any request that uses Utility.GetToolRequestHeaders() (including tooling gateway discovery and chat history calls). If the intent is truly scoped to tool execution, consider gating this behind an explicit ToolOptions flag (or a dedicated header builder for tool calls) or update the PR description to reflect the broader behavior.

Copilot uses AI. Check for mistakes.

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;
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch clause in sanitizeTextForHeader() returns the original input, which can violate the method’s own contract/documentation (“ASCII-safe text suitable for HTTP header values”). If an error occurs, prefer returning a conservative sanitized fallback (e.g., strip/replace non-ASCII/control chars and collapse whitespace) or omit the header entirely, rather than returning unsanitized text.

Suggested change
return input;
// Conservative fallback: avoid returning unsanitized text if normalization fails.
return input
.replace(/[\u00A0\u202F]/g, ' ')
.replace(/[^\x20-\x7E]/g, ' ')
.replace(/\s+/g, ' ')
.trim();

Copilot uses AI. Check for mistakes.
}
}

/**
* 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
Loading