diff --git a/CHANGELOG.md b/CHANGELOG.md index 399369fde..e9b0a3e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output. - Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs). - Models/Auth: allow MiniMax API configs without `models.providers.minimax.apiKey` (auth profiles / `MINIMAX_API_KEY`). (#656) — thanks @mneves75. +- Agents: avoid duplicate replies when the message tool sends. (#659) — thanks @mickahouan. ## 2026.1.9 diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index c238b2b8b..0a0035716 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -456,6 +456,53 @@ describe("subscribeEmbeddedPiSession", () => { expect(subscription.assistantTexts).toEqual(["Hello block"]); }); + it("suppresses message_end block replies when the message tool already sent", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onBlockReply = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters< + typeof subscribeEmbeddedPiSession + >[0]["session"], + runId: "run", + onBlockReply, + blockReplyBreak: "message_end", + }); + + const messageText = "This is the answer."; + + handler?.({ + type: "tool_execution_start", + toolName: "message", + toolCallId: "tool-message-1", + args: { action: "send", to: "+1555", message: messageText }, + }); + + handler?.({ + type: "tool_execution_end", + toolName: "message", + toolCallId: "tool-message-1", + isError: false, + result: "ok", + }); + + const assistantMessage = { + role: "assistant", + content: [{ type: "text", text: messageText }], + } as AssistantMessage; + + handler?.({ type: "message_end", message: assistantMessage }); + + expect(onBlockReply).not.toHaveBeenCalled(); + }); + it("clears block reply state on message_start", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 1f2350e11..b8d41040a 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -242,6 +242,16 @@ function extractMessagingToolSend( ? { tool: toolName, provider: "telegram", accountId, to } : undefined; } + if (toolName === "message") { + if (action !== "send" && action !== "thread-reply") return undefined; + const toRaw = typeof args.to === "string" ? args.to : undefined; + if (!toRaw) return undefined; + const providerRaw = + typeof args.provider === "string" ? args.provider.trim() : ""; + const provider = providerRaw || "message"; + const to = toRaw.trim(); + return to ? { tool: toolName, provider, accountId, to } : undefined; + } return undefined; } @@ -310,6 +320,7 @@ export function subscribeEmbeddedPiSession(params: { "whatsapp", "discord", "slack", + "message", "sessions_send", ]); const messagingToolSentTexts: string[] = []; @@ -547,13 +558,18 @@ export function subscribeEmbeddedPiSession(params: { ? (args as Record) : {}; const action = - typeof argsRecord.action === "string" ? argsRecord.action : ""; - // Track send actions: sendMessage/threadReply for Discord/Slack, or sessions_send (no action field) - if ( + typeof argsRecord.action === "string" + ? argsRecord.action.trim() + : ""; + // Track send actions: sendMessage/threadReply for Discord/Slack, sessions_send (no action field), + // and message/send or message/thread-reply for the generic message tool. + const isMessagingSend = action === "sendMessage" || action === "threadReply" || - toolName === "sessions_send" - ) { + toolName === "sessions_send" || + (toolName === "message" && + (action === "send" || action === "thread-reply")); + if (isMessagingSend) { const sendTarget = extractMessagingToolSend(toolName, argsRecord); if (sendTarget) { pendingMessagingTargets.set(toolCallId, sendTarget);