fix: keep slack status reactions in tool-only rooms

This commit is contained in:
Peter Steinberger
2026-05-02 05:08:50 +01:00
parent 3e2a2c7b74
commit 37a253834a
6 changed files with 124 additions and 14 deletions

View File

@@ -64,7 +64,7 @@ Docs: https://docs.openclaw.ai
- macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc.
- Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129.
- Slack/directory: make `openclaw directory peers/groups list --channel slack` prefer token-backed live readers and return the connected Slack account from `directory self`, so valid Slack tokens no longer produce empty directory CLI results. Fixes #50776. Thanks @pjaillon.
- Slack: keep the assistant typing status and temporary typing reaction active for group/channel turns that use message-tool-only visible replies, while still suppressing automatic source replies. Fixes #75877. Thanks @teosborne.
- Slack: keep assistant typing status, temporary typing reactions, and status reactions active for group/channel turns that use message-tool-only visible replies, while still suppressing automatic source replies. Fixes #75877. Thanks @teosborne.
- Slack: recover full inbound DM text from top-level rich-text blocks when Slack sends a shortened message preview, so long direct messages still reach the agent intact. Fixes #55358. Thanks @tonyjwinter.
- Replies: strip legacy `[TOOL_CALL]{tool => ..., args => ...}[/TOOL_CALL]` pseudo-call text from user-facing replies and flag it in tool-call diagnostics instead of showing raw tool syntax in channels. Fixes #63610. Thanks @canh0chua.
- WhatsApp: close long-lived web sockets through Baileys `end(error)` before falling back to raw websocket close, so listener teardown runs Baileys cleanup instead of leaving zombie sockets. Fixes #52442. Thanks @essendigitalgroup-cyber.

View File

