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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-13 07:40:46 -05:00
1 parent fc3b15537b
commit df06df2306
11 files changed
+114 -62

No files matched your search

+12 -8
View File
@@ -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.
+8
View File
@@ -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.
+6
View File
@@ -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.
+20
View File
@@ -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.
+24 -24
View File
@@ -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"
},
@@ -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 () => {
@@ -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 } });
+2
View File
@@ -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 (
@@ -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
+3 -1
View File
@@ -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);
}
@@ -590,7 +590,7 @@ function Settings({
{endpoint.provider === "slack" && (
<SettingToggle
label="Require at-mention"
detail="Only respond to messages that @mention this bot, including thread replies and direct messages. Direct messages must also be allowed above."
detail="In channels, only respond to messages that @mention this bot, including thread replies. Direct messages never require an @mention and must be allowed above."
checked={endpoint.requireAtMention ?? false}
pending={updateEndpoint.isPending}
onChange={(requireAtMention) =>