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
This commit is contained in:
Hermione
2026-03-09 22:30:24 +00:00
committed by GitHub
parent 56f787e3c0
commit 64746c150c
10 changed files with 207 additions and 10 deletions

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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 }))

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,

View File

@@ -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<typeof dispatcherModule.dispatchReplyWithDispatcher>[0]
| undefined;
await dispatchCall?.dispatcherOptions.deliver({ text: longReply }, { kind: "final" });
expect(interaction.reply).toHaveBeenCalledWith(expect.objectContaining({ content: longReply }));
expect(interaction.followUp).not.toHaveBeenCalled();
});
});

View File

@@ -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),
});

View File

@@ -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" }],

View File

@@ -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,