Skip to content

fix(hooks): duplicate delivery when hook agent turn uses announce flow #20196

Description

@artwist-polyakov

Bug description

When a hook is triggered via /hooks/agent with deliver: true, the response is delivered twice to the target channel — once through the announce flow and once through the main agent heartbeat.

Root cause

dispatchAgentHook in src/gateway/server/hooks.ts calls runCronIsolatedAgentTurn() and then unconditionally posts a summary to the main agent session via enqueueSystemEvent() and wakes the heartbeat via requestHeartbeatNow(). When the isolated run already delivered its output (via the subagent announce flow or direct outbound), this causes the main agent to wake up and generate a second response that is also delivered to the target channel.

This is the same pattern that was fixed for the cron service path in ea95e88 (#15692), but the hooks handler was not updated.

Steps to reproduce

  1. Configure two agents (main and a secondary agent, e.g. familyoffice) with Telegram bindings
  2. Send a POST to /hooks/agent with:
    {
      "message": "Hello",
      "agentId": "familyoffice",
      "deliver": true,
      "channel": "telegram",
      "to": "<group-chat-id>",
      "wakeMode": "now"
    }
  3. Observe that:
    • The announce flow delivers the response to the target group chat (correct)
    • enqueueSystemEvent posts a summary to the main agent session
    • requestHeartbeatNow wakes all agents including main
    • The main agent processes the system event and delivers to its last active chat (duplicate)

Expected behavior

When result.delivered is true, skip both enqueueSystemEvent and requestHeartbeatNow — the output was already delivered to the target channel.

Existing fix for reference

Commit ea95e88 applied the same fix to src/cron/service/timer.ts (executeJobCore), checking res.delivered before posting the summary. The RunCronAgentTurnResult.delivered flag and its JSDoc already document this contract:

delivered: true — Callers should skip posting a summary to the main session to avoid duplicate messages.

The hooks handler simply needs to honor this flag.

Proposed fix

--- a/src/gateway/server/hooks.ts
+++ b/src/gateway/server/hooks.ts
@@ -86,11 +86,13 @@
         const summary = result.summary?.trim() || result.error?.trim() || result.status;
         const prefix =
           result.status === "ok" ? `Hook ${value.name}` : `Hook ${value.name} (${result.status})`;
-        enqueueSystemEvent(`${prefix}: ${summary}`.trim(), {
-          sessionKey: mainSessionKey,
-        });
-        if (value.wakeMode === "now") {
-          requestHeartbeatNow({ reason: `hook:${jobId}` });
+        if (!result.delivered) {
+          enqueueSystemEvent(`${prefix}: ${summary}`.trim(), {
+            sessionKey: mainSessionKey,
+          });
+          if (value.wakeMode === "now") {
+            requestHeartbeatNow({ reason: `hook:${jobId}` });
+          }
         }

Environment

  • OpenClaw version: 2026.2.18 (commit d833dcd)
  • Channel: Telegram (multi-agent setup with bindings)
  • Trigger: external scheduler via /hooks/agent

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions