mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:30:43 +00:00
fix(slack): keep typing indicators for message-tool replies
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user