From 788cff1df4bd92a8854806ab1d3f86cb3d4226e6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 3 May 2026 15:39:02 +0100 Subject: [PATCH] Add opt-in reaction tool tracking --- CHANGELOG.md | 1 + docs/tools/reactions.md | 3 + .../monitor/message-handler.process.test.ts | 43 +++++++++- .../src/monitor/message-handler.process.ts | 80 ++++++++++++++++++- .../pi-embedded-subscribe.handlers.tools.ts | 7 +- src/agents/tools/message-tool.ts | 11 +++ src/auto-reply/get-reply-options.types.ts | 6 +- .../reply/agent-runner-execution.ts | 9 ++- src/channels/status-reactions.test.ts | 25 +++--- src/channels/status-reactions.ts | 5 ++ 10 files changed, 168 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db97a4fa42..52cfd5aff43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Changes - Agents/tools: skip optional media and PDF tool factories when the effective tool denylist already blocks them, avoiding unnecessary hot-path setup for tools that will be filtered out before model use. (#76773) Thanks @dorukardahan. +- Discord/status: let explicit reaction tool calls opt into tracking subsequent tool progress on the reacted message with `trackToolCalls: true`, and use the shared tool display emoji table for status reactions. - Gateway/performance: lazy-load early runtime discovery and shutdown-hook helpers, defer maintenance timers until after readiness, and trim duplicate plugin auto-enable work during Gateway startup. - QA/Mantis: add a `pnpm openclaw qa mantis discord-smoke` runner and manual GitHub workflow that verify the Mantis Discord bot can see the configured guild/channel, post a smoke message, add a reaction, and upload artifacts. - Gateway/performance: lazy-load the heavy cron runtime after the rest of Gateway startup, defer restart-sentinel refresh after readiness, and let the Gateway startup benchmark write per-run V8 CPU profiles with `--cpu-prof-dir`. diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index 10896c1ac4d..fcbd10586f3 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -22,6 +22,9 @@ tool with the `react` action. Reaction behavior varies by channel and transport. - `emoji` is required when adding a reaction. - Set `emoji` to an empty string (`""`) to remove the bot's reaction(s). - Set `remove: true` to remove a specific emoji (requires non-empty `emoji`). +- On channels that support status reactions, `trackToolCalls: true` on a + reaction lets the runtime use that reacted message for subsequent tool + progress reactions during the same turn. ## Channel behavior diff --git a/extensions/discord/src/monitor/message-handler.process.test.ts b/extensions/discord/src/monitor/message-handler.process.test.ts index c47509a23a2..96d1cc371c8 100644 --- a/extensions/discord/src/monitor/message-handler.process.test.ts +++ b/extensions/discord/src/monitor/message-handler.process.test.ts @@ -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; onReasoningEnd?: () => Promise | void; - onToolStart?: (payload: { name?: string }) => Promise | void; + onToolStart?: (payload: { + name?: string; + phase?: string; + args?: Record; + }) => Promise | 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; diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index a2a96423163..a08e6cda2dc 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -82,6 +82,21 @@ type DiscordMessageProcessObserver = { onReplyPlanResolved?: (params: { createdThreadId?: string; sessionKey?: string }) => void; }; +type ToolStartPayload = { + name?: string; + phase?: string; + args?: Record; +}; + +function readToolStringArg(args: Record, key: string): string | undefined { + const value = args[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readToolBooleanArg(args: Record, 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(); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 5996d17d7ac..9b188741e77 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -649,7 +649,12 @@ export function handleToolExecutionStart( // Best-effort typing signal; do not block tool summaries on slow emitters. void ctx.params.onAgentEvent?.({ stream: "tool", - data: { phase: "start", name: toolName, toolCallId }, + data: { + phase: "start", + name: toolName, + toolCallId, + args: sanitizeToolArgs(args) as Record, + }, }); if (isExecToolName(toolName)) { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index c0e843c5dcb..74a07d0812d 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -180,6 +180,17 @@ function buildReactionSchema() { ), emoji: Type.Optional(Type.String()), remove: Type.Optional(Type.Boolean()), + trackToolCalls: Type.Optional( + Type.Boolean({ + description: + "When true for a reaction to the current inbound message, use that reacted message as the status-reaction target for subsequent tool progress when the channel supports it.", + }), + ), + track_tool_calls: Type.Optional( + Type.Boolean({ + description: "snake_case alias of trackToolCalls.", + }), + ), targetAuthor: Type.Optional(Type.String()), targetAuthorUuid: Type.Optional(Type.String()), groupId: Type.Optional(Type.String()), diff --git a/src/auto-reply/get-reply-options.types.ts b/src/auto-reply/get-reply-options.types.ts index c7261447b80..2d482df1150 100644 --- a/src/auto-reply/get-reply-options.types.ts +++ b/src/auto-reply/get-reply-options.types.ts @@ -81,7 +81,11 @@ export type GetReplyOptions = { onBlockReply?: (payload: ReplyPayload, context?: BlockReplyContext) => Promise | void; onToolResult?: (payload: ReplyPayload) => Promise | void; /** Called when a tool phase starts/updates, before summary payloads are emitted. */ - onToolStart?: (payload: { name?: string; phase?: string }) => Promise | void; + onToolStart?: (payload: { + name?: string; + phase?: string; + args?: Record; + }) => Promise | void; /** Called when a concrete work item starts, updates, or completes. */ onItemEvent?: (payload: { itemId?: string; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 0fab8d91dbc..c865aa1a1a9 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -1528,7 +1528,14 @@ export async function runAgentTurnWithFallback(params: { const name = readStringValue(evt.data.name); if (phase === "start" || phase === "update") { await params.typingSignals.signalToolStart(); - await params.opts?.onToolStart?.({ name, phase }); + await params.opts?.onToolStart?.({ + name, + phase, + args: + evt.data.args && typeof evt.data.args === "object" + ? (evt.data.args as Record) + : undefined, + }); } } if (evt.stream === "item") { diff --git a/src/channels/status-reactions.test.ts b/src/channels/status-reactions.test.ts index 9b4ffd08fba..9907f5bf972 100644 --- a/src/channels/status-reactions.test.ts +++ b/src/channels/status-reactions.test.ts @@ -78,18 +78,19 @@ function expectObjectHasKeys(value: Record, keys: readonly stri describe("resolveToolEmoji", () => { it.each([ - { name: "returns coding emoji for exec tool", tool: "exec", expected: DEFAULT_EMOJIS.coding }, + { name: "returns display emoji for exec tool", tool: "exec", expected: "🛠️" }, { - name: "returns coding emoji for process tool", + name: "returns display emoji for process tool", tool: "process", - expected: DEFAULT_EMOJIS.coding, + expected: "🧰", }, { - name: "returns web emoji for web_search tool", + name: "returns display emoji for web_search tool", tool: "web_search", - expected: DEFAULT_EMOJIS.web, + expected: "🔎", }, - { name: "returns web emoji for browser tool", tool: "browser", expected: DEFAULT_EMOJIS.web }, + { name: "returns display emoji for browser tool", tool: "browser", expected: "🌐" }, + { name: "returns display emoji for message tool", tool: "message", expected: "✉️" }, { name: "returns tool emoji for unknown tool", tool: "unknown_tool", @@ -97,7 +98,7 @@ describe("resolveToolEmoji", () => { }, { name: "returns tool emoji for empty string", tool: "", expected: DEFAULT_EMOJIS.tool }, { name: "returns tool emoji for undefined", tool: undefined, expected: DEFAULT_EMOJIS.tool }, - { name: "is case-insensitive", tool: "EXEC", expected: DEFAULT_EMOJIS.coding }, + { name: "is case-insensitive", tool: "EXEC", expected: "🛠️" }, { name: "matches tokens within tool names", tool: "my_exec_wrapper", @@ -174,7 +175,7 @@ describe("createStatusReactionController", () => { void controller.setTool("exec"); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); - expectSetEmojiCall(calls, DEFAULT_EMOJIS.coding); + expectSetEmojiCall(calls, "🛠️"); }); const immediateTerminalCases = [ @@ -244,9 +245,9 @@ describe("createStatusReactionController", () => { void controller.setTool("exec"); await vi.advanceTimersByTimeAsync(DEFAULT_TIMING.debounceMs); - // Should only have the last one (exec → coding) + // Should only have the last one (exec → display emoji) const setEmojis = calls.filter((c) => c.method === "set").map((c) => c.emoji); - expect(setEmojis).toEqual([DEFAULT_EMOJIS.coding]); + expect(setEmojis).toEqual(["🛠️"]); }); it("should deduplicate same emoji calls", async () => { @@ -307,9 +308,7 @@ describe("createStatusReactionController", () => { await controller.setDone(); const removeEmojis = calls.filter((call) => call.method === "remove").map((call) => call.emoji); - expect(removeEmojis).toEqual( - expect.arrayContaining(["👀", DEFAULT_EMOJIS.thinking, DEFAULT_EMOJIS.coding]), - ); + expect(removeEmojis).toEqual(expect.arrayContaining(["👀", DEFAULT_EMOJIS.thinking, "🛠️"])); expect(removeEmojis).not.toContain(DEFAULT_EMOJIS.done); }); diff --git a/src/channels/status-reactions.ts b/src/channels/status-reactions.ts index d392ec109bf..92bdeff7850 100644 --- a/src/channels/status-reactions.ts +++ b/src/channels/status-reactions.ts @@ -1,3 +1,5 @@ +import { TOOL_DISPLAY_CONFIG } from "../agents/tool-display-config.js"; +import { resolveToolDisplay } from "../agents/tool-display.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; /** @@ -108,6 +110,9 @@ export function resolveToolEmoji( if (!normalized) { return emojis.tool; } + if (Object.hasOwn(TOOL_DISPLAY_CONFIG.tools, normalized)) { + return resolveToolDisplay({ name: toolName }).emoji; + } if (WEB_TOOL_TOKENS.some((token) => normalized.includes(token))) { return emojis.web; }