Slack: tighten threading + tool reply suppression

This commit is contained in:
SocialNerd42069 2026-01-26 20:48:19 -06:00
parent 83de980d6c
commit f55bf604e4
11 changed files with 66 additions and 13 deletions

View File

@ -263,7 +263,11 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
const to = typeof args.to === "string" ? args.to : undefined; const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null; if (!to) return null;
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId }; const threadIdRaw = typeof args.threadId === "string" ? args.threadId.trim() : "";
const replyToRaw = typeof args.replyTo === "string" ? args.replyTo.trim() : "";
const threadTsRaw = typeof args.threadTs === "string" ? args.threadTs.trim() : "";
const threadId = threadIdRaw || replyToRaw || threadTsRaw || undefined;
return { to, accountId, threadId };
}, },
handleAction: async ({ action, params, cfg, accountId, toolContext }) => { handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const resolveChannelId = () => const resolveChannelId = () =>

View File

@ -5,6 +5,7 @@ export type MessagingToolSend = {
provider: string; provider: string;
accountId?: string; accountId?: string;
to?: string; to?: string;
threadId?: string | number;
}; };
const CORE_MESSAGING_TOOLS = new Set(["sessions_send", "message"]); const CORE_MESSAGING_TOOLS = new Set(["sessions_send", "message"]);

View File

@ -118,6 +118,14 @@ export function extractMessagingToolSend(
toolName: string, toolName: string,
args: Record<string, unknown>, args: Record<string, unknown>,
): MessagingToolSend | undefined { ): MessagingToolSend | undefined {
const readThreadId = (value: unknown): string | number | undefined => {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number" && Number.isFinite(value)) return value;
return undefined;
};
// Provider docking: new provider tools must implement plugin.actions.extractToolSend. // Provider docking: new provider tools must implement plugin.actions.extractToolSend.
const action = typeof args.action === "string" ? args.action.trim() : ""; const action = typeof args.action === "string" ? args.action.trim() : "";
const accountIdRaw = typeof args.accountId === "string" ? args.accountId.trim() : undefined; const accountIdRaw = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
@ -132,7 +140,11 @@ export function extractMessagingToolSend(
const providerId = providerHint ? normalizeChannelId(providerHint) : null; const providerId = providerHint ? normalizeChannelId(providerHint) : null;
const provider = providerId ?? (providerHint ? providerHint.toLowerCase() : "message"); const provider = providerId ?? (providerHint ? providerHint.toLowerCase() : "message");
const to = normalizeTargetForProvider(provider, toRaw); const to = normalizeTargetForProvider(provider, toRaw);
return to ? { tool: toolName, provider, accountId, to } : undefined; let threadId = readThreadId(args.threadId);
if (!threadId && provider === "slack") {
threadId = readThreadId(args.replyTo ?? args.threadTs);
}
return to ? { tool: toolName, provider, accountId, to, threadId } : undefined;
} }
const providerId = normalizeChannelId(toolName); const providerId = normalizeChannelId(toolName);
if (!providerId) return undefined; if (!providerId) return undefined;
@ -146,6 +158,7 @@ export function extractMessagingToolSend(
provider: providerId, provider: providerId,
accountId: extracted.accountId ?? accountId, accountId: extracted.accountId ?? accountId,
to, to,
threadId: extracted.threadId,
} }
: undefined; : undefined;
} }

View File

