diff --git a/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts index 047b5139ed2..47a6e4530c3 100644 --- a/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts +++ b/packages/cli/src/modules/agents/integrations/__tests__/helpers/teams/synthetic-fixtures.ts @@ -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 { + 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: 'n8n Agent 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: 'n8n Agent 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: 'n8n Agent 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: 'n8n Agent 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, + }, +}); diff --git a/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts b/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts index ae2a60a615d..81eed5f8c7a 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/__tests__/teams/synthetic-integration.test.ts @@ -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, 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(); + } + }); }); diff --git a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-integration.ts b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-integration.ts index b2652a823c8..a30ac09b3f0 100644 --- a/packages/cli/src/modules/agents/integrations/platforms/teams/teams-integration.ts +++ b/packages/cli/src/modules/agents/integrations/platforms/teams/teams-integration.ts @@ -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.',