Slack: implement replyToMode threading for tool path

- Add shared hasRepliedRef state between auto-reply and tool paths
- Extract buildSlackThreadingContext helper in agent-runner.ts
- Extract resolveThreadTsFromContext helper in slack-actions.ts
- Update docs with clear replyToMode table (off/first/all)
- Add tests for first mode behavior across multiple messages
This commit is contained in:
Austin Mudd
2026-01-08 16:04:52 -08:00
committed by Peter Steinberger
parent 29e6f13b29
commit b4663ed11c
11 changed files with 475 additions and 12 deletions

View File

@@ -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:<id>]]` — 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:<id>]]` — reply to a specific message id.
## Sessions + routing
- DMs share the `main` session (like WhatsApp/Telegram).

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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

View File

@@ -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",
});
});
});

View File

@@ -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<string, unknown>,
cfg: ClawdbotConfig,
context?: SlackActionContext,
): Promise<AgentToolResult<unknown>> {
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,

View File

@@ -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,
});
},
};
}

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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);
});
});
});

View File

@@ -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;