From ef70a55b7a30bccc23b347c1d25d252022fbd2db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 14:15:37 +0100 Subject: [PATCH] refactor(reply): clarify explicit reply tags in off mode (#16189) * refactor(reply): clarify explicit reply tags in off mode * fix(plugin-sdk): alias account-id subpath for extensions --- CHANGELOG.md | 1 + extensions/slack/src/channel.ts | 2 +- src/auto-reply/reply/reply-payloads.ts | 85 ++++++++++++---------- src/auto-reply/reply/reply-routing.test.ts | 2 +- src/auto-reply/reply/reply-threading.ts | 20 +++-- src/channels/dock.ts | 2 +- src/channels/plugins/types.core.ts | 10 +++ src/plugins/loader.ts | 41 ++++++++++- 8 files changed, 112 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ce8754efd..585d3c1294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai - Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow. - Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u. - Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim. +- Plugins/Threading: rename `allowTagsWhenOff` to `allowExplicitReplyTagsWhenOff` and keep the old key as a deprecated alias for compatibility. (#16189) - Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr. - Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale. - Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow. diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 4b2586003b..b41ac6ab05 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -177,7 +177,7 @@ export const slackPlugin: ChannelPlugin = { threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), - allowTagsWhenOff: true, + allowExplicitReplyTagsWhenOff: true, buildToolContext: (params) => buildSlackThreadingToolContext(params), }, messaging: { diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index b112476839..9b879026c3 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -7,41 +7,54 @@ import { normalizeTargetForProvider } from "../../infra/outbound/target-normaliz import { extractReplyToTag } from "./reply-tags.js"; import { createReplyToModeFilterForChannel } from "./reply-threading.js"; +function resolveReplyThreadingForPayload(params: { + payload: ReplyPayload; + implicitReplyToId?: string; + currentMessageId?: string; +}): ReplyPayload { + const implicitReplyToId = params.implicitReplyToId?.trim() || undefined; + const currentMessageId = params.currentMessageId?.trim() || undefined; + + // 1) Apply implicit reply threading first (replyToMode will strip later if needed). + let resolved: ReplyPayload = + params.payload.replyToId || params.payload.replyToCurrent === false || !implicitReplyToId + ? params.payload + : { ...params.payload, replyToId: implicitReplyToId }; + + // 2) Parse explicit reply tags from text (if present) and clean them. + if (typeof resolved.text === "string" && resolved.text.includes("[[")) { + const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag( + resolved.text, + currentMessageId, + ); + resolved = { + ...resolved, + text: cleaned ? cleaned : undefined, + replyToId: replyToId ?? resolved.replyToId, + replyToTag: hasTag || resolved.replyToTag, + replyToCurrent: replyToCurrent || resolved.replyToCurrent, + }; + } + + // 3) If replyToCurrent was set out-of-band (e.g. tags already stripped upstream), + // ensure replyToId is set to the current message id when available. + if (resolved.replyToCurrent && !resolved.replyToId && currentMessageId) { + resolved = { + ...resolved, + replyToId: currentMessageId, + }; + } + + return resolved; +} + +// Backward-compatible helper: apply explicit reply tags/directives to a single payload. +// This intentionally does not apply implicit threading. export function applyReplyTagsToPayload( payload: ReplyPayload, currentMessageId?: string, ): ReplyPayload { - if (typeof payload.text !== "string") { - if (!payload.replyToCurrent || payload.replyToId) { - return payload; - } - return { - ...payload, - replyToId: currentMessageId?.trim() || undefined, - }; - } - const shouldParseTags = payload.text.includes("[["); - if (!shouldParseTags) { - if (!payload.replyToCurrent || payload.replyToId) { - return payload; - } - return { - ...payload, - replyToId: currentMessageId?.trim() || undefined, - replyToTag: payload.replyToTag ?? true, - }; - } - const { cleaned, replyToId, replyToCurrent, hasTag } = extractReplyToTag( - payload.text, - currentMessageId, - ); - return { - ...payload, - text: cleaned ? cleaned : undefined, - replyToId: replyToId ?? payload.replyToId, - replyToTag: hasTag || payload.replyToTag, - replyToCurrent: replyToCurrent || payload.replyToCurrent, - }; + return resolveReplyThreadingForPayload({ payload, currentMessageId }); } export function isRenderablePayload(payload: ReplyPayload): boolean { @@ -64,13 +77,9 @@ export function applyReplyThreading(params: { const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); const implicitReplyToId = currentMessageId?.trim() || undefined; return payloads - .map((payload) => { - const autoThreaded = - payload.replyToId || payload.replyToCurrent === false || !implicitReplyToId - ? payload - : { ...payload, replyToId: implicitReplyToId }; - return applyReplyTagsToPayload(autoThreaded, currentMessageId); - }) + .map((payload) => + resolveReplyThreadingForPayload({ payload, implicitReplyToId, currentMessageId }), + ) .filter(isRenderablePayload) .map(applyReplyToMode); } diff --git a/src/auto-reply/reply/reply-routing.test.ts b/src/auto-reply/reply/reply-routing.test.ts index f1a8db6270..78a4010c53 100644 --- a/src/auto-reply/reply/reply-routing.test.ts +++ b/src/auto-reply/reply/reply-routing.test.ts @@ -232,7 +232,7 @@ describe("createReplyToModeFilter", () => { }); it("keeps replyToId when mode is off and reply tags are allowed", () => { - const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true }); + const filter = createReplyToModeFilter("off", { allowExplicitReplyTagsWhenOff: true }); expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1"); }); diff --git a/src/auto-reply/reply/reply-threading.ts b/src/auto-reply/reply/reply-threading.ts index cfc6f3a733..8fb54e9161 100644 --- a/src/auto-reply/reply/reply-threading.ts +++ b/src/auto-reply/reply/reply-threading.ts @@ -25,7 +25,7 @@ export function resolveReplyToMode( export function createReplyToModeFilter( mode: ReplyToMode, - opts: { allowTagsWhenOff?: boolean } = {}, + opts: { allowExplicitReplyTagsWhenOff?: boolean } = {}, ) { let hasThreaded = false; return (payload: ReplyPayload): ReplyPayload => { @@ -33,7 +33,8 @@ export function createReplyToModeFilter( return payload; } if (mode === "off") { - if (opts.allowTagsWhenOff && payload.replyToTag) { + const isExplicit = Boolean(payload.replyToTag) || Boolean(payload.replyToCurrent); + if (opts.allowExplicitReplyTagsWhenOff && isExplicit) { return payload; } return { ...payload, replyToId: undefined }; @@ -54,12 +55,15 @@ export function createReplyToModeFilterForChannel( channel?: OriginatingChannelType, ) { const provider = normalizeChannelId(channel); - // Always honour explicit [[reply_to_*]] tags even when replyToMode is "off". - // Per-channel opt-out is possible but the safe default is to allow them. - const allowTagsWhenOff = provider - ? (getChannelDock(provider)?.threading?.allowTagsWhenOff ?? true) - : true; + const normalized = typeof channel === "string" ? channel.trim().toLowerCase() : undefined; + const isWebchat = normalized === "webchat"; + // Default: allow explicit reply tags/directives even when replyToMode is "off". + // Unknown channels fail closed; internal webchat stays allowed. + const dock = provider ? getChannelDock(provider) : undefined; + const allowExplicitReplyTagsWhenOff = provider + ? (dock?.threading?.allowExplicitReplyTagsWhenOff ?? dock?.threading?.allowTagsWhenOff ?? true) + : isWebchat; return createReplyToModeFilter(mode, { - allowTagsWhenOff, + allowExplicitReplyTagsWhenOff, }); } diff --git a/src/channels/dock.ts b/src/channels/dock.ts index 54d9e8f679..e7a5b7e0f1 100644 --- a/src/channels/dock.ts +++ b/src/channels/dock.ts @@ -369,7 +369,7 @@ const DOCKS: Record = { threading: { resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), - allowTagsWhenOff: true, + allowExplicitReplyTagsWhenOff: true, buildToolContext: (params) => buildSlackThreadingToolContext(params), }, }, diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index bd82e98453..abf30940ae 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -223,6 +223,16 @@ export type ChannelThreadingAdapter = { accountId?: string | null; chatType?: string | null; }) => "off" | "first" | "all"; + /** + * When replyToMode is "off", allow explicit reply tags/directives to keep replyToId. + * + * Default in shared reply flow: true for known providers; per-channel opt-out supported. + */ + allowExplicitReplyTagsWhenOff?: boolean; + /** + * Deprecated alias for allowExplicitReplyTagsWhenOff. + * Kept for compatibility with older extensions/docks. + */ allowTagsWhenOff?: boolean; buildToolContext?: (params: { cfg: OpenClawConfig; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 360022ea80..0603454766 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -74,6 +74,37 @@ const resolvePluginSdkAlias = (): string | null => { return null; }; +const resolvePluginSdkAccountIdAlias = (): string | null => { + try { + const modulePath = fileURLToPath(import.meta.url); + const isProduction = process.env.NODE_ENV === "production"; + const isTest = process.env.VITEST || process.env.NODE_ENV === "test"; + let cursor = path.dirname(modulePath); + for (let i = 0; i < 6; i += 1) { + const srcCandidate = path.join(cursor, "src", "plugin-sdk", "account-id.ts"); + const distCandidate = path.join(cursor, "dist", "plugin-sdk", "account-id.js"); + const orderedCandidates = isProduction + ? isTest + ? [distCandidate, srcCandidate] + : [distCandidate] + : [srcCandidate, distCandidate]; + for (const candidate of orderedCandidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + } catch { + // ignore + } + return null; +}; + function buildCacheKey(params: { workspaceDir?: string; plugins: NormalizedPluginsConfig; @@ -211,12 +242,18 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics); const pluginSdkAlias = resolvePluginSdkAlias(); + const pluginSdkAccountIdAlias = resolvePluginSdkAccountIdAlias(); const jiti = createJiti(import.meta.url, { interopDefault: true, extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], - ...(pluginSdkAlias + ...(pluginSdkAlias || pluginSdkAccountIdAlias ? { - alias: { "openclaw/plugin-sdk": pluginSdkAlias }, + alias: { + ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), + ...(pluginSdkAccountIdAlias + ? { "openclaw/plugin-sdk/account-id": pluginSdkAccountIdAlias } + : {}), + }, } : {}), });