fix(discord): preserve tracked reaction targets

This commit is contained in:
Peter Steinberger
2026-05-03 17:21:50 +01:00
parent 788cff1df4
commit 5039a35a33
4 changed files with 118 additions and 7 deletions

View File

@@ -66,6 +66,17 @@ vi.mock("../send.js", () => ({
},
}));
const discordTargetMocks = vi.hoisted(() => ({
resolveDiscordTargetChannelId: vi.fn(async (target: string) => ({
channelId: target === "user:u1" ? "dm-u1" : target,
})),
}));
vi.mock("../send.shared.js", () => ({
resolveDiscordTargetChannelId: (target: string, opts: unknown) =>
discordTargetMocks.resolveDiscordTargetChannelId(target, opts),
}));
vi.mock("../send.messages.js", () => ({
editMessageDiscord: (channelId: string, messageId: string, payload: unknown, opts?: unknown) =>
deliveryMocks.editMessageDiscord(channelId, messageId, payload, opts),
@@ -315,6 +326,7 @@ beforeEach(() => {
vi.useRealTimers();
sendMocks.reactMessageDiscord.mockClear();
sendMocks.removeReactionDiscord.mockClear();
discordTargetMocks.resolveDiscordTargetChannelId.mockClear();
editMessageDiscord.mockClear();
deliverDiscordReply.mockClear();
createDiscordDraftStream.mockClear();
@@ -637,6 +649,43 @@ describe("processDiscordMessage ack reactions", () => {
expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", DEFAULT_EMOJIS.done]));
});
it("resolves tracked reaction to targets like the Discord reaction action", async () => {
vi.useFakeTimers();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({
name: "message",
phase: "start",
args: {
action: "react",
to: "user:u1",
messageId: "m1",
emoji: "📈",
trackToolCalls: true,
},
});
await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs);
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
cfg: { messages: { ackReaction: "👀" } },
});
await runProcessDiscordMessage(ctx);
await vi.runAllTimersAsync();
expect(discordTargetMocks.resolveDiscordTargetChannelId).toHaveBeenCalledWith(
"user:u1",
expect.objectContaining({ accountId: "default" }),
);
const calls = sendMocks.reactMessageDiscord.mock.calls as unknown as Array<
[string, string, string]
>;
expect(calls).toContainEqual(expect.arrayContaining(["dm-u1", "m1", "📈"]));
expect(calls).toContainEqual(expect.arrayContaining(["dm-u1", "m1", "✉️"]));
expect(calls).toContainEqual(expect.arrayContaining(["dm-u1", "m1", DEFAULT_EMOJIS.done]));
});
it("shows stall emojis for long no-progress runs", async () => {
vi.useFakeTimers();
let releaseDispatch!: () => void;

View File

@@ -27,6 +27,8 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { createDiscordRestClient } from "../client.js";
import { removeReactionDiscord } from "../send.js";
import { editMessageDiscord } from "../send.messages.js";
import { resolveDiscordTargetChannelId } from "../send.shared.js";
import { resolveDiscordChannelId } from "../targets.js";
import {
createDiscordAckReactionAdapter,
createDiscordAckReactionContext,
@@ -235,7 +237,29 @@ export async function processDiscordMessage(
});
},
});
const maybeBindStatusReactionsToToolReaction = (payload: ToolStartPayload) => {
const resolveTrackedReactionChannelId = async (
args: Record<string, unknown>,
): Promise<string> => {
const target =
readToolStringArg(args, "channelId") ??
readToolStringArg(args, "channel_id") ??
readToolStringArg(args, "to");
if (!target) {
return messageChannelId;
}
try {
return resolveDiscordChannelId(target);
} catch {
return (
await resolveDiscordTargetChannelId(target, {
cfg,
token,
accountId,
})
).channelId;
}
};
const maybeBindStatusReactionsToToolReaction = async (payload: ToolStartPayload) => {
if (
sourceRepliesAreToolOnly ||
cfg.messages?.statusReactions?.enabled === false ||
@@ -262,8 +286,18 @@ export async function processDiscordMessage(
}
const trackedMessageId =
readToolStringArg(args, "messageId") ?? readToolStringArg(args, "message_id") ?? message.id;
const trackedChannelId =
readToolStringArg(args, "channelId") ?? readToolStringArg(args, "to") ?? messageChannelId;
let trackedChannelId: string;
try {
trackedChannelId = await resolveTrackedReactionChannelId(args);
} catch (err) {
logAckFailure({
log: logVerbose,
channel: "discord",
target: `${readToolStringArg(args, "to") ?? readToolStringArg(args, "channelId") ?? messageChannelId}/${trackedMessageId}`,
error: err,
});
return;
}
statusReactionTarget = `${trackedChannelId}/${trackedMessageId}`;
if (statusReactionsActive) {
void statusReactions.clear();
@@ -619,7 +653,7 @@ export async function processDiscordMessage(
if (isProcessAborted(abortSignal)) {
return;
}
maybeBindStatusReactionsToToolReaction(payload);
await maybeBindStatusReactionsToToolReaction(payload);
await statusReactions.setTool(payload.name);
draftPreview.pushToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",

View File

@@ -110,6 +110,24 @@ describe("resolveToolEmoji", () => {
expect(resolveToolEmoji(tool, DEFAULT_EMOJIS)).toBe(expected);
},
);
it("preserves explicit status emoji overrides before exact tool display emojis", () => {
const emojis = {
...DEFAULT_EMOJIS,
coding: "🧪",
web: "🛰️",
tool: "🔧",
};
const overrides = {
coding: "🧪",
web: "🛰️",
tool: "🔧",
};
expect(resolveToolEmoji("exec", emojis, overrides)).toBe("🧪");
expect(resolveToolEmoji("web_search", emojis, overrides)).toBe("🛰️");
expect(resolveToolEmoji("message", emojis, overrides)).toBe("🔧");
});
});
describe("createStatusReactionController", () => {

View File

@@ -105,18 +105,28 @@ export const WEB_TOOL_TOKENS: string[] = [
export function resolveToolEmoji(
toolName: string | undefined,
emojis: Required<StatusReactionEmojis>,
emojiOverrides?: StatusReactionEmojis,
): string {
const normalized = normalizeOptionalLowercaseString(toolName) ?? "";
if (!normalized) {
return emojis.tool;
}
const category = WEB_TOOL_TOKENS.some((token) => normalized.includes(token))
? "web"
: CODING_TOOL_TOKENS.some((token) => normalized.includes(token))
? "coding"
: "tool";
if (emojiOverrides?.[category] !== undefined) {
return emojis[category];
}
if (Object.hasOwn(TOOL_DISPLAY_CONFIG.tools, normalized)) {
return resolveToolDisplay({ name: toolName }).emoji;
}
if (WEB_TOOL_TOKENS.some((token) => normalized.includes(token))) {
if (category === "web") {
return emojis.web;
}
if (CODING_TOOL_TOKENS.some((token) => normalized.includes(token))) {
if (category === "coding") {
return emojis.coding;
}
return emojis.tool;
@@ -322,7 +332,7 @@ export function createStatusReactionController(params: {
}
function setTool(toolName?: string): void {
const emoji = resolveToolEmoji(toolName, emojis);
const emoji = resolveToolEmoji(toolName, emojis, params.emojis);
scheduleEmoji(emoji);
}