From 79a38858ae1069c9b9995237cd0c5323e3073bd5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Feb 2026 05:20:47 +0100 Subject: [PATCH] fix: preserve off-mode semantics in auto reply threading (#14976) (thanks @Diaspar4u) --- CHANGELOG.md | 1 + .../reply/agent-runner-execution.ts | 6 +++-- src/auto-reply/reply/block-reply-coalescer.ts | 8 +++--- .../reply-payloads.auto-threading.test.ts | 27 ++++++++++++++++++- src/auto-reply/reply/reply-payloads.ts | 11 +++++--- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f65a6f23..a9668b1075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr. +- 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. - Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive. - Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck. - macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR. diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 9990a1ec58..9da0713dc1 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -376,9 +376,11 @@ export async function runAgentTurnWithFallback(params: { text, mediaUrls: payload.mediaUrls, mediaUrl: payload.mediaUrls?.[0], - replyToId: payload.replyToId, + replyToId: + payload.replyToId ?? + (payload.replyToCurrent === false ? undefined : currentMessageId), replyToTag: payload.replyToTag, - replyToCurrent: true, + replyToCurrent: payload.replyToCurrent, }, currentMessageId, ); diff --git a/src/auto-reply/reply/block-reply-coalescer.ts b/src/auto-reply/reply/block-reply-coalescer.ts index 5c5c16d0cb..130f57b3d0 100644 --- a/src/auto-reply/reply/block-reply-coalescer.ts +++ b/src/auto-reply/reply/block-reply-coalescer.ts @@ -100,10 +100,12 @@ export function createBlockReplyCoalescer(params: { return; } - if ( + const replyToConflict = Boolean( bufferText && - (bufferReplyToId !== payload.replyToId || bufferAudioAsVoice !== payload.audioAsVoice) - ) { + payload.replyToId && + (!bufferReplyToId || bufferReplyToId !== payload.replyToId), + ); + if (bufferText && (replyToConflict || bufferAudioAsVoice !== payload.audioAsVoice)) { void flush({ force: true }); } diff --git a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts b/src/auto-reply/reply/reply-payloads.auto-threading.test.ts index 9cd795bc41..8a3c379b38 100644 --- a/src/auto-reply/reply/reply-payloads.auto-threading.test.ts +++ b/src/auto-reply/reply/reply-payloads.auto-threading.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { applyReplyThreading } from "./reply-payloads.js"; -describe("applyReplyThreading auto-injects replyToCurrent", () => { +describe("applyReplyThreading auto-threading", () => { it("sets replyToId to currentMessageId even without [[reply_to_current]] tag", () => { const result = applyReplyThreading({ payloads: [{ text: "Hello" }], @@ -47,4 +47,29 @@ describe("applyReplyThreading auto-injects replyToCurrent", () => { expect(result).toHaveLength(1); expect(result[0].replyToId).toBeUndefined(); }); + + it("does not bypass off mode for Slack when reply is implicit", () => { + const result = applyReplyThreading({ + payloads: [{ text: "A" }], + replyToMode: "off", + replyToChannel: "slack", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBeUndefined(); + }); + + it("keeps explicit tags for Slack when off mode allows tags", () => { + const result = applyReplyThreading({ + payloads: [{ text: "[[reply_to_current]]A" }], + replyToMode: "off", + replyToChannel: "slack", + currentMessageId: "42", + }); + + expect(result).toHaveLength(1); + expect(result[0].replyToId).toBe("42"); + expect(result[0].replyToTag).toBe(true); + }); }); diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index c5e5572262..b112476839 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -62,10 +62,15 @@ export function applyReplyThreading(params: { }): ReplyPayload[] { const { payloads, replyToMode, replyToChannel, currentMessageId } = params; const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); + const implicitReplyToId = currentMessageId?.trim() || undefined; return payloads - .map((payload) => - applyReplyTagsToPayload({ ...payload, replyToCurrent: true }, currentMessageId), - ) + .map((payload) => { + const autoThreaded = + payload.replyToId || payload.replyToCurrent === false || !implicitReplyToId + ? payload + : { ...payload, replyToId: implicitReplyToId }; + return applyReplyTagsToPayload(autoThreaded, currentMessageId); + }) .filter(isRenderablePayload) .map(applyReplyToMode); }