diff --git a/doc/connections/SLACK-TASK-TOOLS.md b/doc/connections/SLACK-TASK-TOOLS.md index 02b54daf82c..892426ac34c 100644 --- a/doc/connections/SLACK-TASK-TOOLS.md +++ b/doc/connections/SLACK-TASK-TOOLS.md @@ -198,16 +198,20 @@ people's bot DMs remain inaccessible. Scheduling uses ordinary Paperclip routine not a Slack-specific timer. Routine results are sent explicitly through the tool; ordinary task finals are not automatically broadcast to Slack. -## Require an at-mention for inbound messages +## Require an at-mention for inbound channel messages Slack app settings include **Require at-mention** directly below **Allow direct messages**. It defaults to on for new Slack connections. Existing connections -keep their saved setting and thread and DM behavior. -When enabled, each inbound message must explicitly @mention the connected bot, -including replies in existing channel threads and direct messages. DMs must also -be enabled with **Allow direct messages**. Unmentioned messages do not create -or continue a task. Queued messages are checked again before starting agent work. -If a queued run combines several messages, an unmentioned input cancels that -queued run when this setting is enabled. Send a new mention to start it again. +keep their saved setting; the setting applies only to channels. +When enabled, each inbound channel message must explicitly @mention the connected +bot, including replies in existing channel threads. Direct messages never require +an @mention, but must be enabled with **Allow direct messages** and pass the usual +sender authorization checks. Top-level DMs remain a linear conversation with +top-level replies; use a private channel for conversations organized into threads. +Unmentioned channel messages do not create or continue a task. Queued messages +are checked again before starting agent work. If a queued run combines several +channel messages, an unmentioned input cancels that queued run when this setting +is enabled. Send a new mention to start it again. Unmentioned DM inputs do not +trigger this cancellation. This setting does not cancel work that has already started or revoke its Slack tool access. diff --git a/maps/agent-access-map/chat-channels.md b/maps/agent-access-map/chat-channels.md index 58fe31ce533..cc852db37d8 100644 --- a/maps/agent-access-map/chat-channels.md +++ b/maps/agent-access-map/chat-channels.md @@ -83,6 +83,14 @@ remaining operations before qualifying the whole capability. Gaps: Normal agent replies work through server delivery. Explicit endpoint publication APIs are board management paths. +Slack's **Require at-mention** policy applies to channel messages and channel +thread replies, including queued work. DMs are exempt from mention checks; +endpoint availability, **Allow direct messages**, and sender authorization still +apply. Top-level DM replies retain their linear conversation destination. Source: +[admission](../../server/src/services/chat-channels.ts) and +[queued run policy](../../server/src/services/durable-chat-wakeup.ts). Live Slack +verification of this exemption remains separate from provider-fixture coverage. + New conversations resolve typed project/workspace defaults from their endpoint and destination under the task-creation transaction. Defaults do not authorize private sources: admission checks the linked user's authority and assigned agent. diff --git a/maps/feature-map/chat-channels.md b/maps/feature-map/chat-channels.md index a5437be5639..edc4337e531 100644 --- a/maps/feature-map/chat-channels.md +++ b/maps/feature-map/chat-channels.md @@ -38,6 +38,12 @@ Automated: [channel integration](../../server/src/__tests__/chat-channels.integr Manual: For each supported provider being changed, send a unique message, verify the intended agent/company receives it, and inspect the actual external reply in the original thread. Repeat with an unlinked/unauthorized sender, duplicate inbound delivery, and one supported attachment. Record provider-specific results separately. +For Slack, enable **Require at-mention** and **Allow direct messages**. Send a +plain DM and follow up without a mention: both should reach the agent, with +top-level replies to top-level DMs. Channel messages and thread replies still +require a mention when that setting is enabled. Disable direct messages and +confirm a plain DM is rejected. Use a private channel for threaded conversations. + ### `endpoint-management` Automated: [channel integration](../../server/src/__tests__/chat-channels.integration.test.ts) covers shared endpoint/delivery behavior; live credential repair and provider ownership remain manual checks. diff --git a/maps/storybook-map/REVIEW.md b/maps/storybook-map/REVIEW.md index 68802f3ab20..90aa33a7744 100644 --- a/maps/storybook-map/REVIEW.md +++ b/maps/storybook-map/REVIEW.md @@ -331,3 +331,23 @@ All 223 authored suites and 2,210 authored story IDs remain. Generated feature pages still cover 35 areas and 171 capabilities. Catalog/generator tests (16), catalog validation, map checks and token gates pass. This is source reconciliation, not a new visual or live production acceptance claim. + +## Slack DM mention exemption — 2026-10-13 + +Scoped source review: Slack DMs no longer require an at-mention, while channel +messages and channel thread replies retain the saved mention policy. Top-level +DM replies remain linear. The production `ChatEndpointDetail` settings copy and +the feature-map manual recipe now state this distinction. + +The Chat management story mounts that production settings page and merges +endpoint PATCH values in its local fixture. GitHub bot management and Photon +also reference the page, but do not render its Slack-only toggle. No story UI, +fixture behavior, or story identity changed, and no browser proof is claimed. + +After rebasing onto master `60bc0d5fee`, the upstream catalog reconciliation +supplies the reviewed baseline for the previously stale entries. Refreshed the +fourteen entries affected by this change: all reference the updated chat recipe, +and three also reference the Slack-only settings copy. Other source bytes and +all classifications, fixture boundaries, story IDs and sidebar titles remain +unchanged from that baseline. Catalog checks validate these references and +fingerprints, not live Slack behavior. diff --git a/maps/storybook-map/catalog.json b/maps/storybook-map/catalog.json index a609ad0a97c..55388ec6fea 100644 --- a/maps/storybook-map/catalog.json +++ b/maps/storybook-map/catalog.json @@ -637,7 +637,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "maps/feature-map/connection-setup.md", @@ -646,7 +646,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/agentmail-onboarding.stories.tsx" ], - "fingerprint": "e25771274378d2ff2f2447b2841ff20b91319dba4e50f697c5b2c6cc74698b4b", + "fingerprint": "22c79d5f6a714efbaeaaa0023284707b696aa73cddbb66067d8c5a3dde116859", "stableId": "connections-agentmail-setup", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail setup" }, @@ -660,13 +660,13 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation: EmailEndpointSettings remains the mounted inbox subsection. The surrounding ChatEndpointDetail connection-default editor and its save/inheritance behavior are not represented here.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/EmailEndpointSetup.tsx", "ui/storybook/stories/chat-channels/agentmail-settings.stories.tsx" ], - "fingerprint": "a57256936459ba14d7ad8e0a73ed494c0f269c294240a67c0d27af555e0c736d", + "fingerprint": "2fb050825b457f876fa18d08cd101093d19187f47d7cd8b5a51c871d28d7553e", "stableId": "connections-agentmail-settings", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail settings" }, @@ -690,7 +690,7 @@ "ui/src/components/task-chat/presentation-mode.tsx", "ui/storybook/stories/chat-channels/agentmail.stories.tsx" ], - "fingerprint": "55ba1a896505c14b5f7531c5dcc2d870f981344a23fe806d25a90350a61a7f33", + "fingerprint": "5f4cd880d1c6baa0c88dc65bf5919cf451ea3c4bdd8b479ee2e3d2906ce1bd1c", "stableId": "connections-agentmail-tasks", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/AgentMail tasks" }, @@ -1391,13 +1391,13 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/Browse.tsx", "ui/storybook/stories/chat-channels/chat-connection-actions.stories.tsx" ], - "fingerprint": "b5c4a820f42d7b3d5e665161b95056b80b5591f6100e6847488eea49b876d36d", + "fingerprint": "bd604a05920f1015bac52ede82ba83726bf4d79126b7f8f8c8afcd5aa4aca630", "stableId": "connections-chat-connection-actions", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat connection actions" }, @@ -1459,7 +1459,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/chat-management/chat-management.stories.tsx" ], - "fingerprint": "2fab3391785dd764f6c17819a6ac9f2a2388ffc4af0cbc2e4590580e3de21a09", + "fingerprint": "1b095845d727d6703d3cfb3b27120927c8ac8b8fd4f2950770c7c5f4aed44d3b", "stableId": "connections-chat-management", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Chat management" }, @@ -2551,7 +2551,7 @@ "ui/storybook/support/github-bot-management/fixture-api.tsx", "ui/storybook/support/github-bot-management/fixtures.ts" ], - "fingerprint": "8dd50a9e507f68818ee119efaa9c90ea01ecf0ec82721abcf0e6f7d489de6cbb", + "fingerprint": "bc53bda552a9a7f4835d2c78ec0dc66492b5804db227b9adce191f4d6dc27c53", "stableId": "connectors-github-bot-management", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connectors/GitHub bot management" }, @@ -2566,7 +2566,7 @@ "implementation": "prototype", "boundary": "fixtures", "notes": "Historical wizard retained for original setup/access/review design; current GitHub setup is ui/src/pages/apps/chat/GitHubChatSetup.tsx. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/components/GitHubAgentTrustWarning.tsx", @@ -2578,7 +2578,7 @@ "ui/storybook/stories/chat-channels/github-chat.stories.tsx" ], "replacement": "ui/storybook/stories/chat-channels/github-connections.stories.tsx", - "fingerprint": "1b98e4a1e023630f9d27105f47ee8e04c7e49e31f1eb77430e55cf0518bca033", + "fingerprint": "efb2ea9851242dfd79874e3a3c9bc46b17b7728e2b10f6b2127a2437638f0499", "stableId": "historical-github-chat-prototypes", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Historical/GitHub chat prototypes" }, @@ -2593,7 +2593,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "maps/feature-map/connection-setup.md", @@ -2604,7 +2604,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/github-connections.stories.tsx" ], - "fingerprint": "1db66005c3710754f307003ce6907073e8eca0319074c323d57992c0b26d8b23", + "fingerprint": "53ef79816b0825618e15a4bbb501df31efea9a1fb62ccdf066a81e26c71a838c", "stableId": "connections-github-and-code-review-bot", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/GitHub and Code Review Bot" }, @@ -2756,7 +2756,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/imessage-photon.stories.tsx" ], - "fingerprint": "002c59e80ccd20170b8ed432e8343395414df94b724c858006447735b0afc680", + "fingerprint": "c8ba561728c0c418b6b2083cf72cb09712b96415a66285267ec46abc9d7c26bc", "stableId": "connections-imessage-photon", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/iMessage Photon" }, @@ -4832,7 +4832,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "maps/feature-map/connection-setup.md", @@ -4842,7 +4842,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/slack-automatic-setup.stories.tsx" ], - "fingerprint": "df0600372c76d7ff7eb15ccc14a7a5bf7f0b5f5d64a366013686b6aaf888ee00", + "fingerprint": "6d8c13f5ebed08a43b9f146f49a2506c9c5e1ac9305121b94e9cd5bb9c060476", "stableId": "connections-slack-automatic-setup", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Automatic setup" }, @@ -4856,7 +4856,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx", @@ -4864,7 +4864,7 @@ "ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx", "ui/storybook/stories/chat-channels/slack-avatar-settings.stories.tsx" ], - "fingerprint": "725869cfc3280b8384899e0f692bcf17663368f389c38fe1a16ec34c27faf20d", + "fingerprint": "c6688ca3e157bbbd52554dff87cbe1842db9f993b78d07e810492372835d2f98", "stableId": "connections-slack-avatar-in-settings", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Avatar in Settings" }, @@ -4878,7 +4878,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/ChatCommunicationInstructions.tsx", @@ -4886,7 +4886,7 @@ "ui/storybook/prototypes/slack-avatar/SlackAvatarSettingsPreview.tsx", "ui/storybook/stories/chat-channels/slack-communication-settings.stories.tsx" ], - "fingerprint": "f0d8df504be6e6e5a1ea09b868ee8a75e35d76041a792f48955dc1e834297cb6", + "fingerprint": "176a29291c7a696273dbbe50622c0dc845015adc119c9ae162dc1c957943a177", "stableId": "connections-slack-communication-in-settings", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Communication in Settings" }, @@ -4901,7 +4901,7 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "maps/feature-map/connection-setup.md", @@ -4911,7 +4911,7 @@ "ui/storybook/fixtures/paperclipData.ts", "ui/storybook/stories/chat-channels/slack-managed-setup.stories.tsx" ], - "fingerprint": "24e3f986568fad11d604fddc48db890e2c75091b8b7c0ff916c6d609dd4c033d", + "fingerprint": "59d4e5ebb276a491e22d6461fe9c9bab36778097d15ab2203d6cd95a4151e371", "stableId": "connections-slack-managed-setup", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Managed setup" }, @@ -4925,13 +4925,13 @@ "implementation": "production", "boundary": "fixtures", "notes": "Shipped subjects identified through imports and local wrappers; full state, fixture-contract and route parity remain unreviewed. Task-workspace reconciliation reviewed the added connection/destination-default recipe; these unchanged setup, identity or conversation examples do not establish default selection or new-task inheritance.", - "reviewedAt": "2026-10-12", + "reviewedAt": "2026-10-13", "sources": [ "maps/feature-map/chat-channels.md", "ui/src/pages/apps/chat/SlackToolSettings.tsx", "ui/storybook/stories/chat-channels/slack-tools.stories.tsx" ], - "fingerprint": "9eaae8672c83f1be3d6bdea09e6a77fd62f2828ab410addfe82f9c08c3c34a4a", + "fingerprint": "20189b2032c2aecf3242e8d086fed9ca0b02d7adaa80aff9a69b60f1b23bce18", "stableId": "connections-slack-task-tools", "sidebarTitle": "Apps, channels, and extensions/External chat and email/Connections/Slack/Task tools" }, diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 142707f41d7..0b5fe88772b 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -8020,7 +8020,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await service.shutdown(); }); - it.each([false, true])("requires at-mentions by default for new Slack connections (DM=%s)", async (isDM) => { + it.each([false, true])("requires at-mentions by default only in Slack channels (DM=%s)", async (isDM) => { const fixture = await seedCompany(); const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, { requireAtMention: true }); // The returned endpoint is the original creation result, before fixture updates. @@ -8030,12 +8030,12 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const send = (id: string, mentioned: boolean) => deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, message: makeMessage({ id, text: mentioned ? "@maya start" : "Start", mentioned }), trigger: isDM ? "direct_message" : mentioned ? "mention" : "subscribed_message" }); await send("7500.1", false); - expect(wakeup).not.toHaveBeenCalled(); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 1 : 0); await send("7500.2", true); - expect(wakeup).toHaveBeenCalledTimes(1); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 2 : 1); await service.update(endpoint.id, { requireAtMention: false }); await send("7500.3", false); - expect(wakeup).toHaveBeenCalledTimes(2); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 3 : 2); const other = await service.create(fixture.companyId, { provider: "github", assignedAgentId: fixture.assignedAgentId }); expect(other.requireAtMention).toBe(false); expect((await service.get(endpoint.id)).requireAtMention).toBe(false); @@ -8063,11 +8063,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await expect(resolveSlackTaskAuthority(db, binding)).resolves.toMatchObject({ slackUserId: "UBOARD" }); }); - it.each([false, true])("requires Slack at-mentions for every message when enabled (DM=%s)", async (isDM) => { + it.each([false, true])("applies Slack mention settings to channel replies while exempting DMs (DM=%s)", async (isDM) => { const fixture = await seedCompany(); const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture); expect((await service.get(endpoint.id)).requireAtMention).toBe(false); - const thread = makeThread({ channelId: isDM ? "D-MENTION" : "C-MENTION", id: `slack:${isDM ? "D-MENTION" : "C-MENTION"}:7100.1`, isDM }); + const thread = makeThread({ channelId: isDM ? "D-MENTION" : "C-MENTION", id: isDM ? "slack:D-MENTION:" : "slack:C-MENTION:7100.1", isDM }); const send = (id: string, mentioned: boolean, trigger: ChatSdkMessageTrigger = isDM ? "direct_message" : "subscribed_message") => deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, message: makeMessage({ id, text: mentioned ? "@maya continue" : "Continue", mentioned }), trigger }); @@ -8076,47 +8076,53 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await send("7100.2", false); expect(wakeup).toHaveBeenCalledTimes(2); expect((await service.update(endpoint.id, { requireAtMention: true })).requireAtMention).toBe(true); - await send("7100.3", false); - expect(wakeup).toHaveBeenCalledTimes(2); + await send("7100.3", false, "subscribed_message"); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 3 : 2); const [filtered] = await db.select().from(chatDeliveries).where(and( eq(chatDeliveries.endpointId, endpoint.id), eq(chatDeliveries.state, "filtered"))); - expect(filtered.redactedError).toBe("Message did not @mention the bot"); - expect(filtered.normalizedEvent).toMatchObject({ filtering: { contentRetained: false } }); - expect(filtered.normalizedEvent).not.toHaveProperty("message.text"); + if (isDM) { + expect(filtered).toBeUndefined(); + } else { + expect(filtered.redactedError).toBe("Message did not @mention the bot"); + expect(filtered.normalizedEvent).toMatchObject({ filtering: { contentRetained: false } }); + expect(filtered.normalizedEvent).not.toHaveProperty("message.text"); + } // Both Slack callback kinds can carry an explicit mention. Retries cannot // produce a second wakeup for the same provider message. await send("7100.4", true); await send("7100.4", true, "mention"); - expect(wakeup).toHaveBeenCalledTimes(3); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 4 : 3); const conversations = await readChatConversationsByEndpointId(endpoint.id); expect(conversations).toHaveLength(1); const comments = await readIssueCommentsByIssueId(conversations[0].issueId); - expect(comments).toHaveLength(3); + expect(comments).toHaveLength(isDM ? 4 : 3); + expect(conversations[0].externalThreadId).toBe(thread.thread.id); await service.update(endpoint.id, { requireAtMention: false }); await send("7100.5", false); - expect(wakeup).toHaveBeenCalledTimes(4); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 5 : 4); if (isDM) { await service.update(endpoint.id, { requireAtMention: true, allowDirectMessages: false }); - await send("7100.6", true); - expect(wakeup).toHaveBeenCalledTimes(4); + await send("7100.6", false); + expect(wakeup).toHaveBeenCalledTimes(5); } }); - it("filters a queued Slack message when Require at-mention is enabled before drain", async () => { + it.each([false, true])("rechecks queued Slack channel mentions but exempts DMs before drain (DM=%s)", async (isDM) => { const fixture = await seedCompany(); const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, { deferWebhookProcessing: true, scheduleDeferredWork: () => undefined, }); - const thread = makeThread({ channelId: "D-QUEUED-MENTION", id: "slack:D-QUEUED-MENTION:7300.1", isDM: true }); + const channelId = isDM ? "D-QUEUED-MENTION" : "C-QUEUED-MENTION"; + const thread = makeThread({ channelId, id: `slack:${channelId}:${isDM ? "" : "7300.1"}`, isDM }); await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, - message: makeMessage({ id: "7300.1", text: "Start work" }), trigger: "direct_message" }); + message: makeMessage({ id: "7300.1", text: "Start work" }), trigger: isDM ? "direct_message" : "subscribed_message" }); await service.update(endpoint.id, { requireAtMention: true }); const [delivery] = await readChatDeliveriesByEndpointId(endpoint.id); await db.update(chatDeliveries).set({ nextAttemptAt: new Date(0) }).where(eq(chatDeliveries.id, delivery.id)); await service.processPendingDeliveries(25, delivery.id); - expect(wakeup).not.toHaveBeenCalled(); - expect(await readChatConversationsByEndpointId(endpoint.id)).toHaveLength(0); - expect((await readChatDeliveriesById(delivery.id))[0].state).toBe("filtered"); + expect(wakeup).toHaveBeenCalledTimes(isDM ? 1 : 0); + expect(await readChatConversationsByEndpointId(endpoint.id)).toHaveLength(isDM ? 1 : 0); + expect((await readChatDeliveriesById(delivery.id))[0].state).toBe(isDM ? "processed" : "filtered"); }); it("snapshots channel workspace defaults on new tasks and preserves them on replies", async () => { diff --git a/server/src/__tests__/durable-chat-wakeup.test.ts b/server/src/__tests__/durable-chat-wakeup.test.ts index 3afa386bfef..a979df891c2 100644 --- a/server/src/__tests__/durable-chat-wakeup.test.ts +++ b/server/src/__tests__/durable-chat-wakeup.test.ts @@ -196,9 +196,12 @@ describe("durable inbound chat scheduler receipts", () => { }; } - it.each(["unmentioned", "mentioned", "coalesced", "recovery", "deferred"])("checks Slack mentions after a real heartbeat queue wait (%s)", async (scenario) => { + it.each([ + ...["unmentioned", "mentioned", "coalesced", "recovery", "deferred"].map((scenario) => ({ scenario, isDM: false })), + ...["unmentioned", "coalesced"].map((scenario) => ({ scenario, isDM: true })), + ])("checks Slack channel mentions after a real heartbeat queue wait ($scenario, DM=$isDM)", async ({ scenario, isDM }) => { const mentioned = scenario === "mentioned" || scenario === "coalesced"; - const allowed = scenario === "mentioned" || scenario === "recovery"; + const allowed = isDM || scenario === "mentioned" || scenario === "recovery"; const f = await fixture(); await db.update(agents).set({ adapterType: "durable_chat_retry_test" }).where(eq(agents.id, f.agentId)); const applicationId = randomUUID(), connectionId = randomUUID(), endpointId = randomUUID(); @@ -211,8 +214,8 @@ describe("durable inbound chat scheduler receipts", () => { await db.insert(issueComments).values({ id: request.commentId, companyId: f.companyId, issueId: f.issueId, authorUserId: "board-user", body: mentioned ? "@bot work" : "Work" }); const [delivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId, - providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "direct_message", state: "processed", - normalizedEvent: { trigger: "direct_message", message: { mentionedBot: mentioned } } }).returning(); + providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: isDM ? "direct_message" : "message", state: "processed", + normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM }, message: { mentionedBot: mentioned } } }).returning(); await db.insert(chatActions).values({ id: request.id, companyId: f.companyId, endpointId, deliveryId: delivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed", payload: { issueId: f.issueId, commentId: request.commentId } }); @@ -233,8 +236,8 @@ describe("durable inbound chat scheduler receipts", () => { await db.insert(issueComments).values({ id: second.commentId, companyId: f.companyId, issueId: f.issueId, authorUserId: "board-user", body: scenario === "deferred" ? "@bot follow-up" : "Unmentioned follow-up" }); const [secondDelivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId, - providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "direct_message", state: "processed", - normalizedEvent: { trigger: "direct_message", message: { mentionedBot: scenario === "deferred" } } }).returning(); + providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: isDM ? "direct_message" : "message", state: "processed", + normalizedEvent: { trigger: isDM ? "direct_message" : "subscribed_message", conversation: { isDirectMessage: isDM }, message: { mentionedBot: scenario === "deferred" } } }).returning(); await db.insert(chatActions).values({ id: second.id, companyId: f.companyId, endpointId, deliveryId: secondDelivery.id, kind: "inbound_wakeup", providerActionId: randomUUID(), status: "processed", payload: { issueId: f.issueId, commentId: second.commentId } }); diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index f4fd375d29e..729af208be0 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -2729,6 +2729,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { if ( current.endpoint.provider === "slack" && current.endpoint.requireAtMention && + !current.conversation.isDirectMessage && current.delivery.normalizedEvent.trigger !== "mention" && (current.delivery.normalizedEvent.message as { mentionedBot?: boolean } | undefined)?.mentionedBot !== true ) { @@ -4227,6 +4228,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const mentionStillAllowed = currentEndpoint.provider !== "slack" || !currentEndpoint.requireAtMention || + thread.isDM || trigger === "mention" || message.isMention === true; if ( diff --git a/server/src/services/chat-channels/inbound/admission.ts b/server/src/services/chat-channels/inbound/admission.ts index 13fd1c0ae61..69ea9cdac5e 100644 --- a/server/src/services/chat-channels/inbound/admission.ts +++ b/server/src/services/chat-channels/inbound/admission.ts @@ -168,6 +168,7 @@ export function createInboundAdmission(db: Db, options: InboundAdmissionOptions) const mentionRequired = endpoint.provider === "slack" && currentEndpoint.requireAtMention && + !thread.isDM && trigger !== "mention" && message.isMention !== true; // GitHub sends the same comment to every installed App. Subscribed diff --git a/server/src/services/durable-chat-wakeup.ts b/server/src/services/durable-chat-wakeup.ts index ed248f579dc..c00ad561b18 100644 --- a/server/src/services/durable-chat-wakeup.ts +++ b/server/src/services/durable-chat-wakeup.ts @@ -322,6 +322,8 @@ export async function slackMentionAllowsRunStart( sql`${agentWakeupRequests.payload}->>'coalescedIntoWakeupRequestId' = ${run.wakeupRequestId}`, ), )); - return inputs.every(({ event }) => event.trigger === "mention" || + return inputs.every(({ event }) => + (event.conversation as { isDirectMessage?: boolean } | undefined)?.isDirectMessage === true || + event.trigger === "mention" || (event.message as { mentionedBot?: boolean } | undefined)?.mentionedBot === true); } diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index 2c07fc4d894..b2055f78f75 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -590,7 +590,7 @@ function Settings({ {endpoint.provider === "slack" && (