@ -31,6 +31,7 @@ export function buildReplyPayloads(params: {
typeof shouldSuppressMessagingToolReplies typeof shouldSuppressMessagingToolReplies
>[0]["messagingToolSentTargets"]; >[0]["messagingToolSentTargets"];
originatingTo?: string; originatingTo?: string;
originatingThreadId?: string | number;
accountId?: string; accountId?: string;
}): { replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean } { }): { replyPayloads: ReplyPayload[]; didLogHeartbeatStrip: boolean } {
let didLogHeartbeatStrip = params.didLogHeartbeatStrip; let didLogHeartbeatStrip = params.didLogHeartbeatStrip;
@ -94,6 +95,7 @@ export function buildReplyPayloads(params: {
messageProvider: params.messageProvider, messageProvider: params.messageProvider,
messagingToolSentTargets, messagingToolSentTargets,
originatingTo: params.originatingTo, originatingTo: params.originatingTo,
originatingThreadId: params.originatingThreadId,
accountId: params.accountId, accountId: params.accountId,
}); });
const dedupedPayloads = filterMessagingToolDuplicates({ const dedupedPayloads = filterMessagingToolDuplicates({

View File

@ -408,6 +408,7 @@ export async function runReplyAgent(params: {
messagingToolSentTexts: runResult.messagingToolSentTexts, messagingToolSentTexts: runResult.messagingToolSentTexts,
messagingToolSentTargets: runResult.messagingToolSentTargets, messagingToolSentTargets: runResult.messagingToolSentTargets,
originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To, originatingTo: sessionCtx.OriginatingTo ?? sessionCtx.To,
originatingThreadId: sessionCtx.MessageThreadId,
accountId: sessionCtx.AccountId, accountId: sessionCtx.AccountId,
}); });
const { replyPayloads } = payloadResult; const { replyPayloads } = payloadResult;

View File

@ -82,12 +82,22 @@ export function shouldSuppressMessagingToolReplies(params: {
messageProvider?: string; messageProvider?: string;
messagingToolSentTargets?: MessagingToolSend[]; messagingToolSentTargets?: MessagingToolSend[];
originatingTo?: string; originatingTo?: string;
originatingThreadId?: string | number;
accountId?: string; accountId?: string;
}): boolean { }): boolean {
const provider = params.messageProvider?.trim().toLowerCase(); const provider = params.messageProvider?.trim().toLowerCase();
if (!provider) return false; if (!provider) return false;
const originTarget = normalizeTargetForProvider(provider, params.originatingTo); const originTarget = normalizeTargetForProvider(provider, params.originatingTo);
if (!originTarget) return false; if (!originTarget) return false;
const normalizeThreadId = (value?: string | number) => {
if (value === undefined || value === null) return undefined;
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
return Number.isFinite(value) ? String(value) : undefined;
};
const originThreadId = normalizeThreadId(params.originatingThreadId);
const originAccount = normalizeAccountId(params.accountId); const originAccount = normalizeAccountId(params.accountId);
const sentTargets = params.messagingToolSentTargets ?? []; const sentTargets = params.messagingToolSentTargets ?? [];
if (sentTargets.length === 0) return false; if (sentTargets.length === 0) return false;
@ -95,11 +105,15 @@ export function shouldSuppressMessagingToolReplies(params: {
if (!target?.provider) return false; if (!target?.provider) return false;
if (target.provider.trim().toLowerCase() !== provider) return false; if (target.provider.trim().toLowerCase() !== provider) return false;
const targetKey = normalizeTargetForProvider(provider, target.to); const targetKey = normalizeTargetForProvider(provider, target.to);
if (!targetKey) return false; if (!targetKey || targetKey !== originTarget) return false;
const targetAccount = normalizeAccountId(target.accountId); const targetAccount = normalizeAccountId(target.accountId);
if (originAccount && targetAccount && originAccount !== targetAccount) { if (originAccount && targetAccount && originAccount !== targetAccount) {
return false; return false;
} }
return targetKey === originTarget; const targetThreadId = normalizeThreadId(target.threadId);
if (originThreadId || targetThreadId) {
return originThreadId === targetThreadId;
}
return true;
}); });
} }

View File

