diff --git a/docs/providers/slack.md b/docs/providers/slack.md index cabeaa53e..c95e23a0a 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -199,12 +199,20 @@ Ack reactions are controlled globally via `messages.ackReaction` + - Media uploads are capped by `slack.mediaMaxMb` (default 20). ## Reply threading -Slack supports optional threaded replies via tags: -- `[[reply_to_current]]` — reply to the triggering message. -- `[[reply_to:]]` — reply to a specific message id. +By default, Clawdbot replies in the main channel. Use `slack.replyToMode` to control automatic threading: -Controlled by `slack.replyToMode`: -- `off` (default), `first`, `all`. +| Mode | Behavior | +| --- | --- | +| `off` | **Default.** Reply in main channel. Only thread if the triggering message was already in a thread. | +| `first` | First reply goes to thread (under the triggering message), subsequent replies go to main channel. Useful for keeping context visible while avoiding thread clutter. | +| `all` | All replies go to thread. Keeps conversations contained but may reduce visibility. | + +The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). + +### Manual threading tags +For fine-grained control, use these tags in agent responses: +- `[[reply_to_current]]` — reply to the triggering message (start/continue thread). +- `[[reply_to:]]` — reply to a specific message id. ## Sessions + routing - DMs share the `main` session (like WhatsApp/Telegram). diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 0b5287e60..7ba144ce7 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -22,6 +22,14 @@ export function createClawdbotTools(options?: { agentDir?: string; sandboxed?: boolean; config?: ClawdbotConfig; + /** Current channel ID for auto-threading (Slack). */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading (Slack). */ + currentThreadTs?: string; + /** Reply-to mode for Slack auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; }): AnyAgentTool[] { const imageTool = createImageTool({ config: options?.config, @@ -35,6 +43,10 @@ export function createClawdbotTools(options?: { createMessageTool({ agentAccountId: options?.agentAccountId, config: options?.config, + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + replyToMode: options?.replyToMode, + hasRepliedRef: options?.hasRepliedRef, }), createGatewayTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index b6482a4f1..4586d6a9c 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -868,6 +868,7 @@ export async function compactEmbeddedPiSession(params: { sessionKey: params.sessionKey ?? params.sessionId, agentDir, config: params.config, + // No currentChannelId/currentThreadTs for compaction - not in message context }); const machineName = await getMachineDisplayName(); const runtimeProvider = normalizeMessageProvider( @@ -999,6 +1000,14 @@ export async function runEmbeddedPiAgent(params: { sessionKey?: string; messageProvider?: string; agentAccountId?: string; + /** Current channel ID for auto-threading (Slack). */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading (Slack). */ + currentThreadTs?: string; + /** Reply-to mode for Slack auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; sessionFile: string; workspaceDir: string; agentDir?: string; @@ -1200,6 +1209,10 @@ export async function runEmbeddedPiAgent(params: { sessionKey: params.sessionKey ?? params.sessionId, agentDir, config: params.config, + currentChannelId: params.currentChannelId, + currentThreadTs: params.currentThreadTs, + replyToMode: params.replyToMode, + hasRepliedRef: params.hasRepliedRef, }); const machineName = await getMachineDisplayName(); const runtimeInfo = { diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index cf0794842..c52a38a07 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -510,6 +510,14 @@ export function createClawdbotCodingTools(options?: { sessionKey?: string; agentDir?: string; config?: ClawdbotConfig; + /** Current channel ID for auto-threading (Slack). */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading (Slack). */ + currentThreadTs?: string; + /** Reply-to mode for Slack auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; }): AnyAgentTool[] { const bashToolName = "bash"; const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined; @@ -580,6 +588,10 @@ export function createClawdbotCodingTools(options?: { agentDir: options?.agentDir, sandboxed: !!sandbox, config: options?.config, + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + replyToMode: options?.replyToMode, + hasRepliedRef: options?.hasRepliedRef, }), ]; const toolsFiltered = effectiveToolsPolicy diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts index d80558b04..034470ae1 100644 --- a/src/agents/tools/slack-actions.test.ts +++ b/src/agents/tools/slack-actions.test.ts @@ -122,9 +122,194 @@ describe("handleSlackAction", () => { }, cfg, ); - expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Hello thread", { + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C123", + "Hello thread", + { + mediaUrl: undefined, + threadTs: "1234567890.123456", + }, + ); + }); + + it("auto-injects threadTs from context when replyToMode=all", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "Auto-threaded", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "all", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C123", + "Auto-threaded", + { + mediaUrl: undefined, + threadTs: "1111111111.111111", + }, + ); + }); + + it("replyToMode=first threads first message then stops", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + const hasRepliedRef = { value: false }; + const context = { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "first" as const, + hasRepliedRef, + }; + + // First message should be threaded + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "First" }, + cfg, + context, + ); + expect(sendSlackMessage).toHaveBeenLastCalledWith("channel:C123", "First", { mediaUrl: undefined, - threadTs: "1234567890.123456", + threadTs: "1111111111.111111", + }); + expect(hasRepliedRef.value).toBe(true); + + // Second message should NOT be threaded + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "Second" }, + cfg, + context, + ); + expect(sendSlackMessage).toHaveBeenLastCalledWith( + "channel:C123", + "Second", + { + mediaUrl: undefined, + threadTs: undefined, + }, + ); + }); + + it("replyToMode=first without hasRepliedRef does not thread", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { action: "sendMessage", to: "channel:C123", content: "No ref" }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "first", + // no hasRepliedRef + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "No ref", { + mediaUrl: undefined, + threadTs: undefined, + }); + }); + + it("does not auto-inject threadTs when replyToMode=off", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "Off mode", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "off", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith("channel:C123", "Off mode", { + mediaUrl: undefined, + threadTs: undefined, + }); + }); + + it("does not auto-inject threadTs when sending to different channel", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C999", + content: "Different channel", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "all", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C999", + "Different channel", + { + mediaUrl: undefined, + threadTs: undefined, + }, + ); + }); + + it("explicit threadTs overrides context threadTs", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "channel:C123", + content: "Explicit thread", + threadTs: "2222222222.222222", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "all", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith( + "channel:C123", + "Explicit thread", + { + mediaUrl: undefined, + threadTs: "2222222222.222222", + }, + ); + }); + + it("handles channel target without prefix when replyToMode=all", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + sendSlackMessage.mockClear(); + await handleSlackAction( + { + action: "sendMessage", + to: "C123", + content: "No prefix", + }, + cfg, + { + currentChannelId: "C123", + currentThreadTs: "1111111111.111111", + replyToMode: "all", + }, + ); + expect(sendSlackMessage).toHaveBeenCalledWith("C123", "No prefix", { + mediaUrl: undefined, + threadTs: "1111111111.111111", }); }); }); diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index ae3d4c712..f54e15b34 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -34,9 +34,60 @@ const messagingActions = new Set([ const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +export type SlackActionContext = { + /** Current channel ID for auto-threading. */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading. */ + currentThreadTs?: string; + /** Reply-to mode for auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; +}; + +/** + * Resolve threadTs for a Slack message based on context and replyToMode. + * - "all": always inject threadTs + * - "first": inject only for first message (updates hasRepliedRef) + * - "off": never auto-inject + */ +function resolveThreadTsFromContext( + explicitThreadTs: string | undefined, + targetChannel: string, + context: SlackActionContext | undefined, +): string | undefined { + // Agent explicitly provided threadTs - use it + if (explicitThreadTs) return explicitThreadTs; + // No context or missing required fields + if (!context?.currentThreadTs || !context?.currentChannelId) return undefined; + + // Normalize target (strip "channel:" prefix if present) + const normalizedTarget = targetChannel.startsWith("channel:") + ? targetChannel.slice("channel:".length) + : targetChannel; + + // Different channel - don't inject + if (normalizedTarget !== context.currentChannelId) return undefined; + + // Check replyToMode + if (context.replyToMode === "all") { + return context.currentThreadTs; + } + if ( + context.replyToMode === "first" && + context.hasRepliedRef && + !context.hasRepliedRef.value + ) { + context.hasRepliedRef.value = true; + return context.currentThreadTs; + } + return undefined; +} + export async function handleSlackAction( params: Record, cfg: ClawdbotConfig, + context?: SlackActionContext, ): Promise> { const action = readStringParam(params, "action", { required: true }); const accountId = readStringParam(params, "accountId"); @@ -91,7 +142,11 @@ export async function handleSlackAction( const to = readStringParam(params, "to", { required: true }); const content = readStringParam(params, "content", { required: true }); const mediaUrl = readStringParam(params, "mediaUrl"); - const threadTs = readStringParam(params, "threadTs"); + const threadTs = resolveThreadTsFromContext( + readStringParam(params, "threadTs"), + to, + context, + ); const result = await sendSlackMessage(to, content, { accountId: accountId ?? undefined, mediaUrl: mediaUrl ?? undefined, diff --git a/src/agents/tools/slack-tool.ts b/src/agents/tools/slack-tool.ts index fabb63388..afac983b1 100644 --- a/src/agents/tools/slack-tool.ts +++ b/src/agents/tools/slack-tool.ts @@ -9,6 +9,14 @@ import { SlackToolSchema } from "./slack-schema.js"; type SlackToolOptions = { agentAccountId?: string; config?: ClawdbotConfig; + /** Current channel ID for auto-threading. */ + currentChannelId?: string; + /** Current thread timestamp for auto-threading. */ + currentThreadTs?: string; + /** Reply-to mode for auto-threading. */ + replyToMode?: "off" | "first" | "all"; + /** Mutable ref to track if a reply was sent (for "first" mode). */ + hasRepliedRef?: { value: boolean }; }; function resolveAgentAccountId(value?: string): string | undefined { @@ -63,7 +71,12 @@ export function createSlackTool(options?: SlackToolOptions): AnyAgentTool { ).trim()}`, ); } - return await handleSlackAction(resolvedParams, cfg); + return await handleSlackAction(resolvedParams, cfg, { + currentChannelId: options?.currentChannelId, + currentThreadTs: options?.currentThreadTs, + replyToMode: options?.replyToMode, + hasRepliedRef: options?.hasRepliedRef, + }); }, }; } diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 137720b14..345f09082 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -62,6 +62,41 @@ import { createTypingSignaler } from "./typing-mode.js"; const BUN_FETCH_SOCKET_ERROR_RE = /socket connection was closed unexpectedly/i; const BLOCK_REPLY_SEND_TIMEOUT_MS = 15_000; +/** + * Build Slack-specific threading context for tool auto-injection. + * Returns undefined values for non-Slack providers. + */ +function buildSlackThreadingContext(params: { + sessionCtx: TemplateContext; + config: { slack?: { replyToMode?: "off" | "first" | "all" } } | undefined; + hasRepliedRef: { value: boolean } | undefined; +}): { + currentChannelId: string | undefined; + currentThreadTs: string | undefined; + replyToMode: "off" | "first" | "all" | undefined; + hasRepliedRef: { value: boolean } | undefined; +} { + const { sessionCtx, config, hasRepliedRef } = params; + const isSlack = sessionCtx.Provider?.toLowerCase() === "slack"; + if (!isSlack) { + return { + currentChannelId: undefined, + currentThreadTs: undefined, + replyToMode: undefined, + hasRepliedRef: undefined, + }; + } + return { + // Extract channel from "channel:C123" format + currentChannelId: sessionCtx.To?.startsWith("channel:") + ? sessionCtx.To.slice("channel:".length) + : undefined, + currentThreadTs: sessionCtx.ReplyToId, + replyToMode: config?.slack?.replyToMode ?? "off", + hasRepliedRef, + }; +} + const isBunFetchSocketError = (message?: string) => Boolean(message && BUN_FETCH_SOCKET_ERROR_RE.test(message)); @@ -375,6 +410,12 @@ export async function runReplyAgent(params: { messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined, agentAccountId: sessionCtx.AccountId, + // Slack threading context for tool auto-injection + ...buildSlackThreadingContext({ + sessionCtx, + config: followupRun.run.config, + hasRepliedRef: opts?.hasRepliedRef, + }), sessionFile: followupRun.run.sessionFile, workspaceDir: followupRun.run.workspaceDir, agentDir: followupRun.run.agentDir, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index a276fe66d..fb7b8321a 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -21,6 +21,8 @@ export type GetReplyOptions = { blockReplyTimeoutMs?: number; /** If provided, only load these skills for this session (empty = no skills). */ skillFilter?: string[]; + /** Mutable ref to track if a reply was sent (for Slack "first" threading mode). */ + hasRepliedRef?: { value: boolean }; }; export type ReplyPayload = { diff --git a/src/slack/monitor.test.ts b/src/slack/monitor.test.ts index baa5a7397..73a6ac873 100644 --- a/src/slack/monitor.test.ts +++ b/src/slack/monitor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isSlackRoomAllowedByPolicy } from "./monitor.js"; +import { isSlackRoomAllowedByPolicy, resolveSlackThreadTs } from "./monitor.js"; describe("slack groupPolicy gating", () => { it("allows when policy is open", () => { @@ -53,3 +53,83 @@ describe("slack groupPolicy gating", () => { ).toBe(false); }); }); + +describe("resolveSlackThreadTs", () => { + const threadTs = "1234567890.123456"; + + describe("replyToMode=off", () => { + it("returns baseThreadTs when in a thread", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "off", + baseThreadTs: threadTs, + hasReplied: false, + }), + ).toBe(threadTs); + }); + + it("returns baseThreadTs even after replies (stays in thread)", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "off", + baseThreadTs: threadTs, + hasReplied: true, + }), + ).toBe(threadTs); + }); + + it("returns undefined when not in a thread", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "off", + baseThreadTs: undefined, + hasReplied: false, + }), + ).toBeUndefined(); + }); + }); + + describe("replyToMode=first", () => { + it("returns baseThreadTs for first reply", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "first", + baseThreadTs: threadTs, + hasReplied: false, + }), + ).toBe(threadTs); + }); + + it("returns undefined for subsequent replies (goes to main channel)", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "first", + baseThreadTs: threadTs, + hasReplied: true, + }), + ).toBeUndefined(); + }); + }); + + describe("replyToMode=all", () => { + it("returns baseThreadTs for first reply", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "all", + baseThreadTs: threadTs, + hasReplied: false, + }), + ).toBe(threadTs); + }); + + it("returns baseThreadTs for subsequent replies (all go to thread)", () => { + expect( + resolveSlackThreadTs({ + replyToMode: "all", + baseThreadTs: threadTs, + hasReplied: true, + }), + ).toBe(threadTs); + }); + }); +}); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index ea02865a9..6a25a22b8 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -1069,11 +1069,22 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ); } - const { replyThreadTs, statusThreadTs } = resolveSlackThreadTargets({ + // Use helper for status thread; compute baseThreadTs for "first" mode support + const { statusThreadTs } = resolveSlackThreadTargets({ message, replyToMode, }); + // Base thread timestamp: where should first reply go? + // - "off": only thread if already in a thread + // - "first"/"all": start thread under the message + const baseThreadTs = + replyToMode === "off" + ? message.thread_ts + : (message.thread_ts ?? message.ts); let didSetStatus = false; + // Shared mutable ref for tracking if a reply was sent (used by both + // auto-reply path and tool path for "first" threading mode). + const hasRepliedRef = { value: false }; const onReplyStart = async () => { didSetStatus = true; await setSlackThreadStatus({ @@ -1087,6 +1098,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId) .responsePrefix, deliver: async (payload) => { + const effectiveThreadTs = resolveSlackThreadTs({ + replyToMode, + baseThreadTs, + hasReplied: hasRepliedRef.value, + }); await deliverReplies({ replies: [payload], target: replyTarget, @@ -1094,8 +1110,9 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { accountId: account.accountId, runtime, textLimit, - replyThreadTs, + threadTs: effectiveThreadTs, }); + hasRepliedRef.value = true; }, onError: (err, info) => { runtime.error?.( @@ -1119,6 +1136,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { replyOptions: { ...replyOptions, skillFilter: channelConfig?.skills, + hasRepliedRef, disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming @@ -1958,6 +1976,30 @@ export function isSlackRoomAllowedByPolicy(params: { return channelAllowed; } +/** + * Compute effective threadTs for a Slack reply based on replyToMode. + * - "off": stay in thread if already in one, otherwise main channel + * - "first": first reply goes to thread, subsequent replies to main channel + * - "all": all replies go to thread + */ +export function resolveSlackThreadTs(params: { + replyToMode: "off" | "first" | "all"; + baseThreadTs: string | undefined; + hasReplied: boolean; +}): string | undefined { + const { replyToMode, baseThreadTs, hasReplied } = params; + if (replyToMode === "off") { + // Always stay in thread if already in one + return baseThreadTs; + } + if (replyToMode === "all") { + // All replies go to thread + return baseThreadTs; + } + // "first": only first reply goes to thread + return hasReplied ? undefined : baseThreadTs; +} + async function deliverSlackSlashReplies(params: { replies: ReplyPayload[]; respond: SlackRespondFn;