diff --git a/CHANGELOG.md b/CHANGELOG.md index 724ae2b6669..18a4c7a0c24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/generated media: treat attachment-style message tool actions as completed chat sends, preventing duplicate fallback media posts when generated files were already uploaded. - Discord/streaming: show live reasoning text in progress drafts instead of a bare `Reasoning` status line. - Doctor/status: warn when `OPENCLAW_GATEWAY_TOKEN` would shadow a different active `gateway.auth.token` source for local CLI commands, while avoiding false positives when config points at the same env token. Fixes #74271. Thanks @yelog. - Gateway/HTTP: avoid loading managed outgoing-image media handlers for unrelated requests, so disabled OpenAI-compatible routes return 404 without waiting on lazy media sidecars. Thanks @vincentkoc. diff --git a/src/agents/pi-embedded-messaging.ts b/src/agents/pi-embedded-messaging.ts index 0eeef45af7c..d511668f3eb 100644 --- a/src/agents/pi-embedded-messaging.ts +++ b/src/agents/pi-embedded-messaging.ts @@ -2,6 +2,18 @@ import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index. import { normalizeOptionalString } from "../shared/string-coerce.js"; const CORE_MESSAGING_TOOLS = new Set(["sessions_send", "message"]); +const MESSAGE_TOOL_SEND_ACTIONS = new Set([ + "send", + "thread-reply", + "sendWithEffect", + "sendAttachment", + "upload-file", +]); + +export function isMessageToolSendActionName(action: unknown): boolean { + const normalized = normalizeOptionalString(action) ?? ""; + return MESSAGE_TOOL_SEND_ACTIONS.has(normalized); +} // Provider docking: any plugin with `actions` opts into messaging tool handling. export function isMessagingTool(toolName: string): boolean { @@ -21,7 +33,7 @@ export function isMessagingToolSendAction( return true; } if (toolName === "message") { - return action === "send" || action === "thread-reply"; + return isMessageToolSendActionName(action); } const providerId = normalizeChannelId(toolName); if (!providerId) { diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index 89e53214f6a..0580533a1f4 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -941,6 +941,85 @@ describe("messaging tool media URL tracking", () => { ]); }); + it("commits upload-file args as message delivery evidence", async () => { + const { ctx } = createTestContext(); + + const startEvt: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolName: "message", + toolCallId: "tool-upload-file", + args: { + action: "upload-file", + channel: "discord", + to: "channel:123", + message: "track ready", + path: "/tmp/generated-song.mp3", + }, + }; + await handleToolExecutionStart(ctx, startEvt); + + expect(ctx.state.pendingMessagingMediaUrls.get("tool-upload-file")).toEqual([ + "/tmp/generated-song.mp3", + ]); + + const endEvt: ToolExecutionEndEvent = { + type: "tool_execution_end", + toolName: "message", + toolCallId: "tool-upload-file", + isError: false, + result: { ok: true }, + }; + await handleToolExecutionEnd(ctx, endEvt); + + expect(ctx.state.messagingToolSentMediaUrls).toEqual(["/tmp/generated-song.mp3"]); + expect(ctx.state.messagingToolSentTargets).toEqual([ + expect.objectContaining({ + provider: "discord", + to: "channel:123", + text: "track ready", + mediaUrls: ["/tmp/generated-song.mp3"], + }), + ]); + expect(ctx.state.pendingMessagingMediaUrls.has("tool-upload-file")).toBe(false); + }); + + it("commits sendAttachment args as message delivery evidence", async () => { + const { ctx } = createTestContext(); + + const startEvt: ToolExecutionStartEvent = { + type: "tool_execution_start", + toolName: "message", + toolCallId: "tool-send-attachment", + args: { + action: "sendAttachment", + provider: "discord", + to: "channel:123", + content: "track ready", + filePath: "/tmp/generated-song.mp3", + }, + }; + await handleToolExecutionStart(ctx, startEvt); + + const endEvt: ToolExecutionEndEvent = { + type: "tool_execution_end", + toolName: "message", + toolCallId: "tool-send-attachment", + isError: false, + result: { ok: true }, + }; + await handleToolExecutionEnd(ctx, endEvt); + + expect(ctx.state.messagingToolSentMediaUrls).toEqual(["/tmp/generated-song.mp3"]); + expect(ctx.state.messagingToolSentTargets).toEqual([ + expect.objectContaining({ + provider: "discord", + to: "channel:123", + text: "track ready", + mediaUrls: ["/tmp/generated-song.mp3"], + }), + ]); + }); + it("trims messagingToolSentMediaUrls to 200 on commit (FIFO)", async () => { const { ctx } = createTestContext(); diff --git a/src/agents/pi-embedded-subscribe.tools.extract.test.ts b/src/agents/pi-embedded-subscribe.tools.extract.test.ts index 86ec98e417c..267f025970b 100644 --- a/src/agents/pi-embedded-subscribe.tools.extract.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.extract.test.ts @@ -60,4 +60,58 @@ describe("extractMessagingToolSend", () => { expect(result?.provider).toBe("telegram"); expect(result?.to).toBe("telegram:123"); }); + + it("recognizes attachment-style message tool sends", () => { + const upload = extractMessagingToolSend("message", { + action: "upload-file", + channel: "discord", + to: "channel:123", + path: "/tmp/song.mp3", + }); + const attachment = extractMessagingToolSend("message", { + action: "sendAttachment", + provider: "discord", + to: "channel:123", + filePath: "/tmp/song.mp3", + }); + const effect = extractMessagingToolSend("message", { + action: "sendWithEffect", + provider: "discord", + to: "channel:123", + content: "done", + }); + + expect(upload).toMatchObject({ + tool: "message", + provider: "discord", + to: "channel:123", + }); + expect(attachment).toMatchObject({ + tool: "message", + provider: "discord", + to: "channel:123", + }); + expect(effect).toMatchObject({ + tool: "message", + provider: "discord", + to: "channel:123", + }); + }); + + it("keeps thread id evidence for thread replies", () => { + const result = extractMessagingToolSend("message", { + action: "thread-reply", + provider: "discord", + to: "channel:123", + threadId: "456", + content: "done", + }); + + expect(result).toMatchObject({ + tool: "message", + provider: "discord", + to: "channel:123", + threadId: "456", + }); + }); }); diff --git a/src/agents/pi-embedded-subscribe.tools.ts b/src/agents/pi-embedded-subscribe.tools.ts index 2346fa091f8..8fc9a971195 100644 --- a/src/agents/pi-embedded-subscribe.tools.ts +++ b/src/agents/pi-embedded-subscribe.tools.ts @@ -10,6 +10,7 @@ import { } from "../shared/string-coerce.js"; import { truncateUtf16Safe } from "../utils.js"; import { collectTextContentBlocks } from "./content-blocks.js"; +import { isMessageToolSendActionName } from "./pi-embedded-messaging.js"; import type { MessagingToolSend } from "./pi-embedded-messaging.types.js"; import { normalizeToolName } from "./tool-policy.js"; @@ -539,7 +540,7 @@ export function extractMessagingToolSend( const action = normalizeOptionalString(args.action) ?? ""; const accountId = normalizeOptionalString(args.accountId); if (toolName === "message") { - if (action !== "send" && action !== "thread-reply") { + if (!isMessageToolSendActionName(action)) { return undefined; } const toRaw = resolveMessageToolTarget(args); @@ -552,7 +553,8 @@ export function extractMessagingToolSend( const providerId = providerHint ? normalizeChannelId(providerHint) : null; const provider = providerId ?? normalizeOptionalLowercaseString(providerHint) ?? "message"; const to = normalizeTargetForProvider(provider, toRaw); - return to ? { tool: toolName, provider, accountId, to } : undefined; + const threadId = normalizeOptionalString(args.threadId); + return to ? { tool: toolName, provider, accountId, to, threadId } : undefined; } const providerId = normalizeChannelId(toolName); if (!providerId) {