Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slick-points-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/slack": minor
---

add threadDirectMessages option to reply in a thread when a user DMs the bot, which also enables native streaming in DMs
32 changes: 27 additions & 5 deletions apps/docs/content/adapters/official/slack.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ bot.onNewMention(async (thread, message) => {
description:
"Enable the Agent messaging experience (agent_view manifest mode).",
},
threadDirectMessages: {
type: "boolean",
default: "false",
description:
"Reply in a thread when a user DMs the bot, so each top-level DM starts its own conversation. Implied by agentView.",
},
suggestedPrompts: {
type: "SlackSuggestedPrompts",
description:
Expand Down Expand Up @@ -276,6 +282,26 @@ The `@chat-adapter/slack/webhook`, `@chat-adapter/slack/format`, `@chat-adapter/

## Advanced

### Threaded DMs

By default a DM with your bot is one flat conversation: every message and every reply lands at the top level. Set `threadDirectMessages` to reply in a thread instead, so each top-level DM starts its own conversation:

```typescript
const slack = createSlackAdapter({ threadDirectMessages: true });
```

Each message the user sends at the DM top level becomes a thread root and the bot replies inside it. Users get one scoped conversation per topic instead of an unbounded transcript, and follow-ups stay attached to the message they belong to.

It also gives DMs the `thread_ts` that Slack's [native streaming API](#native-streaming) requires, so streamed replies render natively in DMs rather than falling back to post-and-edit.

<Callout type="warn">
Because bot replies are threaded under each user message, channel-level history (`channel.messages`, `conversations.history`) only returns the user's side of a DM conversation. If you build AI conversation history for DMs, use [transcripts](/docs/conversation-history) (which record both roles across thread IDs) instead of channel history, otherwise the model never sees its own previous replies.
</Callout>

Thread IDs change from `slack:{channelId}:` to `slack:{channelId}:{ts}`, so turning this on for a running bot starts fresh threads and state stored against the old conversation-scoped ID stays there. Subscriptions are bridged: when the conversation-scoped thread returned by `openDM()` is subscribed, top-level DM messages still route to it, so `onSubscribedMessage` and proactive flows keep working.

[`agentView`](#agent-messaging-experience) already threads DMs, so agents do not need to set this.

### Agents

Everything for building an AI agent on Slack: the Agent messaging experience (`agent_view`), the Assistants API (suggested prompts, status, titles), native streaming, and feedback buttons.
Expand All @@ -294,11 +320,7 @@ With `agentView: true`:
- `onAppContextChanged` reports the user's active view (see [Handling active-view context](/docs/handling-events#handling-active-view-context-agent-messaging)).
- `getAppContext(message)` returns the folded active-view context on a DM message.
- `setSuggestedPrompts(channelId, undefined, prompts)` may omit the thread reference — prompts sit at the top of the agent conversation. A `suggestedPrompts` config entry is applied automatically on every Messages-tab open.
- DM messages are threaded per Slack's model (each user message is a thread root). Threads returned by `openDM()` keep working: when the conversation-scoped thread is subscribed, incoming top-level DM messages route to it, so `onSubscribedMessage` and per-thread state behave as before.

<Callout type="warn">
Because bot replies are threaded under each user message, channel-level history (`channel.messages`, `conversations.history`) only returns the user's side of a DM conversation. If you build AI conversation history for DMs, use [transcripts](/docs/conversation-history) (which record both roles across thread IDs) instead of channel history — otherwise the model never sees its own previous replies.
</Callout>
- DM messages are threaded per Slack's model (each user message is a thread root). See [Threaded DMs](#threaded-dms) for how thread IDs, subscriptions, and conversation history behave.

Add the event subscription and scope to your manifest:

Expand Down
115 changes: 115 additions & 0 deletions packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4963,6 +4963,121 @@ describe("agent_view DM threading", () => {
});
});

// ============================================================================
// threadDirectMessages Tests
// ============================================================================

describe("threadDirectMessages", () => {
const secret = "test-secret";

function dmBody(threadTs?: string) {
return JSON.stringify({
type: "event_callback",
event: {
type: "message",
channel: "D1",
channel_type: "im",
user: "U1",
text: "hi",
ts: "1771.99",
event_ts: "1771.99",
...(threadTs ? { thread_ts: threadTs } : {}),
},
});
}

async function routeDM(
adapter: SlackAdapter,
mockChat: ReturnType<typeof createMockChatInstance>,
body: string
) {
await adapter.initialize(mockChat);
const tasks: Promise<unknown>[] = [];
await adapter.handleWebhook(createWebhookRequest(body, secret), {
waitUntil: (p) => {
tasks.push(p);
},
});
await Promise.all(tasks);
return vi.mocked(mockChat.processMessage).mock.calls[0]?.[1];
}

it("threads a top-level DM message under its own ts", async () => {
const adapter = createSlackAdapter({
threadDirectMessages: true,
signingSecret: secret,
botUserId: "U_BOT",
logger: mockLogger,
});
const mockChat = createMockChatInstance({ state: createMockState() });

expect(await routeDM(adapter, mockChat, dmBody())).toBe("slack:D1:1771.99");
});

it("keeps DM replies on the parent thread_ts", async () => {
const adapter = createSlackAdapter({
threadDirectMessages: true,
signingSecret: secret,
botUserId: "U_BOT",
logger: mockLogger,
});
const mockChat = createMockChatInstance({ state: createMockState() });

expect(await routeDM(adapter, mockChat, dmBody("1771.11"))).toBe(
"slack:D1:1771.11"
);
});

it("routes to the conversation-scoped thread when it is subscribed (openDM flow)", async () => {
const adapter = createSlackAdapter({
threadDirectMessages: true,
signingSecret: secret,
botUserId: "U_BOT",
logger: mockLogger,
});
const state = createMockState();
await state.subscribe("slack:D1:");
const mockChat = createMockChatInstance({ state });

expect(await routeDM(adapter, mockChat, dmBody())).toBe("slack:D1:");
});

it("keeps DMs conversation-scoped when unset", async () => {
const adapter = createSlackAdapter({
signingSecret: secret,
botUserId: "U_BOT",
logger: mockLogger,
});
const mockChat = createMockChatInstance({ state: createMockState() });

expect(await routeDM(adapter, mockChat, dmBody())).toBe("slack:D1:");
});

it("leaves channel threading unchanged", async () => {
const adapter = createSlackAdapter({
threadDirectMessages: true,
signingSecret: secret,
botUserId: "U_BOT",
logger: mockLogger,
});
const mockChat = createMockChatInstance({ state: createMockState() });
const body = JSON.stringify({
type: "event_callback",
event: {
type: "message",
channel: "C1",
channel_type: "channel",
user: "U1",
text: "hi",
ts: "1771.99",
event_ts: "1771.99",
},
});

expect(await routeDM(adapter, mockChat, body)).toBe("slack:C1:1771.99");
});
});

// ============================================================================
// Typing Indicator Tests
// ============================================================================
Expand Down
27 changes: 16 additions & 11 deletions packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,8 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
// Socket mode support
protected readonly appToken: string | undefined;
protected readonly agentView: boolean;
/** True when top-level DMs are routed to their own thread root. */
protected readonly dmThreading: boolean;
protected readonly suggestedPrompts?: SlackSuggestedPrompts;
protected readonly loadingMessages?: string[];
/** Normalized feedbackButtons config (`true` becomes `{}`). */
Expand Down Expand Up @@ -811,6 +813,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {

this.appToken = config.appToken;
this.agentView = config.agentView ?? false;
this.dmThreading = this.agentView || (config.threadDirectMessages ?? false);
this.suggestedPrompts = config.suggestedPrompts;
this.loadingMessages = config.loadingMessages;
this.nativeStreaming = config.nativeStreaming ?? true;
Expand Down Expand Up @@ -2611,16 +2614,17 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
return;
}

// For DMs under assistant_view (legacy): top-level messages use empty threadTs
// (matches openDM subscriptions); thread replies use thread_ts for per-conversation
// isolation.
// Under agent_view the Messages-tab conversation is threaded per Slack's model —
// each user message is a thread root — so reply in-thread using `thread_ts ?? ts`
// (except when the conversation-scoped openDM ID is subscribed; see bridge below).
// For DMs by default (and under assistant_view, legacy): top-level messages use
// empty threadTs (matches openDM subscriptions); thread replies use thread_ts for
// per-conversation isolation.
// Under agent_view or threadDirectMessages the DM conversation is threaded per
// Slack's model, each user message being a thread root, so reply in-thread using
// `thread_ts ?? ts` (except when the conversation-scoped openDM ID is subscribed;
// see bridge below).
// For channels: always use thread_ts or ts for per-thread IDs.
const isDM = event.channel_type === "im";
const threadTs =
isDM && !this.agentView
isDM && !this.dmThreading
? event.thread_ts || ""
: event.thread_ts || event.ts;
const threadId = this.encodeThreadId({
Expand All @@ -2646,12 +2650,12 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
return msg;
};

// Under agent_view each top-level DM message is its own thread root, which
// would silently bypass subscriptions created on the conversation-scoped
// With DM threading on, each top-level DM message is its own thread root,
// which would silently bypass subscriptions created on the conversation-scoped
// thread ID that openDM() returns (slack:{D…}:). Bridge: when that
// conversation-scoped ID is subscribed, route the message to it so
// onSubscribedMessage and per-thread state keep working for proactive flows.
if (this.agentView && isDM && !event.thread_ts) {
if (this.dmThreading && isDM && !event.thread_ts) {
const chat = this.chat;
const conversationThreadId = this.encodeThreadId({
channel: event.channel,
Expand All @@ -2665,7 +2669,7 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
}
} catch (error) {
this.logger.warn(
"agent_view DM subscription check failed; using per-message thread",
"DM subscription check failed; using per-message thread",
{ error: String(error), threadId }
);
}
Expand Down Expand Up @@ -5832,6 +5836,7 @@ export function createSlackAdapter(config?: SlackAdapterConfig): SlackAdapter {
logger: config?.logger ?? new ConsoleLogger("info").child("slack"),
nativeStreaming: config?.nativeStreaming,
suggestedPrompts: config?.suggestedPrompts,
threadDirectMessages: config?.threadDirectMessages,
socketForwardingSecret:
config?.socketForwardingSecret ??
process.env.SLACK_SOCKET_FORWARDING_SECRET,
Expand Down
13 changes: 13 additions & 0 deletions packages/adapter-slack/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,19 @@ export interface SlackAdapterConfig {
* active-view entities) and returns prompts per thread.
*/
suggestedPrompts?: SlackSuggestedPrompts;
/**
* Reply in a thread when a user DMs the bot, so each top-level DM starts its
* own conversation instead of one flat, unbounded transcript. Also gives DMs
* the `thread_ts` that Slack's native streaming API requires.
*
* Thread IDs become `slack:{D…}:{ts}` rather than `slack:{D…}:`, so enabling
* this on a running bot starts fresh threads: existing per-thread state stays
* on the old ID. Subscriptions created by `openDM()` keep working: a
* subscribed conversation-scoped thread still receives every DM.
*
* Implied by `agentView`. Defaults to false.
*/
threadDirectMessages?: boolean;
/** Override bot username (optional) */
userName?: string;
/**
Expand Down