From d6c2280aab9141ea3958efa5fbd478ce27bd3dd1 Mon Sep 17 00:00:00 2001 From: Bek <66288351+bek91@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:02:18 -0400 Subject: [PATCH] fix(slack): normalize action thread targets (#73931) --- extensions/slack/src/action-runtime.test.ts | 18 +++++++++++ extensions/slack/src/action-runtime.ts | 30 +++++++++++-------- extensions/slack/src/action-threading.test.ts | 15 ++++++++++ 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index 0fe99263dcf..0f4ae86f3f0 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -515,6 +515,24 @@ describe("handleSlackAction", () => { await sendSecondMessageAndExpectNoThread({ cfg, context }); }); + it("replyToMode=first normalizes channel target when accounting explicit threadTs", async () => { + const { cfg, context, hasRepliedRef } = createReplyToFirstScenario(); + + await handleSlackAction( + { + action: "sendMessage", + to: "#c123", + content: "Explicit", + threadTs: "9999999999.999999", + }, + cfg, + context, + ); + + expect(hasRepliedRef.value).toBe(true); + await sendSecondMessageAndExpectNoThread({ cfg, context }); + }); + it("replyToMode=first marks hasRepliedRef even when threadTs is explicit", async () => { const { cfg, context, hasRepliedRef } = createReplyToFirstScenario(); diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index 5ce3af67ee7..991ad245ce8 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -1,5 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { createActionGate, @@ -26,6 +27,19 @@ const messagingActions = new Set([ const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); +function sameSlackChannelTarget(targetChannel: string, currentChannelId: string): boolean { + const parsedTarget = parseSlackTarget(targetChannel, { + defaultKind: "channel", + }); + if (!parsedTarget || parsedTarget.kind !== "channel") { + return false; + } + return ( + normalizeLowercaseStringOrEmpty(parsedTarget.id) === + normalizeLowercaseStringOrEmpty(currentChannelId) + ); +} + type SlackActionsRuntimeModule = typeof import("./actions.runtime.js"); type SlackAccountsRuntimeModule = typeof import("./accounts.runtime.js"); @@ -105,16 +119,8 @@ function resolveThreadTsFromContext( return undefined; } - const parsedTarget = parseSlackTarget(targetChannel, { - defaultKind: "channel", - }); - if (!parsedTarget || parsedTarget.kind !== "channel") { - return undefined; - } - const normalizedTarget = parsedTarget.id; - // Different channel - don't inject - if (normalizedTarget !== context.currentChannelId) { + if (!sameSlackChannelTarget(targetChannel, context.currentChannelId)) { return undefined; } @@ -267,8 +273,7 @@ export async function handleSlackAction( // threadTs: once we send a message to the current channel, consider the // first reply "used" so later tool calls don't auto-thread again. if (context?.hasRepliedRef && context.currentChannelId) { - const parsedTarget = parseSlackTarget(to, { defaultKind: "channel" }); - if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) { + if (sameSlackChannelTarget(to, context.currentChannelId)) { context.hasRepliedRef.value = true; } } @@ -310,8 +315,7 @@ export async function handleSlackAction( } if (context?.hasRepliedRef && context.currentChannelId) { - const parsedTarget = parseSlackTarget(to, { defaultKind: "channel" }); - if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) { + if (sameSlackChannelTarget(to, context.currentChannelId)) { context.hasRepliedRef.value = true; } } diff --git a/extensions/slack/src/action-threading.test.ts b/extensions/slack/src/action-threading.test.ts index 995f0564128..b486118003d 100644 --- a/extensions/slack/src/action-threading.test.ts +++ b/extensions/slack/src/action-threading.test.ts @@ -41,6 +41,21 @@ describe("resolveSlackAutoThreadId", () => { ).toBeUndefined(); }); + it("threads first matching prefixed channel target with bare current channel", () => { + const hasRepliedRef = { value: false }; + + expect( + resolveSlackAutoThreadId({ + to: "channel:C123", + toolContext: createToolContext({ + replyToMode: "first", + hasRepliedRef, + }), + }), + ).toBe("thread-1"); + expect(hasRepliedRef.value).toBe(false); + }); + it("skips auto-threading when reply mode or thread context blocks it", () => { expect( resolveSlackAutoThreadId({