@@ -695,6 +695,39 @@ describe("monitorSlackProvider tool results", () => {
});
});
it("keeps status reactions for mentioned message-tool-only channel turns", async () => {
replyMock.mockResolvedValue({ text: "quiet default reply" });
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
groupChat: { visibleReplies: "message_tool" },
statusReactions: {
enabled: true,
timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 },
},
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
mockGeneralChannelInfo();
await runMentionGatedChannelMessageAndFlush();
expect(replyMock).toHaveBeenCalledTimes(1);
expect(sendMock).not.toHaveBeenCalled();
expect(reactMock).toHaveBeenCalledWith({
channel: "C1",
timestamp: "456",
name: "eyes",
});
});
it("keeps the error reaction when dispatch fails before any reply is delivered", async () => {
replyMock.mockRejectedValue(new Error("boom"));
setMentionGatedAckConfig(true);

View File

@@ -32,6 +32,16 @@ class TestSlackStreamNotDeliveredError extends Error {
let mockedNativeStreaming = false;
let mockedBlockStreamingEnabled: boolean | undefined = false;
let capturedReplyOptions: { disableBlockStreaming?: boolean } | undefined;
let capturedStatusReactionOptions: { enabled?: boolean; initialEmoji?: string } | undefined;
const statusReactionControllerMock = {
setQueued: vi.fn(async () => {}),
setThinking: vi.fn(async () => {}),
setTool: vi.fn(async () => {}),
setError: vi.fn(async () => {}),
setDone: vi.fn(async () => {}),
clear: vi.fn(async () => {}),
restoreInitial: vi.fn(async () => {}),
};
let mockedReplyThreadTs: string | undefined = THREAD_TS;
let mockedReplyThreadTsSequence: Array<string | undefined> | undefined;
let capturedTyping:
@@ -87,6 +97,8 @@ function createPreparedSlackMessage(params?: {
status: string;
}) => Promise<void>;
typingReaction?: string;
ackReactionMessageTs?: string;
ackReactionPromise?: Promise<boolean> | null;
}) {
return {
ctx: {
@@ -136,7 +148,8 @@ function createPreparedSlackMessage(params?: {
historyKey: "history-key",
preview: "",
ackReactionValue: "eyes",
ackReactionPromise: null,
ackReactionMessageTs: params?.ackReactionMessageTs,
ackReactionPromise: params?.ackReactionPromise ?? null,
} as never;
}
@@ -149,15 +162,10 @@ vi.mock("openclaw/plugin-sdk/channel-feedback", () => ({
doneHoldMs: 0,
errorHoldMs: 0,
},
createStatusReactionController: () => ({
setQueued: async () => {},
setThinking: async () => {},
setTool: async () => {},
setError: async () => {},
setDone: async () => {},
clear: async () => {},
restoreInitial: async () => {},
}),
createStatusReactionController: (params: { enabled?: boolean; initialEmoji?: string }) => {
capturedStatusReactionOptions = params;
return statusReactionControllerMock;
},
logAckFailure: () => {},
logTypingFailure: () => {},
removeAckReactionAfterReply: () => {},
@@ -408,9 +416,13 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
stopSlackStreamMock.mockReset();
reactSlackMessageMock.mockReset();
removeSlackReactionMock.mockReset();
for (const value of Object.values(statusReactionControllerMock)) {
value.mockClear();
}
mockedNativeStreaming = false;
mockedBlockStreamingEnabled = false;
capturedReplyOptions = undefined;
capturedStatusReactionOptions = undefined;
capturedTyping = undefined;
mockedReplyThreadTs = THREAD_TS;
mockedReplyThreadTsSequence = undefined;
@@ -520,6 +532,32 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
);
});
it("keeps Slack status reactions when channel replies are message-tool-only", async () => {
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
cfg: {
messages: {
groupChat: { visibleReplies: "message_tool" },
statusReactions: { enabled: true },
},
},
ctxPayload: { ChatType: "channel" },
ackReactionMessageTs: "171234.111",
ackReactionPromise: Promise.resolve(true),
}),
);
expect(capturedReplyOptions?.disableBlockStreaming).toBe(true);
expect(capturedStatusReactionOptions).toEqual(
expect.objectContaining({
enabled: true,
initialEmoji: "eyes",
}),
);
expect(statusReactionControllerMock.setQueued).toHaveBeenCalledTimes(1);
expect(statusReactionControllerMock.setDone).toHaveBeenCalledTimes(1);
});
it("escapes Slack mrkdwn in tool progress preview labels", async () => {
const draftStream = createDraftStreamStub();
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);

View File

@@ -306,7 +306,6 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const incomingThreadTs = message.thread_ts;
let didSetStatus = false;
const statusReactionsEnabled =
!sourceRepliesAreToolOnly &&
Boolean(prepared.ackReactionPromise) &&
Boolean(reactionMessageTs) &&
cfg.messages?.statusReactions?.enabled !== false;

View File

@@ -399,6 +399,42 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(prepared?.ackReactionPromise).toBeNull();
});
it("primes Slack status reactions when channel replies are message-tool-only", async () => {
const slackCtx = createInboundSlackCtx({
cfg: {
messages: {
ackReaction: "eyes",
groupChat: { visibleReplies: "message_tool" },
statusReactions: { enabled: true },
},
channels: {
slack: {
enabled: true,
groupPolicy: "open",
replyToMode: "all",
},
},
} as OpenClawConfig,
replyToMode: "all",
});
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(slackCtx, defaultAccount, {
channel: "C123",
channel_type: "channel",
user: "U1",
text: "<@B1> hi",
ts: "1.000",
} as SlackMessageEvent);
expect(prepared).toBeTruthy();
expect(prepared?.ackReactionMessageTs).toBe("1.000");
expect(prepared?.ackReactionValue).toBe("eyes");
expect(prepared?.ackReactionPromise).toBeTruthy();
expect(await prepared!.ackReactionPromise).toBe(true);
});
it("includes forwarded shared attachment text in raw body", async () => {
const prepared = await prepareWithDefaultCtx(
createSlackMessage({

View File

@@ -563,7 +563,7 @@ export async function prepareSlackMessage(params: {
const sourceRepliesAreToolOnly =
resolveChannelSourceReplyDeliveryMode({ cfg, ctx: { ChatType: chatType } }) ===
"message_tool_only";
const statusReactionsExplicitlyEnabled = cfg.messages?.statusReactions?.enabled === true;
const shouldAckReaction = () =>
Boolean(
ackReaction &&
@@ -580,7 +580,11 @@ export async function prepareSlackMessage(params: {
);
const ackReactionMessageTs = message.ts;
const shouldSendAckReaction = !sourceRepliesAreToolOnly && shouldAckReaction();
const allowToolOnlyStatusReaction =
statusReactionsExplicitlyEnabled &&
(effectiveWasMentioned || mentionDecision.shouldBypassMention === true);
const shouldSendAckReaction =
shouldAckReaction() && (!sourceRepliesAreToolOnly || allowToolOnlyStatusReaction);
const statusReactionsWillHandle =
Boolean(ackReactionMessageTs) &&
cfg.messages?.statusReactions?.enabled !== false &&