fix(slack): keep status ack on silent runs

This commit is contained in:
Frank Yang
2026-03-29 11:03:54 +08:00
parent 9ac8e35c4b
commit b21618389b
2 changed files with 87 additions and 21 deletions

View File

@@ -598,6 +598,56 @@ describe("monitorSlackProvider tool results", () => {
});
});
it("keeps ack reaction when no reply is delivered and status reactions are enabled", async () => {
replyMock.mockResolvedValue(undefined);
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).toHaveBeenCalledTimes(1);
expect(reactMock).toHaveBeenCalledWith({
channel: "C1",
timestamp: "456",
name: "eyes",
});
});
it("replies with pairing code when dmPolicy is pairing and no allowFrom is set", async () => {
setPairingOnlyDirectMessages();

View File

@@ -559,7 +559,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
}
};
let dispatchError = false;
let dispatchError: unknown;
let didAdvanceStatusReaction = false;
let queuedFinal = false;
let counts: { final?: number; block?: number } = {};
try {
@@ -588,11 +589,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
onReasoningEnd: onDraftBoundary,
onReasoningStream: statusReactionsEnabled
? async () => {
didAdvanceStatusReaction = true;
await statusReactions.setThinking();
}
: undefined,
onToolStart: statusReactionsEnabled
? async (payload) => {
didAdvanceStatusReaction = true;
await statusReactions.setTool(payload.name);
}
: undefined,
@@ -601,30 +604,11 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
queuedFinal = result.queuedFinal;
counts = result.counts;
} catch (err) {
dispatchError = true;
throw err;
dispatchError = err;
} finally {
await draftStream?.flush();
draftStream?.stop();
markDispatchIdle();
if (statusReactionsEnabled) {
if (dispatchError) {
await statusReactions.setError();
} else {
await statusReactions.setDone();
}
if (ctx.removeAckAfterReply) {
void (async () => {
await sleep(
dispatchError ? statusReactionTiming.errorHoldMs : statusReactionTiming.doneHoldMs,
);
await statusReactions.clear();
})();
} else {
void statusReactions.restoreInitial();
}
}
}
// -----------------------------------------------------------------------
@@ -641,6 +625,38 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
if (statusReactionsEnabled) {
if (dispatchError) {
await statusReactions.setError();
if (ctx.removeAckAfterReply) {
void (async () => {
await sleep(statusReactionTiming.errorHoldMs);
await statusReactions.clear();
})();
} else {
void statusReactions.restoreInitial();
}
} else if (anyReplyDelivered) {
await statusReactions.setDone();
if (ctx.removeAckAfterReply) {
void (async () => {
await sleep(statusReactionTiming.doneHoldMs);
await statusReactions.clear();
})();
} else {
void statusReactions.restoreInitial();
}
} else if (didAdvanceStatusReaction) {
// Silent success should preserve the original ack instead of looking like
// we delivered a visible reply.
await statusReactions.restoreInitial();
}
}
if (dispatchError) {
throw dispatchError;
}
// Record thread participation only when we actually delivered a reply and
// know the thread ts that was used (set by deliverNormally, streaming start,
// or draft stream). Falls back to statusThreadTs for edge cases.