From 64746c150c4d721fe30dc301073ea5a1ba83f4de Mon Sep 17 00:00:00 2001 From: Hermione Date: Mon, 9 Mar 2026 22:30:24 +0000 Subject: [PATCH] fix(discord): apply effective maxLinesPerMessage in live replies (#40133) Merged via squash. Prepared head SHA: 031d0325347abd11892fbd5f44328f6b3c043902 Co-authored-by: rbutera <6047293+rbutera@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/discord/accounts.test.ts | 61 ++++++++++++++++++- src/discord/accounts.ts | 14 +++++ src/discord/monitor/agent-components.ts | 7 ++- .../monitor/message-handler.process.test.ts | 32 ++++++++++ .../monitor/message-handler.process.ts | 10 ++- .../native-command.commands-allowfrom.test.ts | 45 +++++++++++++- src/discord/monitor/native-command.ts | 5 +- src/discord/monitor/reply-delivery.test.ts | 23 +++++++ src/discord/monitor/reply-delivery.ts | 19 +++++- 10 files changed, 207 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85871923b..534922abe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Docs: https://docs.openclaw.ai - ACP/follow-up hardening: make session restore and prompt completion degrade gracefully on transcript/update failures, enforce bounded tool-location traversal, and skip non-image ACPX turns the runtime cannot serialize. (#41464) Thanks @mbelinky. - Agents/fallback observability: add structured, sanitized model-fallback decision and auth-profile failure-state events with correlated run IDs so cooldown probes and failover paths are easier to trace in logs. (#41337) thanks @altaywtf. - Protocol/Swift model sync: regenerate pending node work Swift bindings after the landed `node.pending.*` schema additions so generated protocol artifacts are consistent again. (#41477) Thanks @mbelinky. +- Discord/reply chunking: resolve the effective `maxLinesPerMessage` config across live reply paths and preserve `chunkMode` in the fast send path so long Discord replies no longer split unexpectedly at the default 17-line limit. (#40133) thanks @rbutera. ## 2026.3.8 diff --git a/src/discord/accounts.test.ts b/src/discord/accounts.test.ts index 6fd11965a..1f6d70b1e 100644 --- a/src/discord/accounts.test.ts +++ b/src/discord/accounts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveDiscordAccount } from "./accounts.js"; +import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "./accounts.js"; describe("resolveDiscordAccount allowFrom precedence", () => { it("prefers accounts.default.allowFrom over top-level for default account", () => { @@ -56,3 +56,62 @@ describe("resolveDiscordAccount allowFrom precedence", () => { expect(resolved.config.allowFrom).toBeUndefined(); }); }); + +describe("resolveDiscordMaxLinesPerMessage", () => { + it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => { + const resolved = resolveDiscordMaxLinesPerMessage({ + cfg: { + channels: { + discord: { + maxLinesPerMessage: 120, + accounts: { + default: { token: "token-default" }, + }, + }, + }, + }, + discordConfig: {}, + accountId: "default", + }); + + expect(resolved).toBe(120); + }); + + it("prefers explicit runtime discord maxLinesPerMessage over merged config", () => { + const resolved = resolveDiscordMaxLinesPerMessage({ + cfg: { + channels: { + discord: { + maxLinesPerMessage: 120, + accounts: { + default: { token: "token-default", maxLinesPerMessage: 80 }, + }, + }, + }, + }, + discordConfig: { maxLinesPerMessage: 55 }, + accountId: "default", + }); + + expect(resolved).toBe(55); + }); + + it("uses per-account discord maxLinesPerMessage over the root value when runtime config omits it", () => { + const resolved = resolveDiscordMaxLinesPerMessage({ + cfg: { + channels: { + discord: { + maxLinesPerMessage: 120, + accounts: { + work: { token: "token-work", maxLinesPerMessage: 80 }, + }, + }, + }, + }, + discordConfig: {}, + accountId: "work", + }); + + expect(resolved).toBe(80); + }); +}); diff --git a/src/discord/accounts.ts b/src/discord/accounts.ts index 75eeff40b..b4e71c783 100644 --- a/src/discord/accounts.ts +++ b/src/discord/accounts.ts @@ -68,6 +68,20 @@ export function resolveDiscordAccount(params: { }; } +export function resolveDiscordMaxLinesPerMessage(params: { + cfg: OpenClawConfig; + discordConfig?: DiscordAccountConfig | null; + accountId?: string | null; +}): number | undefined { + if (typeof params.discordConfig?.maxLinesPerMessage === "number") { + return params.discordConfig.maxLinesPerMessage; + } + return resolveDiscordAccount({ + cfg: params.cfg, + accountId: params.accountId, + }).config.maxLinesPerMessage; +} + export function listEnabledDiscordAccounts(cfg: OpenClawConfig): ResolvedDiscordAccount[] { return listDiscordAccountIds(cfg) .map((accountId) => resolveDiscordAccount({ cfg, accountId })) diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index deeb9b352..16b3f564b 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -43,6 +43,7 @@ import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, } from "../../security/dm-policy-shared.js"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import { createDiscordFormModal, @@ -1017,7 +1018,11 @@ async function dispatchDiscordComponentEvent(params: { replyToId, replyToMode, textLimit, - maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ + cfg: ctx.cfg, + discordConfig: ctx.discordConfig, + accountId, + }), tableMode, chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId), mediaLocalRoots, diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts index 9bc9cf774..8b059d00f 100644 --- a/src/discord/monitor/message-handler.process.test.ts +++ b/src/discord/monitor/message-handler.process.test.ts @@ -502,6 +502,38 @@ describe("processDiscordMessage draft streaming", () => { expect(deliverDiscordReply).toHaveBeenCalledTimes(1); }); + it("uses root discord maxLinesPerMessage for preview finalization when runtime config omits it", async () => { + const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n"); + dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { + await params?.dispatcher.sendFinalReply({ text: longReply }); + return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } }; + }); + + const ctx = await createBaseContext({ + cfg: { + messages: { ackReaction: "👀" }, + session: { store: "/tmp/openclaw-discord-process-test-sessions.json" }, + channels: { + discord: { + maxLinesPerMessage: 120, + }, + }, + }, + discordConfig: { streamMode: "partial" }, + }); + + // oxlint-disable-next-line typescript/no-explicit-any + await processDiscordMessage(ctx as any); + + expect(editMessageDiscord).toHaveBeenCalledWith( + "c1", + "preview-1", + { content: longReply }, + { rest: {} }, + ); + expect(deliverDiscordReply).not.toHaveBeenCalled(); + }); + it("suppresses reasoning payload delivery to Discord", async () => { mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true }); await processStreamOffDiscordMessage(); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 85bbccd59..c283658ac 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -32,6 +32,7 @@ import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { truncateUtf16Safe } from "../../utils.js"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; import { createDiscordDraftStream } from "../draft-stream.js"; @@ -426,6 +427,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) channel: "discord", accountId, }); + const maxLinesPerMessage = resolveDiscordMaxLinesPerMessage({ + cfg, + discordConfig, + accountId, + }); const chunkMode = resolveChunkMode(cfg, "discord", accountId); const typingCallbacks = createTypingCallbacks({ @@ -484,7 +490,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const formatted = convertMarkdownTables(text, tableMode); const chunks = chunkDiscordTextWithMode(formatted, { maxChars: draftMaxChars, - maxLines: discordConfig?.maxLinesPerMessage, + maxLines: maxLinesPerMessage, chunkMode, }); if (!chunks.length && formatted) { @@ -687,7 +693,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) replyToId, replyToMode, textLimit, - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + maxLinesPerMessage, tableMode, chunkMode, sessionKey: ctxPayload.SessionKey, diff --git a/src/discord/monitor/native-command.commands-allowfrom.test.ts b/src/discord/monitor/native-command.commands-allowfrom.test.ts index 218df22f0..5144eb742 100644 --- a/src/discord/monitor/native-command.commands-allowfrom.test.ts +++ b/src/discord/monitor/native-command.commands-allowfrom.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; import type { OpenClawConfig } from "../../config/config.js"; +import type { DiscordAccountConfig } from "../../config/types.discord.js"; import * as pluginCommandsModule from "../../plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; import { @@ -49,7 +50,7 @@ function createConfig(): OpenClawConfig { } as OpenClawConfig; } -function createCommand(cfg: OpenClawConfig) { +function createCommand(cfg: OpenClawConfig, discordConfig?: DiscordAccountConfig) { const commandSpec: NativeCommandSpec = { name: "status", description: "Status", @@ -58,7 +59,7 @@ function createCommand(cfg: OpenClawConfig) { return createDiscordNativeCommand({ command: commandSpec, cfg, - discordConfig: cfg.channels?.discord ?? {}, + discordConfig: discordConfig ?? cfg.channels?.discord ?? {}, accountId: "default", sessionPrefix: "discord:slash", ephemeralDefault: true, @@ -79,10 +80,11 @@ function createDispatchSpy() { async function runGuildSlashCommand(params?: { userId?: string; mutateConfig?: (cfg: OpenClawConfig) => void; + runtimeDiscordConfig?: DiscordAccountConfig; }) { const cfg = createConfig(); params?.mutateConfig?.(cfg); - const command = createCommand(cfg); + const command = createCommand(cfg, params?.runtimeDiscordConfig); const interaction = createInteraction({ userId: params?.userId }); vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); const dispatchSpy = createDispatchSpy(); @@ -164,4 +166,41 @@ describe("Discord native slash commands with commands.allowFrom", () => { expect(dispatchSpy).not.toHaveBeenCalled(); expectUnauthorizedReply(interaction); }); + + it("uses the root discord maxLinesPerMessage when runtime discordConfig omits it", async () => { + const longReply = Array.from({ length: 20 }, (_value, index) => `Line ${index + 1}`).join("\n"); + const { interaction } = await runGuildSlashCommand({ + mutateConfig: (cfg) => { + cfg.channels = { + ...cfg.channels, + discord: { + ...cfg.channels?.discord, + maxLinesPerMessage: 120, + }, + }; + }, + runtimeDiscordConfig: { + groupPolicy: "allowlist", + guilds: { + "345678901234567890": { + channels: { + "234567890123456789": { + allow: true, + requireMention: false, + }, + }, + }, + }, + }, + }); + + const dispatchCall = vi.mocked(dispatcherModule.dispatchReplyWithDispatcher).mock + .calls[0]?.[0] as + | Parameters[0] + | undefined; + await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" }); + + expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ content: longReply })); + expect(interaction.followUp).not.toHaveBeenCalled(); + }); }); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 23b5bcd4c..4af7d5ef6 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -56,6 +56,7 @@ import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; import { chunkItems } from "../../utils/chunk-items.js"; import { withTimeout } from "../../utils/with-timeout.js"; import { loadWebMedia } from "../../web/media.js"; +import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { isDiscordGroupAllowedByPolicy, @@ -1571,7 +1572,7 @@ async function dispatchDiscordCommandInteraction(params: { textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { fallbackLimit: 2000, }), - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), preferFollowUp, chunkMode: resolveChunkMode(cfg, "discord", accountId), }); @@ -1706,7 +1707,7 @@ async function dispatchDiscordCommandInteraction(params: { textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { fallbackLimit: 2000, }), - maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), preferFollowUp: preferFollowUp || didReply, chunkMode: resolveChunkMode(cfg, "discord", accountId), }); diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 3274a669c..3d0357ef4 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -256,6 +256,29 @@ describe("deliverDiscordReply", () => { expect(sendDiscordTextMock.mock.calls[1]?.[1]).toBe("789"); }); + it("passes maxLinesPerMessage and chunkMode through the fast path", async () => { + const fakeRest = {} as import("@buape/carbon").RequestClient; + + await deliverDiscordReply({ + replies: [{ text: Array.from({ length: 18 }, (_, index) => `line ${index + 1}`).join("\n") }], + target: "channel:789", + token: "token", + rest: fakeRest, + runtime, + textLimit: 2000, + maxLinesPerMessage: 120, + chunkMode: "newline", + }); + + expect(sendMessageDiscordMock).not.toHaveBeenCalled(); + expect(sendDiscordTextMock).toHaveBeenCalledTimes(1); + const firstSendDiscordTextCall = sendDiscordTextMock.mock.calls[0]; + const [, , , , , maxLinesPerMessageArg, , , chunkModeArg] = firstSendDiscordTextCall ?? []; + + expect(maxLinesPerMessageArg).toBe(120); + expect(chunkModeArg).toBe("newline"); + }); + it("falls back to sendMessageDiscord when rest is not provided", async () => { await deliverDiscordReply({ replies: [{ text: "single chunk" }], diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index 11fc1733e..d3e7ef9bf 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -130,9 +130,11 @@ async function sendDiscordChunkWithFallback(params: { text: string; token: string; accountId?: string; + maxLinesPerMessage?: number; rest?: RequestClient; replyTo?: string; binding?: DiscordThreadBindingLookupRecord; + chunkMode?: ChunkMode; username?: string; avatarUrl?: string; /** Pre-resolved channel ID to bypass redundant resolution per chunk. */ @@ -169,7 +171,18 @@ async function sendDiscordChunkWithFallback(params: { if (params.channelId && params.request && params.rest) { const { channelId, request, rest } = params; await sendWithRetry( - () => sendDiscordText(rest, channelId, text, params.replyTo, request), + () => + sendDiscordText( + rest, + channelId, + text, + params.replyTo, + request, + params.maxLinesPerMessage, + undefined, + undefined, + params.chunkMode, + ), params.retryConfig, ); return; @@ -294,8 +307,10 @@ export async function deliverDiscordReply(params: { token: params.token, rest: params.rest, accountId: params.accountId, + maxLinesPerMessage: params.maxLinesPerMessage, replyTo, binding, + chunkMode: params.chunkMode, username: persona.username, avatarUrl: persona.avatarUrl, channelId, @@ -329,8 +344,10 @@ export async function deliverDiscordReply(params: { token: params.token, rest: params.rest, accountId: params.accountId, + maxLinesPerMessage: params.maxLinesPerMessage, replyTo: resolveReplyTo(), binding, + chunkMode: params.chunkMode, username: persona.username, avatarUrl: persona.avatarUrl, channelId,