From df06df2306405a1a7add6a48df032d8ac4a05959 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 13 Oct 2026 07:40:46 -0500 Subject: [PATCH] fix(slack): allow direct messages without at-mentions (#16151) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Slack connections let people start and continue agent tasks. > - The Require at-mention setting currently applies to channels and direct messages. > - A person who sends a direct message has already selected the agent. > - This pull request exempts direct messages from mention checks, including queued work. > - People can talk normally in DMs while channel messages keep the configured mention rule. ## Linked Issues or Issue Description Refs #15612, which introduced the Require at-mention setting. Searches found no duplicate PR for the DM exemption. **What happened?** With Require at-mention enabled, a plain Slack DM does not start or continue agent work. New Slack connections enable the setting by default. **Expected behavior** Direct messages do not need an at-mention. Top-level DM replies remain top-level. Channel messages and channel thread replies still follow the saved mention setting. **Steps to reproduce** 1. Enable Allow direct messages and Require at-mention on a Slack connection. 2. Send a plain DM from an authorized sender. 3. Confirm the agent responds without an at-mention. Send a plain follow-up and confirm it also works. 4. Confirm unmentioned channel messages remain filtered when Require at-mention is enabled. 5. Disable Allow direct messages and confirm new DMs remain blocked. **Paperclip version or commit** Source baseline: `eb06c9fedd`. **Deployment mode** Built from source. Automated tests use disposable local Postgres and provider fixtures. This change has not been deployed or retested in live Slack. ## What Changed - Exempt DMs at inbound admission, task mutation, work authorization, and heartbeat queue claim. - Keep channel mention checks and all existing sender, endpoint, and destination checks. - Update regressions for new connections, follow-ups, deferred delivery, and coalesced queued messages. - Clarify the Slack settings copy, connection documentation, and verification maps. - Refresh the affected Storybook review references after source inspection. ## Verification - Passed 55 Slack and durable scheduler tests with Vitest on the current rebased commit, including admission through the extracted inbound module. - Passed `pnpm -r typecheck` and `pnpm build` on the rebased PR commit. - Passed token gates and map checks. - Passed Storybook catalog validation after rebase: 223 files and 2,210 stories. - Full local `pnpm test:run` was stopped before completion after three failures in the unchanged tool-access suite. All 418 tests in that suite passed in a standalone rerun. The focused regressions passed; full GitHub CI remains the merge gate. - Greptile reviewed commit `b0293fa6571403c5e01c30bb7b5fcc5e91b88c97`: 5/5, with no findings or unresolved review threads. All 55 GitHub checks passed on this commit; two optional Storybook jobs were skipped. - No live Slack messages were sent during implementation verification. ## Risks Existing Slack connections now accept authorized plain DMs even when Require at-mention is enabled. Allow direct messages and sender authorization still apply. Queued DM work follows the same exemption. Channel behavior and DM reply routing are unchanged. No schema or migration change is required. ## Model Used OpenAI GPT-6, running as Codex, with reasoning, tool use, and code execution. The runtime does not expose a more specific model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/connections/SLACK-TASK-TOOLS.md | 20 +++++--- maps/agent-access-map/chat-channels.md | 8 +++ maps/feature-map/chat-channels.md | 6 +++ maps/storybook-map/REVIEW.md | 20 ++++++++ maps/storybook-map/catalog.json | 48 +++++++++--------- .../chat-channels.integration.test.ts | 50 +++++++++++-------- .../src/__tests__/durable-chat-wakeup.test.ts | 15 +++--- server/src/services/chat-channels.ts | 2 + .../chat-channels/inbound/admission.ts | 1 + server/src/services/durable-chat-wakeup.ts | 4 +- ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 2 +- 11 files changed, 114 insertions(+), 62 deletions(-) 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" && (