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
This commit is contained in:
Peter Steinberger
2026-02-14 14:15:37 +01:00
committed by GitHub
parent 6f7d31c426
commit ef70a55b7a
8 changed files with 112 additions and 51 deletions
+1
View File
@@ -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. - 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: 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. - 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. - 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. - 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. - Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
+1 -1
View File
@@ -177,7 +177,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
allowTagsWhenOff: true, allowExplicitReplyTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params), buildToolContext: (params) => buildSlackThreadingToolContext(params),
}, },
messaging: { messaging: {
+47 -38
View File
@@ -7,41 +7,54 @@ import { normalizeTargetForProvider } from "../../infra/outbound/target-normaliz
import { extractReplyToTag } from "./reply-tags.js"; import { extractReplyToTag } from "./reply-tags.js";
import { createReplyToModeFilterForChannel } from "./reply-threading.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( export function applyReplyTagsToPayload(
payload: ReplyPayload, payload: ReplyPayload,
currentMessageId?: string, currentMessageId?: string,
): ReplyPayload { ): ReplyPayload {
if (typeof payload.text !== "string") { return resolveReplyThreadingForPayload({ payload, currentMessageId });
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,
};
} }
export function isRenderablePayload(payload: ReplyPayload): boolean { export function isRenderablePayload(payload: ReplyPayload): boolean {
@@ -64,13 +77,9 @@ export function applyReplyThreading(params: {
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
const implicitReplyToId = currentMessageId?.trim() || undefined; const implicitReplyToId = currentMessageId?.trim() || undefined;
return payloads return payloads
.map((payload) => { .map((payload) =>
const autoThreaded = resolveReplyThreadingForPayload({ payload, implicitReplyToId, currentMessageId }),
payload.replyToId || payload.replyToCurrent === false || !implicitReplyToId )
? payload
: { ...payload, replyToId: implicitReplyToId };
return applyReplyTagsToPayload(autoThreaded, currentMessageId);
})
.filter(isRenderablePayload) .filter(isRenderablePayload)
.map(applyReplyToMode); .map(applyReplyToMode);
} }
+1 -1
View File
@@ -232,7 +232,7 @@ describe("createReplyToModeFilter", () => {
}); });
it("keeps replyToId when mode is off and reply tags are allowed", () => { 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"); expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
}); });
+12 -8
View File
@@ -25,7 +25,7 @@ export function resolveReplyToMode(
export function createReplyToModeFilter( export function createReplyToModeFilter(
mode: ReplyToMode, mode: ReplyToMode,
opts: { allowTagsWhenOff?: boolean } = {}, opts: { allowExplicitReplyTagsWhenOff?: boolean } = {},
) { ) {
let hasThreaded = false; let hasThreaded = false;
return (payload: ReplyPayload): ReplyPayload => { return (payload: ReplyPayload): ReplyPayload => {
@@ -33,7 +33,8 @@ export function createReplyToModeFilter(
return payload; return payload;
} }
if (mode === "off") { if (mode === "off") {
if (opts.allowTagsWhenOff && payload.replyToTag) { const isExplicit = Boolean(payload.replyToTag) || Boolean(payload.replyToCurrent);
if (opts.allowExplicitReplyTagsWhenOff && isExplicit) {
return payload; return payload;
} }
return { ...payload, replyToId: undefined }; return { ...payload, replyToId: undefined };
@@ -54,12 +55,15 @@ export function createReplyToModeFilterForChannel(
channel?: OriginatingChannelType, channel?: OriginatingChannelType,
) { ) {
const provider = normalizeChannelId(channel); const provider = normalizeChannelId(channel);
// Always honour explicit [[reply_to_*]] tags even when replyToMode is "off". const normalized = typeof channel === "string" ? channel.trim().toLowerCase() : undefined;
// Per-channel opt-out is possible but the safe default is to allow them. const isWebchat = normalized === "webchat";
const allowTagsWhenOff = provider // Default: allow explicit reply tags/directives even when replyToMode is "off".
? (getChannelDock(provider)?.threading?.allowTagsWhenOff ?? true) // Unknown channels fail closed; internal webchat stays allowed.
: true; const dock = provider ? getChannelDock(provider) : undefined;
const allowExplicitReplyTagsWhenOff = provider
? (dock?.threading?.allowExplicitReplyTagsWhenOff ?? dock?.threading?.allowTagsWhenOff ?? true)
: isWebchat;
return createReplyToModeFilter(mode, { return createReplyToModeFilter(mode, {
allowTagsWhenOff, allowExplicitReplyTagsWhenOff,
}); });
} }
+1 -1
View File
@@ -369,7 +369,7 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveReplyToMode: ({ cfg, accountId, chatType }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType),
allowTagsWhenOff: true, allowExplicitReplyTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params), buildToolContext: (params) => buildSlackThreadingToolContext(params),
}, },
}, },
+10
View File
@@ -223,6 +223,16 @@ export type ChannelThreadingAdapter = {
accountId?: string | null; accountId?: string | null;
chatType?: string | null; chatType?: string | null;
}) => "off" | "first" | "all"; }) => "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; allowTagsWhenOff?: boolean;
buildToolContext?: (params: { buildToolContext?: (params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
+39 -2
View File
@@ -74,6 +74,37 @@ const resolvePluginSdkAlias = (): string | null => {
return 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: { function buildCacheKey(params: {
workspaceDir?: string; workspaceDir?: string;
plugins: NormalizedPluginsConfig; plugins: NormalizedPluginsConfig;
@@ -211,12 +242,18 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics); pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics);
const pluginSdkAlias = resolvePluginSdkAlias(); const pluginSdkAlias = resolvePluginSdkAlias();
const pluginSdkAccountIdAlias = resolvePluginSdkAccountIdAlias();
const jiti = createJiti(import.meta.url, { const jiti = createJiti(import.meta.url, {
interopDefault: true, interopDefault: true,
extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], 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 }
: {}),
},
} }
: {}), : {}),
}); });