@ -62,7 +62,9 @@ export const telegramMessageActions: ChannelMessageActionAdapter = {
const to = typeof args.to === "string" ? args.to : undefined; const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null; if (!to) return null;
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId }; const threadIdRaw = typeof args.threadId === "string" ? args.threadId.trim() : "";
const threadId = threadIdRaw || undefined;
return { to, accountId, threadId };
}, },
handleAction: async ({ action, params, cfg, accountId }) => { handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "send") { if (action === "send") {

View File

@ -54,7 +54,11 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
const to = typeof args.to === "string" ? args.to : undefined; const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null; if (!to) return null;
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined; const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId }; const threadIdRaw = typeof args.threadId === "string" ? args.threadId.trim() : "";
const replyToRaw = typeof args.replyTo === "string" ? args.replyTo.trim() : "";
const threadTsRaw = typeof args.threadTs === "string" ? args.threadTs.trim() : "";
const threadId = threadIdRaw || replyToRaw || threadTsRaw || undefined;
return { to, accountId, threadId };
}, },
handleAction: async (ctx: ChannelMessageActionContext) => { handleAction: async (ctx: ChannelMessageActionContext) => {
const { action, params, cfg } = ctx; const { action, params, cfg } = ctx;

View File

@ -300,6 +300,7 @@ export type ChannelMessageActionContext = {
export type ChannelToolSend = { export type ChannelToolSend = {
to: string; to: string;
accountId?: string | null; accountId?: string | null;
threadId?: string | number;
}; };
export type ChannelMessageActionAdapter = { export type ChannelMessageActionAdapter = {

View File

@ -44,6 +44,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const messageTs = message.ts ?? message.event_ts; const messageTs = message.ts ?? message.event_ts;
const incomingThreadTs = message.thread_ts; const incomingThreadTs = message.thread_ts;
const toolThreadTs = incomingThreadTs ?? (ctx.replyToMode === "all" ? messageTs : undefined);
let didSetStatus = false; let didSetStatus = false;
// Shared mutable ref for "replyToMode=first". Both tool + auto-reply flows // Shared mutable ref for "replyToMode=first". Both tool + auto-reply flows
@ -101,8 +102,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
responsePrefix: prefixContext.responsePrefix, responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId), humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => { deliver: async (payload, info) => {
const replyThreadTs = replyPlan.nextThreadTs(); const replyThreadTs = info.kind === "tool" ? toolThreadTs : replyPlan.nextThreadTs();
if (shouldLogVerbose()) {
logVerbose(
`slack threading: deliver kind=${info.kind} target=${prepared.replyTarget} thread_ts=${replyThreadTs ?? "none"} payload.replyToId=${payload.replyToId ?? "none"}`,
);
}
await deliverReplies({ await deliverReplies({
replies: [payload], replies: [payload],
target: prepared.replyTarget, target: prepared.replyTarget,
@ -112,7 +118,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
textLimit: ctx.textLimit, textLimit: ctx.textLimit,
replyThreadTs, replyThreadTs,
}); });
replyPlan.markSent(); if (info.kind !== "tool") {
replyPlan.markSent();
}
}, },
onError: (err, info) => { onError: (err, info) => {
runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`)); runtime.error?.(danger(`slack ${info.kind} reply failed: ${String(err)}`));

View File

@ -197,14 +197,17 @@ export async function prepareSlackMessage(params: {
const threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode }); const threadContext = resolveSlackThreadContext({ message, replyToMode: ctx.replyToMode });
const threadTs = threadContext.incomingThreadTs; const threadTs = threadContext.incomingThreadTs;
const isThreadReply = threadContext.isThreadReply; const isThreadReply = threadContext.isThreadReply;
const autoThreadId =
!isThreadReply && ctx.replyToMode === "all" ? threadContext.messageThreadId : undefined;
const threadSessionId = isThreadReply ? threadTs : autoThreadId;
const threadKeys = resolveThreadSessionKeys({ const threadKeys = resolveThreadSessionKeys({
baseSessionKey, baseSessionKey,
threadId: isThreadReply ? threadTs : undefined, threadId: threadSessionId,
parentSessionKey: isThreadReply && ctx.threadInheritParent ? baseSessionKey : undefined, parentSessionKey: threadSessionId && ctx.threadInheritParent ? baseSessionKey : undefined,
}); });
const sessionKey = threadKeys.sessionKey; const sessionKey = threadKeys.sessionKey;
const historyKey = const historyKey =
isThreadReply && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel; threadSessionId && ctx.threadHistoryScope === "thread" ? sessionKey : message.channel;
const mentionRegexes = buildMentionRegexes(cfg, route.agentId); const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const hasAnyMention = /<@[^>]+>/.test(message.text ?? ""); const hasAnyMention = /<@[^>]+>/.test(message.text ?? "");
@ -402,7 +405,7 @@ export async function prepareSlackMessage(params: {
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg); const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
const previousTimestamp = readSessionUpdatedAt({ const previousTimestamp = readSessionUpdatedAt({
storePath, storePath,
sessionKey: route.sessionKey, sessionKey,
}); });
const body = formatInboundEnvelope({ const body = formatInboundEnvelope({
channel: "Slack", channel: "Slack",