fix(slack): keep typing indicators for message-tool replies

This commit is contained in:
Peter Steinberger
2026-05-02 02:55:51 +01:00
parent 01aea41c2b
commit a08f6ebdda
3 changed files with 144 additions and 64 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570.
- 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: 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: 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

@@ -17,6 +17,8 @@ const startSlackStreamMock = vi.fn(async () => ({
pendingText: "",
}));
const stopSlackStreamMock = vi.fn(async () => {});
const reactSlackMessageMock = vi.fn(async () => {});
const removeSlackReactionMock = vi.fn(async () => {});
class TestSlackStreamNotDeliveredError extends Error {
readonly pendingText: string;
readonly slackCode: string;
@@ -32,6 +34,14 @@ let mockedBlockStreamingEnabled: boolean | undefined = false;
let capturedReplyOptions: { disableBlockStreaming?: boolean } | undefined;
let mockedReplyThreadTs: string | undefined = THREAD_TS;
let mockedReplyThreadTsSequence: Array<string | undefined> | undefined;
let capturedTyping:
| {
start: () => Promise<void>;
stop?: () => Promise<void>;
onStartError: (err: unknown) => void;
onStopError?: (err: unknown) => void;
}
| undefined;
let mockedDispatchSequence: Array<{
kind: "tool" | "block" | "final";
payload: {
@@ -62,6 +72,8 @@ function createDraftStreamStub() {
}
function createPreparedSlackMessage(params?: {
cfg?: Record<string, unknown>;
ctxPayload?: Record<string, unknown>;
message?: Partial<{
channel: string;
ts: string;
@@ -69,21 +81,27 @@ function createPreparedSlackMessage(params?: {
user: string;
}>;
replyToMode?: "off" | "first" | "all" | "batched";
setSlackThreadStatus?: (params: {
channelId: string;
threadTs?: string;
status: string;
}) => Promise<void>;
typingReaction?: string;
}) {
return {
ctx: {
cfg: {},
cfg: params?.cfg ?? {},
runtime: {},
botToken: "xoxb-test",
app: { client: { chat: { postMessage: postMessageMock } } },
teamId: "T1",
textLimit: 4000,
typingReaction: "",
typingReaction: params?.typingReaction ?? "",
removeAckAfterReply: false,
historyLimit: 0,
channelHistories: new Map(),
allowFrom: [],
setSlackThreadStatus: async () => undefined,
setSlackThreadStatus: params?.setSlackThreadStatus ?? (async () => undefined),
},
account: {
accountId: "default",
@@ -106,6 +124,7 @@ function createPreparedSlackMessage(params?: {
replyTarget: "channel:C123",
ctxPayload: {
MessageThreadId: THREAD_TS,
...params?.ctxPayload,
},
turn: {
storePath: "/tmp/slack-sessions.json",
@@ -149,12 +168,29 @@ vi.mock("../conversation.runtime.js", () => ({
}));
vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", () => ({
createChannelReplyPipeline: () => ({
typingCallbacks: {
onIdle: vi.fn(),
},
onModelSelected: undefined,
}),
createChannelReplyPipeline: (params: {
typing?: {
start: () => Promise<void>;
stop?: () => Promise<void>;
onStartError: (err: unknown) => void;
onStopError?: (err: unknown) => void;
};
}) => {
capturedTyping = params.typing;
return {
...(params.typing
? {
typingCallbacks: {
onReplyStart: params.typing.start,
onIdle: () => {
void params.typing?.stop?.();
},
},
}
: {}),
onModelSelected: undefined,
};
},
resolveChannelSourceReplyDeliveryMode: (params: {
cfg?: { messages?: { groupChat?: { visibleReplies?: string } } };
ctx?: { ChatType?: string };
@@ -220,8 +256,8 @@ vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
}));
vi.mock("../../actions.js", () => ({
reactSlackMessage: async () => {},
removeSlackReaction: async () => {},
reactSlackMessage: reactSlackMessageMock,
removeSlackReaction: removeSlackReactionMock,
}));
vi.mock("../../draft-stream.js", () => ({
@@ -370,9 +406,12 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
appendSlackStreamMock.mockReset();
startSlackStreamMock.mockReset();
stopSlackStreamMock.mockReset();
reactSlackMessageMock.mockReset();
removeSlackReactionMock.mockReset();
mockedNativeStreaming = false;
mockedBlockStreamingEnabled = false;
capturedReplyOptions = undefined;
capturedTyping = undefined;
mockedReplyThreadTs = THREAD_TS;
mockedReplyThreadTsSequence = undefined;
mockedDispatchSequence = [{ kind: "final", payload: { text: FINAL_REPLY_TEXT } }];
@@ -439,6 +478,48 @@ describe("dispatchPreparedSlackMessage preview fallback", () => {
expect(capturedReplyOptions?.disableBlockStreaming).toBe(true);
});
it("keeps Slack typing callbacks when channel replies are message-tool-only", async () => {
const setSlackThreadStatus = vi.fn(async () => undefined);
await dispatchPreparedSlackMessage(
createPreparedSlackMessage({
cfg: { messages: { groupChat: { visibleReplies: "message_tool" } } },
ctxPayload: { ChatType: "channel" },
setSlackThreadStatus,
typingReaction: "hourglass_flowing_sand",
}),
);
expect(capturedTyping).toBeDefined();
expect(capturedReplyOptions?.disableBlockStreaming).toBe(true);
await capturedTyping?.start();
await capturedTyping?.stop?.();
expect(setSlackThreadStatus).toHaveBeenCalledWith({
channelId: "C123",
threadTs: THREAD_TS,
status: "is typing...",
});
expect(setSlackThreadStatus).toHaveBeenCalledWith({
channelId: "C123",
threadTs: THREAD_TS,
status: "",
});
expect(reactSlackMessageMock).toHaveBeenCalledWith(
"C123",
"171234.111",
"hourglass_flowing_sand",
expect.objectContaining({ token: "xoxb-test" }),
);
expect(removeSlackReactionMock).toHaveBeenCalledWith(
"C123",
"171234.111",
"hourglass_flowing_sand",
expect.objectContaining({ token: "xoxb-test" }),
);
});
it("escapes Slack mrkdwn in tool progress preview labels", async () => {
const draftStream = createDraftStreamStub();
createSlackDraftStreamMock.mockReturnValueOnce(draftStream);

View File

@@ -380,59 +380,57 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
isSlackInteractiveRepliesEnabled({ cfg, accountId: route.accountId })
? compileSlackInteractiveReplies(payload)
: payload,
typing: sourceRepliesAreToolOnly
? undefined
: {
start: async () => {
didSetStatus = true;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "is typing...",
});
if (typingReaction && message.ts) {
await reactSlackMessage(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: ctx.app.client,
}).catch(() => {});
}
},
stop: async () => {
if (!didSetStatus) {
return;
}
didSetStatus = false;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "",
});
if (typingReaction && message.ts) {
await removeSlackReaction(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: ctx.app.client,
}).catch(() => {});
}
},
onStartError: (err) => {
logTypingFailure({
log: (message) => runtime.error?.(danger(message)),
channel: "slack",
action: "start",
target: typingTarget,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: (message) => runtime.error?.(danger(message)),
channel: "slack",
action: "stop",
target: typingTarget,
error: err,
});
},
},
typing: {
start: async () => {
didSetStatus = true;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "is typing...",
});
if (typingReaction && message.ts) {
await reactSlackMessage(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: ctx.app.client,
}).catch(() => {});
}
},
stop: async () => {
if (!didSetStatus) {
return;
}
didSetStatus = false;
await ctx.setSlackThreadStatus({
channelId: message.channel,
threadTs: statusThreadTs,
status: "",
});
if (typingReaction && message.ts) {
await removeSlackReaction(message.channel, message.ts, typingReaction, {
token: ctx.botToken,
client: ctx.app.client,
}).catch(() => {});
}
},
onStartError: (err) => {
logTypingFailure({
log: (message) => runtime.error?.(danger(message)),
channel: "slack",
action: "start",
target: typingTarget,
error: err,
});
},
onStopError: (err) => {
logTypingFailure({
log: (message) => runtime.error?.(danger(message)),
channel: "slack",
action: "stop",
target: typingTarget,
error: err,
});
},
},
});
const slackStreaming = resolveSlackStreamingConfig({