mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 22:46:07 +00:00
* 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>
115 lines
3.7 KiB
TypeScript
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,
|
|
};
|
|
}
|