fix(slack): preserve ack on pre-reply errors

This commit is contained in:
Frank Yang
2026-03-29 15:53:56 +08:00
parent 0c6ca907d1
commit c50503a37f
2 changed files with 62 additions and 2 deletions

View File

@@ -648,6 +648,57 @@ describe("monitorSlackProvider tool results", () => {
});
});
it("restores ack reaction when dispatch fails before any reply is delivered", async () => {
replyMock.mockRejectedValue(new Error("boom"));
slackTestState.config = {
messages: {
responsePrefix: "PFX",
ackReaction: "👀",
ackReactionScope: "group-mentions",
removeAckAfterReply: true,
statusReactions: {
enabled: true,
timing: { debounceMs: 0, doneHoldMs: 0, errorHoldMs: 0 },
},
},
channels: {
slack: {
dm: { enabled: true, policy: "open", allowFrom: ["*"] },
groupPolicy: "open",
},
},
};
const client = getSlackClient();
if (!client) {
throw new Error("Slack client not registered");
}
const conversations = client.conversations as {
info: ReturnType<typeof vi.fn>;
};
conversations.info.mockResolvedValueOnce({
channel: { name: "general", is_channel: true },
});
await runSlackMessageOnce(monitorSlackProvider, {
event: makeSlackMessageEvent({
text: "<@bot-user> hello",
ts: "456",
channel_type: "channel",
}),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await flush();
expect(sendMock).not.toHaveBeenCalled();
expect(reactMock.mock.calls.map(([args]) => String((args as { name: string }).name))).toEqual([
"eyes",
"scream",
"eyes",
"eyes",
"scream",
]);
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
setPairingOnlyDirectMessages();

View File

@@ -348,6 +348,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
let streamSession: SlackStreamSession | null = null;
let streamFailed = false;
let usedReplyThreadTs: string | undefined;
let observedReplyDelivery = false;
const deliverNormally = async (payload: ReplyPayload, forcedThreadTs?: string): Promise<void> => {
const replyThreadTs = forcedThreadTs ?? replyPlan.nextThreadTs();
@@ -362,6 +363,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
replyToMode: prepared.replyToMode,
...(slackIdentity ? { identity: slackIdentity } : {}),
});
observedReplyDelivery = true;
// Record the thread ts only after confirmed delivery success.
if (replyThreadTs) {
usedReplyThreadTs ??= replyThreadTs;
@@ -399,6 +401,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
teamId: ctx.teamId,
userId: message.user,
});
observedReplyDelivery = true;
usedReplyThreadTs ??= streamThreadTs;
replyPlan.markSent();
return;
@@ -455,6 +458,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
...(slackBlocks?.length ? { blocks: slackBlocks } : {}),
},
);
observedReplyDelivery = true;
return;
} catch (err) {
logVerbose(
@@ -620,7 +624,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
}
}
const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
const anyReplyDelivered =
observedReplyDelivery || queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
if (statusReactionsEnabled) {
if (dispatchError) {
@@ -628,7 +633,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
if (ctx.removeAckAfterReply) {
void (async () => {
await sleep(statusReactionTiming.errorHoldMs);
await statusReactions.clear();
if (anyReplyDelivered) {
await statusReactions.clear();
return;
}
await statusReactions.restoreInitial();
})();
} else {
void statusReactions.restoreInitial();