Files
openclaw/src/plugin-sdk/acp-runtime-backend.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

115 lines
3.7 KiB
TypeScript

// Lightweight ACP runtime backend helpers for startup-loaded plugins.
import { hasExplicitCommandContextText } from "../auto-reply/reply/context-text.js";
import type {
PluginHookReplyDispatchContext,
PluginHookReplyDispatchEvent,
PluginHookReplyDispatchResult,
} from "../plugins/types.js";
export { AcpRuntimeError, isAcpRuntimeError } from "../acp/runtime/errors.js";
export type { AcpRuntimeErrorCode } from "../acp/runtime/errors.js";
export {
getAcpRuntimeBackend,
registerAcpRuntimeBackend,
requireAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
} from "../acp/runtime/registry.js";
export type {
AcpRuntime,
AcpRuntimeCapabilities,
AcpRuntimeDoctorReport,
AcpRuntimeEnsureInput,
AcpRuntimeEvent,
AcpRuntimeHandle,
AcpRuntimeStatus,
AcpRuntimeTurn,
AcpRuntimeTurnAttachment,
AcpRuntimeTurnInput,
AcpRuntimeTurnResult,
AcpRuntimeTurnResultError,
AcpSessionUpdateTag,
} from "@openclaw/acp-core/runtime/types";
let dispatchAcpRuntimePromise: Promise<
typeof import("../auto-reply/reply/dispatch-acp.runtime.js")
> | null = null;
function loadDispatchAcpRuntime() {
// ACP dispatch pulls in session/media/manager code; cache the dynamic import so
// startup-loaded plugin surfaces stay light and concurrent hooks share one load.
dispatchAcpRuntimePromise ??= import("../auto-reply/reply/dispatch-acp.runtime.js");
return dispatchAcpRuntimePromise;
}
/**
* Dispatch a plugin reply hook through ACP when the event targets an ACP-bound session.
* Returns a handled result only when ACP consumes the reply; otherwise callers continue normal delivery.
*/
export async function tryDispatchAcpReplyHook(
event: PluginHookReplyDispatchEvent,
ctx: PluginHookReplyDispatchContext,
): Promise<PluginHookReplyDispatchResult | void> {
// Under sendPolicy: "deny", ACP-bound sessions still need their turns to flow
// through acpManager.runTurn so session state, tool calls, and memory stay
// consistent. Delivery suppression is handled by the ACP delivery path.
if (
event.sendPolicy === "deny" &&
!event.suppressUserDelivery &&
!hasExplicitCommandContextText(event.ctx) &&
!event.isTailDispatch
) {
return;
}
const runtime = await loadDispatchAcpRuntime();
const bypassForCommand = await runtime.shouldBypassAcpDispatchForCommand(event.ctx, ctx.cfg);
if (
event.sendPolicy === "deny" &&
!event.suppressUserDelivery &&
!bypassForCommand &&
!event.isTailDispatch
) {
return;
}
const result = await runtime.tryDispatchAcpReply({
ctx: event.ctx,
cfg: ctx.cfg,
dispatcher: ctx.dispatcher,
runId: event.runId,
sessionKey: event.sessionKey,
toolsAllow: event.toolsAllow,
images: event.images,
abortSignal: ctx.abortSignal,
inboundAudio: event.inboundAudio,
sessionTtsAuto: event.sessionTtsAuto,
ttsChannel: event.ttsChannel,
suppressUserDelivery: event.suppressUserDelivery,
suppressReplyLifecycle: event.suppressReplyLifecycle === true || event.sendPolicy === "deny",
sourceReplyDeliveryMode: event.sourceReplyDeliveryMode,
shouldRouteToOriginating: event.shouldRouteToOriginating,
originatingChannel: event.originatingChannel,
originatingTo: event.originatingTo,
originatingAccountId: event.originatingAccountId,
originatingThreadId: event.originatingThreadId,
originatingChatType: event.originatingChatType,
shouldSendToolSummaries: event.shouldSendToolSummaries,
shouldSendToolSummariesNow: () => event.shouldSendToolSummaries,
bypassForCommand,
onReplyStart: ctx.onReplyStart,
recordProcessed: ctx.recordProcessed,
markIdle: ctx.markIdle,
});
if (!result) {
return;
}
return {
handled: true,
queuedFinal: result.queuedFinal,
counts: result.counts,
};
}