From e1fd27fb24ae81e27cf4ac1297410491009a70c0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 11:06:24 +0100 Subject: [PATCH] feat(messages): add global visible replies mode --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +-- docs/channels/groups.md | 12 +++++++++ docs/gateway/config-channels.md | 5 ++-- docs/gateway/configuration-examples.md | 2 ++ docs/gateway/configuration.md | 3 ++- .../reply/source-reply-delivery-mode.test.ts | 27 +++++++++++++++++++ .../reply/source-reply-delivery-mode.ts | 8 +++--- src/config/schema.base.generated.ts | 16 +++++++++-- src/config/schema.help.quality.test.ts | 1 + src/config/schema.help.ts | 4 ++- src/config/schema.labels.ts | 1 + src/config/types.messages.ts | 8 ++++++ src/config/zod-schema.session.ts | 1 + 14 files changed, 81 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42447bcc1c7..11053331aeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Messages: add global `messages.visibleReplies` so operators can require visible output to go through `message(action=send)` for any source chat, while `messages.groupChat.visibleReplies` stays available as the group/channel override. Thanks @scoootscooob. - Gateway/dev: run `pnpm gateway:watch` through a named tmux session by default, with `gateway:watch:raw` and `OPENCLAW_GATEWAY_WATCH_TMUX=0` for foreground mode, so repeated starts respawn an inspectable watcher without trapping the invoking agent shell. Thanks @vincentkoc. - Plugin SDK: mark remaining legacy alias exports and diffs tool/config aliases with deprecation metadata, and add a guard so future legacy alias comments require `@deprecated` tags. Thanks @vincentkoc. - CLI/QR/dependencies: internalize small terminal progress and QR wrapper helpers while keeping the real QR encoder dependency direct, reducing the default runtime dependency graph without changing QR output behavior. Thanks @vincentkoc. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index fdab1e35af2..7d80e892f2a 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -592d25e08647ced4fae0c4fdbff95e50d1749c42d39070f6b6bc6a3e0475d4f0 config-baseline.json -9cd2c40b4a45976b74458f9ada8ecc31c532ee81f10145a9828bbff31777c03e config-baseline.core.json +664d715fc9aba21236c9ef31e30a81f7ff96ede9a3b77273af569288ece0e7f7 config-baseline.json +0cc8ae3ae49d324face60240b4d3ed545c9ccec9b333bf1a1d98887151d37b77 config-baseline.core.json 9f5fad66a49fa618d64a963470aa69fed9fe4b4639cc4321f9ec04bfb2f8aa50 config-baseline.channel.json 0dd6583fafae6c9134e46c4cf9bddee9822d6436436dcb1a6dcba6d012962e51 config-baseline.plugin.json diff --git a/docs/channels/groups.md b/docs/channels/groups.md index 45ca1eb80cf..4e9db6a2192 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -43,6 +43,8 @@ otherwise -> reply For group/channel rooms, OpenClaw defaults to `messages.groupChat.visibleReplies: "message_tool"`. That means the agent still processes the turn and can update memory/session state, but its normal final answer is not automatically posted back into the room. To speak visibly, the agent uses `message(action=send)`. +For direct chats and any other source turn, use `messages.visibleReplies: "message_tool"` to apply the same tool-only visible-reply behavior globally. `messages.groupChat.visibleReplies` remains the more specific override for group/channel rooms. + This replaces the old pattern of forcing the model to answer `NO_REPLY` for most lurk-mode turns. In tool-only mode, doing nothing visible simply means not calling the message tool. Typing indicators are still sent while the agent works in tool-only mode. The default group typing mode is upgraded from "message" to "instant" for these turns because there may never be normal assistant message text before the agent decides whether to call the message tool. Explicit typing-mode config still wins. @@ -59,6 +61,16 @@ To restore legacy automatic final replies for group/channel rooms: } ``` +To require visible output to go through the message tool for every source chat: + +```json5 +{ + messages: { + visibleReplies: "message_tool", + }, +} +``` + Native slash commands (Discord, Telegram, and other surfaces with native command support) bypass `visibleReplies: "message_tool"` and always reply visibly so the channel-native command UI gets the response it expects. This applies to validated native command turns only; text-typed `/...` commands and ordinary chat turns still follow the configured group default. ## Context visibility and allowlists diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index dd574bf9fd8..2672508ce3d 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -770,7 +770,7 @@ See the full channel index: [Channels](/channels). Group messages default to **require mention** (metadata mention or safe regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats. -Visible replies are controlled separately. Group/channel rooms default to `messages.groupChat.visibleReplies: "message_tool"`: OpenClaw still processes the turn, but normal final replies stay private and visible room output requires `message(action=send)`. Set `"automatic"` only when you want the legacy behavior where normal replies are posted back to the room. +Visible replies are controlled separately. Group/channel rooms default to `messages.groupChat.visibleReplies: "message_tool"`: OpenClaw still processes the turn, but normal final replies stay private and visible room output requires `message(action=send)`. Set `"automatic"` only when you want the legacy behavior where normal replies are posted back to the room. To apply the same tool-only visible-reply behavior to direct chats too, set `messages.visibleReplies: "message_tool"`. **Mention types:** @@ -781,6 +781,7 @@ Visible replies are controlled separately. Group/channel rooms default to `messa ```json5 { messages: { + visibleReplies: "automatic", // global default for direct/source chats groupChat: { historyLimit: 50, visibleReplies: "message_tool", // default; use "automatic" for legacy final replies @@ -794,7 +795,7 @@ Visible replies are controlled separately. Group/channel rooms default to `messa `messages.groupChat.historyLimit` sets the global default. Channels can override with `channels..historyLimit` (or per-account). Set `0` to disable. -`messages.groupChat.visibleReplies` is global for group/channel source turns; channel allowlists and mention gating still decide whether a turn is processed. +`messages.visibleReplies` is the global source-turn default; `messages.groupChat.visibleReplies` overrides it for group/channel source turns. Channel allowlists and mention gating still decide whether a turn is processed. #### DM history limits diff --git a/docs/gateway/configuration-examples.md b/docs/gateway/configuration-examples.md index d69a8cb55a5..94470e66e73 100644 --- a/docs/gateway/configuration-examples.md +++ b/docs/gateway/configuration-examples.md @@ -42,6 +42,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. }, }, messages: { + visibleReplies: "automatic", groupChat: { visibleReplies: "message_tool", // default; use "automatic" for legacy room replies }, @@ -101,6 +102,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number. // Message formatting messages: { messagePrefix: "[openclaw]", + visibleReplies: "automatic", responsePrefix: ">", ackReaction: "👀", ackReactionScope: "group-mentions", diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 974961cb76c..cc3e220eeff 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -184,6 +184,7 @@ cannot roll back unrelated user settings. ```json5 { messages: { + visibleReplies: "automatic", // set "message_tool" to require message-tool sends everywhere groupChat: { visibleReplies: "message_tool", // default; use "automatic" for legacy room replies }, @@ -208,7 +209,7 @@ cannot roll back unrelated user settings. - **Metadata mentions**: native @-mentions (WhatsApp tap-to-mention, Telegram @bot, etc.) - **Text patterns**: safe regex patterns in `mentionPatterns` - - **Visible replies**: `message_tool` keeps normal final replies private; the agent must call `message(action=send)` to post visibly in the group/channel. + - **Visible replies**: `messages.visibleReplies` can require message-tool sends globally; `messages.groupChat.visibleReplies` overrides that for groups/channels. - See [full reference](/gateway/config-channels#group-chat-mention-gating) for visible reply modes, per-channel overrides, and self-chat mode. diff --git a/src/auto-reply/reply/source-reply-delivery-mode.test.ts b/src/auto-reply/reply/source-reply-delivery-mode.test.ts index abcb1da2305..39f7a0319c9 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.test.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.test.ts @@ -13,6 +13,11 @@ const automaticGroupReplyConfig = { }, }, } as const satisfies OpenClawConfig; +const globalToolOnlyReplyConfig = { + messages: { + visibleReplies: "message_tool", + }, +} as const satisfies OpenClawConfig; describe("resolveSourceReplyDeliveryMode", () => { it("defaults groups and channels to message-tool-only delivery", () => { @@ -43,6 +48,28 @@ describe("resolveSourceReplyDeliveryMode", () => { ).toBe("automatic"); }); + it("allows message-tool-only delivery for any source chat via global config", () => { + for (const ChatType of ["direct", "group", "channel"] as const) { + expect( + resolveSourceReplyDeliveryMode({ cfg: globalToolOnlyReplyConfig, ctx: { ChatType } }), + ).toBe("message_tool_only"); + } + }); + + it("lets group/channel config override the global visible reply mode", () => { + expect( + resolveSourceReplyDeliveryMode({ + cfg: { + messages: { + visibleReplies: "message_tool", + groupChat: { visibleReplies: "automatic" }, + }, + }, + ctx: { ChatType: "channel" }, + }), + ).toBe("automatic"); + }); + it("treats native commands as explicit replies in groups", () => { expect( resolveSourceReplyDeliveryMode({ diff --git a/src/auto-reply/reply/source-reply-delivery-mode.ts b/src/auto-reply/reply/source-reply-delivery-mode.ts index 44c4c8b2e51..1de51d6712e 100644 --- a/src/auto-reply/reply/source-reply-delivery-mode.ts +++ b/src/auto-reply/reply/source-reply-delivery-mode.ts @@ -21,11 +21,11 @@ export function resolveSourceReplyDeliveryMode(params: { } const chatType = normalizeChatType(params.ctx.ChatType); if (chatType === "group" || chatType === "channel") { - return params.cfg.messages?.groupChat?.visibleReplies === "automatic" - ? "automatic" - : "message_tool_only"; + const configuredMode = + params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies; + return configuredMode === "automatic" ? "automatic" : "message_tool_only"; } - return "automatic"; + return params.cfg.messages?.visibleReplies === "message_tool" ? "message_tool_only" : "automatic"; } export type SourceReplyVisibilityPolicy = { diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 72dc15dd1bf..41ed87587a2 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -18873,6 +18873,13 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "Prefix text prepended to inbound user messages before they are handed to the agent runtime. Use this sparingly for channel context markers and keep it stable across sessions.", }, + visibleReplies: { + type: "string", + enum: ["automatic", "message_tool"], + title: "Visible Replies", + description: + 'Controls visible source replies across direct, group, and channel conversations. "message_tool" keeps normal final replies private and requires message(action=send) for visible output; "automatic" posts normal replies as before.', + }, responsePrefix: { type: "string", title: "Outbound Response Prefix", @@ -18904,7 +18911,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { enum: ["automatic", "message_tool"], title: "Group Visible Replies", description: - 'Controls visible group/channel replies. "message_tool" keeps normal final replies private and requires message(action=send) for room output; "automatic" posts normal replies as before.', + 'Overrides visible source replies for group/channel conversations. "message_tool" keeps normal final replies private and requires message(action=send) for room output; "automatic" posts normal replies as before.', }, }, additionalProperties: false, @@ -28198,6 +28205,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { help: "Prefix text prepended to inbound user messages before they are handed to the agent runtime. Use this sparingly for channel context markers and keep it stable across sessions.", tags: ["advanced"], }, + "messages.visibleReplies": { + label: "Visible Replies", + help: 'Controls visible source replies across direct, group, and channel conversations. "message_tool" keeps normal final replies private and requires message(action=send) for visible output; "automatic" posts normal replies as before.', + tags: ["advanced"], + }, "messages.responsePrefix": { label: "Outbound Response Prefix", help: "Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.", @@ -28220,7 +28232,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "messages.groupChat.visibleReplies": { label: "Group Visible Replies", - help: 'Controls visible group/channel replies. "message_tool" keeps normal final replies private and requires message(action=send) for room output; "automatic" posts normal replies as before.', + help: 'Overrides visible source replies for group/channel conversations. "message_tool" keeps normal final replies private and requires message(action=send) for room output; "automatic" posts normal replies as before.', tags: ["advanced"], }, "messages.queue": { diff --git a/src/config/schema.help.quality.test.ts b/src/config/schema.help.quality.test.ts index b9f485460a8..6d910d1f402 100644 --- a/src/config/schema.help.quality.test.ts +++ b/src/config/schema.help.quality.test.ts @@ -244,6 +244,7 @@ const TARGET_KEYS = [ "hooks.internal.load.extraDirs", "messages", "messages.messagePrefix", + "messages.visibleReplies", "messages.responsePrefix", "messages.groupChat", "messages.groupChat.mentionPatterns", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 06854bb109b..20b8c717915 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1614,6 +1614,8 @@ export const FIELD_HELP: Record = { "Message formatting, acknowledgment, queueing, debounce, and status reaction behavior for inbound/outbound chat flows. Use this section when channel responsiveness or message UX needs adjustment.", "messages.messagePrefix": "Prefix text prepended to inbound user messages before they are handed to the agent runtime. Use this sparingly for channel context markers and keep it stable across sessions.", + "messages.visibleReplies": + 'Controls visible source replies across direct, group, and channel conversations. "message_tool" keeps normal final replies private and requires message(action=send) for visible output; "automatic" posts normal replies as before.', "messages.responsePrefix": "Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.", "messages.groupChat": @@ -1623,7 +1625,7 @@ export const FIELD_HELP: Record = { "messages.groupChat.historyLimit": "Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.", "messages.groupChat.visibleReplies": - 'Controls visible group/channel replies. "message_tool" keeps normal final replies private and requires message(action=send) for room output; "automatic" posts normal replies as before.', + 'Overrides visible source replies for group/channel conversations. "message_tool" keeps normal final replies private and requires message(action=send) for room output; "automatic" posts normal replies as before.', "messages.queue": "Inbound message queue strategy used to buffer bursts before processing turns. Tune this for busy channels where sequential processing or batching behavior matters.", "messages.queue.mode": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 54045431afc..7a59595c915 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -827,6 +827,7 @@ export const FIELD_LABELS: Record = { "talk.silenceTimeoutMs": "Talk Silence Timeout (ms)", messages: "Messages", "messages.messagePrefix": "Inbound Message Prefix", + "messages.visibleReplies": "Visible Replies", "messages.responsePrefix": "Outbound Response Prefix", "messages.groupChat": "Group Chat Rules", "messages.groupChat.mentionPatterns": "Group Mention Patterns", diff --git a/src/config/types.messages.ts b/src/config/types.messages.ts index fe0e19c3c4b..0cd917d8342 100644 --- a/src/config/types.messages.ts +++ b/src/config/types.messages.ts @@ -91,6 +91,14 @@ export type StatusReactionsConfig = { export type MessagesConfig = { /** @deprecated Use `whatsapp.messagePrefix` (WhatsApp-only inbound prefix). */ messagePrefix?: string; + /** + * Controls how source turns produce visible replies across direct, group, and + * channel conversations. Group/channel turns still default to + * `groupChat.visibleReplies` when it is set. + * + * Default: "automatic" for direct chats, "message_tool" for groups/channels. + */ + visibleReplies?: "automatic" | "message_tool"; /** * Prefix auto-added to all outbound replies. * diff --git a/src/config/zod-schema.session.ts b/src/config/zod-schema.session.ts index a1cf4b96ca4..1527288fe41 100644 --- a/src/config/zod-schema.session.ts +++ b/src/config/zod-schema.session.ts @@ -145,6 +145,7 @@ export const SessionSchema = z export const MessagesSchema = z .object({ messagePrefix: z.string().optional(), + visibleReplies: z.enum(["automatic", "message_tool"]).optional(), responsePrefix: z.string().optional(), groupChat: GroupChatSchema, queue: QueueSchema,