Files
openclaw/src/plugin-sdk/tool-send.ts
sandieman2 c67dc59b02 fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread (#90943)
* fix(reply): deliver final reply when queued follow-up claims session; scope dedupe to routed thread

Two core bugs caused composed replies to be silently dropped (no delivery,
no error) when a second message arrived in the same thread mid-run:

1. dispatch-from-config: ensureDispatchReplyOperation only kept the
   dispatch-owned operation authoritative while it had no result. Once
   runReplyAgent completed the operation to drain queued follow-ups, a
   second same-thread inbound could claim the session and the first final
   reply would try to re-acquire the lane instead of finishing delivery,
   deadlocking behind the queued work. Keep the dispatch-owned operation
   authoritative through final delivery.

2. reply-payloads-dedupe: messaging-tool reply dedupe compared only the
   channel target, not the routed thread, so a send in one thread could
   suppress a later reply in a different thread. Thread the routed thread
   id through buildReplyPayloads + follow-up delivery and only fall back to
   channel-only matching for providers without a thread-aware suppression
   matcher when neither side carries thread evidence.

Adds regression tests; existing Telegram topic-suppression behavior is
preserved by gating the thread guard to providers lacking a plugin matcher.

* fix(reply): preserve threaded message delivery evidence

* fix(reply): dedupe final payloads by delivery route

* fix(slack): preserve native send thread evidence

* fix(reply): preserve explicit reply thread evidence

* fix(reply): align explicit reply route dedupe

* fix(reply): preserve delivery lane through final dispatch

* fix(mattermost): preserve threaded tool send routes

* chore(plugin-sdk): refresh API baseline

* fix(reply): align final delivery route dedupe

* fix(reply): gate followups on final delivery

* fix(reply): keep send receipts private

* fix(reply): infer implicit message provider

* fix(reply): align routed threading policy

* fix(reply): preserve queued delivery context

* fix(reply): hydrate queued system event routes

* fix(reply): hydrate queued execution routes

* fix(reply): scope final delivery barriers

* fix(slack): preserve DM target aliases

* fix(reply): mirror resolved source thread routes

* fix(mattermost): retain delayed delivery barrier

* fix(codex): separate message routing from tool policy

* fix(reply): consume normalized Slack DM targets once

* fix(slack): remove stale target alias

* style(reply): satisfy changed lint gates

* fix(mattermost): preserve explicit reply targets

* test: align Slack reply branch checks

* fix(reply): persist overflow summaries to admitted session

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-14 09:11:05 -07:00

39 lines
1.6 KiB
TypeScript

// Tool send helpers normalize model tool-send requests before provider dispatch.
import { readStringValue } from "../../packages/normalization-core/src/string-coerce.js";
export type { ChannelToolSend } from "../channels/plugins/types.public.js";
/** Extract the canonical send target fields from tool arguments when the action matches. */
export function extractToolSend(
/** Raw model tool arguments supplied to a channel action. */
args: Record<string, unknown>,
/** Action name that should be treated as a send action. */
expectedAction = "sendMessage",
): {
/** Canonical destination id used by core send routing. */
to: string;
/** Optional channel account/profile id when the action includes one. */
accountId?: string;
/** Optional thread/topic id, normalized to string for channel send adapters. */
threadId?: string;
/** True when the send explicitly opts out of ambient thread inheritance. */
threadSuppressed?: boolean;
} | null {
const action = readStringValue(args.action)?.trim() ?? "";
if (action !== expectedAction) {
return null;
}
const to = readStringValue(args.to);
if (!to) {
return null;
}
const accountId = readStringValue(args.accountId)?.trim();
const threadIdRaw =
typeof args.threadId === "number"
? String(args.threadId)
: (readStringValue(args.threadId)?.trim() ?? "");
const threadId = threadIdRaw.length > 0 ? threadIdRaw : undefined;
const threadSuppressed = args.topLevel === true || args.threadId === null;
return { to, accountId, threadId, ...(threadSuppressed ? { threadSuppressed: true } : {}) };
}