test(core): Cover Microsoft Teams channel and group chat conversations (no-changelog) (#38801)

This commit is contained in:
Bernhard Wittmann 2026-09-28 07:39:22 +00:00 committed by GitHub
parent eb684d2256
commit 4f64f8c4a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 284 additions and 5 deletions

View File

@ -91,6 +91,57 @@ const mentionEntity = {
mentioned: { id: `28:${TEAMS_APP_ID}`, name: 'n8n Agent' },
};
// A channel conversation id carries the id of the thread's root message, so
// every thread in a channel is its own conversation — and its own session.
// `channelData.team.aadGroupId` + `channelData.channel.id` are what mark the
// activity as a channel one.
const TEAMS_CHANNEL_ID = '19:channel_engineering@thread.tacv2';
const TEAMS_TEAM_ID = '19:team_platform@thread.tacv2';
const TEAMS_TEAM_AAD_GROUP_ID = '77777777-6666-5555-4444-333333333333';
export const TEAMS_CHANNEL_CONVERSATION_ID = `${TEAMS_CHANNEL_ID};messageid=1700000000001`;
export const TEAMS_CHANNEL_SECOND_THREAD_CONVERSATION_ID = `${TEAMS_CHANNEL_ID};messageid=1700000000002`;
function channelActivity(overrides: Partial<TeamsActivityFixture> = {}): TeamsActivityFixture {
return baseActivity({
conversation: {
id: TEAMS_CHANNEL_CONVERSATION_ID,
conversationType: 'channel',
tenantId: TEAMS_TENANT_ID,
},
channelData: {
tenant: { id: TEAMS_TENANT_ID },
team: { id: TEAMS_TEAM_ID, aadGroupId: TEAMS_TEAM_AAD_GROUP_ID },
channel: { id: TEAMS_CHANNEL_ID },
},
...overrides,
});
}
export const channelMention: TeamsActivityFixture = channelActivity({
id: 'activity-channel-1',
text: '<at>n8n Agent</at> hello agent',
entities: [mentionEntity],
});
/** No mention: it only reaches the agent because the mention subscribed the thread. */
export const channelFollowUp: TeamsActivityFixture = channelActivity({
id: 'activity-channel-2',
text: 'follow up',
});
export const channelSecondThreadMention: TeamsActivityFixture = channelActivity({
id: 'activity-channel-3',
text: '<at>n8n Agent</at> a different thread',
entities: [mentionEntity],
conversation: {
id: TEAMS_CHANNEL_SECOND_THREAD_CONVERSATION_ID,
conversationType: 'channel',
tenantId: TEAMS_TENANT_ID,
},
});
// A group chat conversation id carries no message id, so the whole chat is one
// conversation and one session.
@ -113,3 +164,42 @@ export const groupChatMention: TeamsActivityFixture = groupChatActivity({
text: '<at>n8n Agent</at> hello agent',
entities: [mentionEntity],
});
export const groupChatFollowUp: TeamsActivityFixture = groupChatActivity({
id: 'activity-group-2',
text: 'follow up',
});
/**
* A group chat whose conversation id does not start with `19:`.
*
* The adapter reads the conversation id alone as "not a direct message" only
* when it starts with `19:`. This shape disagrees with that rule, so the
* explicit `conversationType` must carry the classification instead. If it does
* not, the chat is read as a direct message, and the adapter then holds the
* webhook response open for the whole agent run.
*/
export const TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID = 'a:group_chat_legacy';
export const legacyGroupChatMention: TeamsActivityFixture = groupChatActivity({
id: 'activity-legacy-group-1',
text: '<at>n8n Agent</at> hello agent',
entities: [mentionEntity],
conversation: {
id: TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID,
conversationType: 'groupChat',
tenantId: TEAMS_TENANT_ID,
isGroup: true,
},
});
export const legacyGroupChatFollowUp: TeamsActivityFixture = groupChatActivity({
id: 'activity-legacy-group-2',
text: 'follow up',
conversation: {
id: TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID,
conversationType: 'groupChat',
tenantId: TEAMS_TENANT_ID,
isGroup: true,
},
});

View File

@ -3,12 +3,20 @@ import { isRecord } from '@n8n/utils/is-record';
import { createTeamsReplayContext } from '../../../__tests__/helpers/teams/replay-test-context';
import {
cardAction,
channelFollowUp,
channelMention,
channelSecondThreadMention,
dmFollowUp,
dmMessage,
groupChatFollowUp,
groupChatMention,
legacyGroupChatFollowUp,
legacyGroupChatMention,
selfMessage,
TEAMS_CHANNEL_CONVERSATION_ID,
TEAMS_DM_CONVERSATION_ID,
TEAMS_GROUP_CHAT_CONVERSATION_ID,
TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID,
TEAMS_USER_ID,
} from '../../../__tests__/helpers/teams/synthetic-fixtures';
@ -76,6 +84,7 @@ describe('Microsoft Teams integration scenarios', () => {
try {
await ctx.sendWebhook(dmMessage);
const firstThreadId = ctx.latestThreadId();
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
await ctx.sendWebhook(dmFollowUp);
@ -338,4 +347,173 @@ describe('Microsoft Teams integration scenarios', () => {
await ctx.shutdown();
}
});
it('routes a team channel mention to the agent and replies in the same thread', async () => {
const ctx = await createTeamsReplayContext();
try {
await expect(ctx.sendWebhook(channelMention)).resolves.toMatchObject({ status: 200 });
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledWith(
expect.objectContaining({
message: '@n8n Agent hello agent',
author: { id: TEAMS_USER_ID, name: 'Alice' },
integrationType: 'teams',
}),
);
expect(ctx.lastPost()?.body).toMatchObject({
type: 'message',
text: 'Got it',
conversation: { id: TEAMS_CHANNEL_CONVERSATION_ID },
});
} finally {
await ctx.shutdown();
}
});
it('routes an unmentioned follow-up in a subscribed channel thread to the same session', async () => {
const ctx = await createTeamsReplayContext();
try {
await ctx.sendWebhook(channelMention);
const firstThreadId = ctx.latestThreadId();
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
await ctx.sendWebhook(channelFollowUp);
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(2);
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenLastCalledWith(
expect.objectContaining({ message: 'follow up' }),
);
expect(ctx.latestThreadId()).toBe(firstThreadId);
} finally {
await ctx.shutdown();
}
});
it('keeps a separate session for each thread in the same channel', async () => {
const ctx = await createTeamsReplayContext();
try {
await ctx.sendWebhook(channelMention);
const firstThreadId = ctx.latestThreadId();
await ctx.sendWebhook(channelSecondThreadMention);
expect(ctx.latestThreadId()).not.toBe(firstThreadId);
} finally {
await ctx.shutdown();
}
});
it('keeps one session for a whole group chat', async () => {
const ctx = await createTeamsReplayContext();
try {
await ctx.sendWebhook(groupChatMention);
const firstThreadId = ctx.latestThreadId();
// One run for one mention: Teams retries an activity that the adapter
// leaves unanswered, and a retry would show up as a second run here.
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
expect(ctx.lastPost()?.body).toMatchObject({
conversation: { id: TEAMS_GROUP_CHAT_CONVERSATION_ID },
});
await ctx.sendWebhook(groupChatFollowUp);
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(2);
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenLastCalledWith(
expect.objectContaining({ message: 'follow up' }),
);
expect(ctx.latestThreadId()).toBe(firstThreadId);
} finally {
await ctx.shutdown();
}
});
it('reads a group chat whose id does not start with 19: as a group chat', async () => {
const { decodeThreadId } = await import('@chat-adapter/teams');
const ctx = await createTeamsReplayContext();
try {
await ctx.sendWebhook(legacyGroupChatMention);
const firstThreadId = ctx.latestThreadId();
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(1);
// Without the explicit conversation type the adapter would read this id
// as a direct message, and then hold the webhook response open for the
// whole agent run. The thread id carries the agent prefix, so decode
// only the platform part.
const platformThreadId = (firstThreadId ?? '').slice((firstThreadId ?? '').indexOf('teams:'));
expect(decodeThreadId(platformThreadId)).toMatchObject({
conversationId: TEAMS_LEGACY_GROUP_CHAT_CONVERSATION_ID,
conversationType: 'groupChat',
});
await ctx.sendWebhook(legacyGroupChatFollowUp);
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenCalledTimes(2);
expect(ctx.agentExecutor.executeForChatPublished).toHaveBeenLastCalledWith(
expect.objectContaining({ message: 'follow up' }),
);
expect(ctx.latestThreadId()).toBe(firstThreadId);
} finally {
await ctx.shutdown();
}
});
it('resumes a suspended approval from a card click in a channel thread', async () => {
const ctx = await createTeamsReplayContext({
stream: [
{
type: 'tool-call-suspended',
runId: 'run-channel-card-1',
toolCallId: 'tool-channel-card-1',
toolName: 'approval',
suspendPayload: {
type: 'approval',
toolName: 'send_teams_message',
displayName: 'Send Teams message',
args: { text: 'Continue?' },
},
resumeSchema: {
type: 'object',
properties: { approved: { type: 'boolean' } },
required: ['approved'],
},
},
{ type: 'finish', finishReason: 'stop' },
],
});
try {
await ctx.sendWebhook(channelMention);
const cardMessageId = ctx.lastPostedMessageId();
if (!cardMessageId) throw new Error('Expected the approval card to have been posted');
const approve = cardActions(ctx.lastPost()?.body)[0];
if (!approve) throw new Error('Expected an Adaptive Card action on the approval card');
ctx.nextStream([
{ type: 'text-delta', id: 'resume-text', delta: 'Card handled' },
{ type: 'finish', finishReason: 'stop' },
]);
await ctx.sendWebhook(
cardAction(approve.data as Record<string, unknown>, cardMessageId, channelMention),
);
expect(ctx.agentExecutor.resumeForChat).toHaveBeenCalledWith(
expect.objectContaining({
runId: 'run-channel-card-1',
toolCallId: 'tool-channel-card-1',
integrationType: 'teams',
resumeData: { approved: true },
}),
);
// The resume payload carries no thread id, so the outbound reply is what
// proves the resumed run stayed in the channel thread.
expect(ctx.lastPost()?.body).toMatchObject({
conversation: { id: TEAMS_CHANNEL_CONVERSATION_ID },
});
// Removed rather than relabelled, so a stale button cannot be clicked.
expect(ctx.lastDelete()?.body.uri).toContain(cardMessageId);
} finally {
await ctx.shutdown();
}
});
});

View File

@ -39,9 +39,18 @@ const TENANT_ID_DOMAIN =
* endpoint is configured once in Azure Bot Service, so there is no API call to
* register or release it and no `onAfterConnect`/`onBeforeDisconnect` hook.
*
* This first slice targets 1:1 direct messages. Group chats and channels are not
* blocked, but they are untested: without RSC permissions Teams only delivers an
* @-mention there, so nothing arrives ambiently.
* Direct messages, team channels and group chats are all supported. Outside a
* DM the bot must be @-mentioned once; the mention subscribes the conversation,
* so later messages reach the agent without a mention. In a channel that
* subscription covers the one thread, because the thread id carries the root
* message id. In a group chat it covers the whole chat.
*
* Whether a later message arrives at all is a setup choice. The manifest
* carries `ChannelMessage.Read.Group` and `ChatMessage.Read.Chat` only when
* `readAllChannelMessages` and `readAllGroupMessages` are on. Teams grants them
* when the app is added to a team or a chat, and they also make Teams deliver
* every message instead of mentions alone. Without them Teams delivers only the
* mention, and the subscription stays inert.
*/
@Service()
export class TeamsIntegration extends AgentChatIntegration {
@ -58,13 +67,15 @@ export class TeamsIntegration extends AgentChatIntegration {
readonly builderGuidance = {
capabilities: [
'Receive Microsoft Teams direct messages as agent triggers.',
'Respond in the same Microsoft Teams conversation.',
'Receive Microsoft Teams direct messages, team channel messages and group chat messages as agent triggers.',
'Respond in the same Microsoft Teams conversation, and in the same channel thread.',
'Stay in the conversation after an @-mention, so later messages need no mention.',
'Render Adaptive Cards with buttons.',
],
useIntegrationWhen: [
'The agent should be chatted with from Microsoft Teams or act as a Teams bot.',
'The agent needs to reply to Teams users in the same conversation context.',
'The agent should take part in a Microsoft Teams channel or group chat.',
],
useNodeToolWhen: [
'Microsoft Teams is only a backend API step and the agent does not need to be connected as a Teams chat surface.',