Add opt-in reaction tool tracking

This commit is contained in:
Peter Steinberger
2026-05-03 15:39:02 +01:00
committed by Peter Steinberger
parent c40f89414c
commit 788cff1df4
10 changed files with 168 additions and 22 deletions

View File

@@ -1,4 +1,4 @@
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
import { DEFAULT_EMOJIS, DEFAULT_TIMING } from "openclaw/plugin-sdk/channel-feedback";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
@@ -87,7 +87,11 @@ type DispatchInboundParams = {
replyOptions?: {
onReasoningStream?: () => Promise<void> | void;
onReasoningEnd?: () => Promise<void> | void;
onToolStart?: (payload: { name?: string }) => Promise<void> | void;
onToolStart?: (payload: {
name?: string;
phase?: string;
args?: Record<string, unknown>;
}) => Promise<void> | void;
onItemEvent?: (payload: {
progressText?: string;
summary?: string;
@@ -585,7 +589,7 @@ describe("processDiscordMessage ack reactions", () => {
it("debounces intermediate phase reactions and jumps to done for short runs", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onReasoningStream?.();
await params?.replyOptions?.onToolStart?.({ name: "exec" });
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return createNoQueuedDispatchResult();
});
@@ -600,6 +604,39 @@ describe("processDiscordMessage ack reactions", () => {
expect(emojis).not.toContain(DEFAULT_EMOJIS.coding);
});
it("can bind status reactions to an explicitly tracked reaction target", async () => {
vi.useFakeTimers();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({
name: "message",
phase: "start",
args: {
action: "react",
channelId: "c1",
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();
const calls = sendMocks.reactMessageDiscord.mock.calls as unknown as Array<
[string, string, string]
>;
expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", "📈"]));
expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", "✉️"]));
expect(calls).toContainEqual(expect.arrayContaining(["c1", "m1", DEFAULT_EMOJIS.done]));
});
it("shows stall emojis for long no-progress runs", async () => {
vi.useFakeTimers();
let releaseDispatch!: () => void;

View File

@@ -82,6 +82,21 @@ type DiscordMessageProcessObserver = {
onReplyPlanResolved?: (params: { createdThreadId?: string; sessionKey?: string }) => void;
};
type ToolStartPayload = {
name?: string;
phase?: string;
args?: Record<string, unknown>;
};
function readToolStringArg(args: Record<string, unknown>, key: string): string | undefined {
const value = args[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readToolBooleanArg(args: Record<string, unknown>, key: string): boolean {
return args[key] === true;
}
export async function processDiscordMessage(
ctx: DiscordMessagePreflightContext,
observer?: DiscordMessageProcessObserver,
@@ -203,7 +218,9 @@ export async function processDiscordMessage(
messageId: message.id,
reactionContext: ackReactionContext,
});
const statusReactions = createStatusReactionController({
let statusReactionTarget = `${messageChannelId}/${message.id}`;
let statusReactionsActive = statusReactionsEnabled;
let statusReactions = createStatusReactionController({
enabled: statusReactionsEnabled,
adapter: discordAdapter,
initialEmoji: ackReaction,
@@ -213,11 +230,67 @@ export async function processDiscordMessage(
logAckFailure({
log: logVerbose,
channel: "discord",
target: `${messageChannelId}/${message.id}`,
target: statusReactionTarget,
error: err,
});
},
});
const maybeBindStatusReactionsToToolReaction = (payload: ToolStartPayload) => {
if (
sourceRepliesAreToolOnly ||
cfg.messages?.statusReactions?.enabled === false ||
payload.phase !== "start" ||
payload.name !== "message" ||
!payload.args
) {
return;
}
const args = payload.args;
const action = readToolStringArg(args, "action")?.toLowerCase();
if (action !== "react") {
return;
}
const shouldTrack =
readToolBooleanArg(args, "trackToolCalls") || readToolBooleanArg(args, "track_tool_calls");
if (!shouldTrack) {
return;
}
const emoji = readToolStringArg(args, "emoji");
const remove = readToolBooleanArg(args, "remove");
if (!emoji || remove) {
return;
}
const trackedMessageId =
readToolStringArg(args, "messageId") ?? readToolStringArg(args, "message_id") ?? message.id;
const trackedChannelId =
readToolStringArg(args, "channelId") ?? readToolStringArg(args, "to") ?? messageChannelId;
statusReactionTarget = `${trackedChannelId}/${trackedMessageId}`;
if (statusReactionsActive) {
void statusReactions.clear();
}
const trackedAdapter = createDiscordAckReactionAdapter({
channelId: trackedChannelId,
messageId: trackedMessageId,
reactionContext: ackReactionContext,
});
statusReactions = createStatusReactionController({
enabled: true,
adapter: trackedAdapter,
initialEmoji: emoji,
emojis: cfg.messages?.statusReactions?.emojis,
timing: cfg.messages?.statusReactions?.timing,
onError: (err) => {
logAckFailure({
log: logVerbose,
channel: "discord",
target: statusReactionTarget,
error: err,
});
},
});
statusReactionsActive = true;
void statusReactions.setQueued();
};
queueInitialDiscordAckReaction({
enabled: statusReactionsEnabled,
shouldSendAckReaction,
@@ -546,6 +619,7 @@ export async function processDiscordMessage(
if (isProcessAborted(abortSignal)) {
return;
}
maybeBindStatusReactionsToToolReaction(payload);
await statusReactions.setTool(payload.name);
draftPreview.pushToolProgress(
payload.name ? `tool: ${payload.name}` : "tool running",
@@ -632,7 +706,7 @@ export async function processDiscordMessage(
markDispatchIdle();
}
}
if (statusReactionsEnabled) {
if (statusReactionsActive) {
if (dispatchAborted) {
if (removeAckAfterReply) {
void statusReactions.clear();