diff --git a/.changeset/slick-points-arrive.md b/.changeset/slick-points-arrive.md new file mode 100644 index 000000000..8eb4bd547 --- /dev/null +++ b/.changeset/slick-points-arrive.md @@ -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 diff --git a/apps/docs/content/adapters/official/slack.mdx b/apps/docs/content/adapters/official/slack.mdx index c96ca72f3..3e546d72a 100644 --- a/apps/docs/content/adapters/official/slack.mdx +++ b/apps/docs/content/adapters/official/slack.mdx @@ -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: @@ -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. + + +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. + + +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. @@ -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. - - -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. - +- 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: diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 007753cde..0b262c596 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -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, + body: string + ) { + await adapter.initialize(mockChat); + const tasks: Promise[] = []; + 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 // ============================================================================ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index af5bcdefd..39e5396a0 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -631,6 +631,8 @@ export class SlackAdapter implements Adapter { // 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 `{}`). */ @@ -811,6 +813,7 @@ export class SlackAdapter implements Adapter { 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; @@ -2611,16 +2614,17 @@ export class SlackAdapter implements Adapter { 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({ @@ -2646,12 +2650,12 @@ export class SlackAdapter implements Adapter { 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, @@ -2665,7 +2669,7 @@ export class SlackAdapter implements Adapter { } } 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 } ); } @@ -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, diff --git a/packages/adapter-slack/src/types.ts b/packages/adapter-slack/src/types.ts index ea69f60df..ee4e8c3da 100644 --- a/packages/adapter-slack/src/types.ts +++ b/packages/adapter-slack/src/types.ts @@ -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; /**