fix(config): normalize channel streaming config shape (#61381)

* feat(config): add canonical streaming config helpers

* refactor(runtime): prefer canonical streaming accessors

* feat(config): normalize preview channel streaming shape

* test(config): lock streaming normalization followups

* fix(config): polish streaming migration edges

* chore(config): refresh streaming baseline hash
This commit is contained in:
Vincent Koc
2026-04-06 05:08:20 +01:00
committed by GitHub
parent 93ddcb37de
commit 0fdf9e874b
48 changed files with 3012 additions and 705 deletions

View File

@@ -1,4 +1,4 @@
cce3a44a277150049986fa95d0fee3afd27804ddaa4af45323c2d7ca03679401 config-baseline.json
433dc1a6776b3c782524489d6bb22c770015d4915f6886da89bb3538698f0057 config-baseline.json
71414a189b62e3a362443068cb911372b2fe326a0bf43237a36d475533508499 config-baseline.core.json
5e45b930d3518f34a05cacd7128ad459dd26ae21394a99936c30d2d7201db9e6 config-baseline.channel.json
a2c4233e7884f8510f7bc3dc4587ddd9389bf36fd18450b6d1a63e33f906cfb3 config-baseline.plugin.json
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json

View File

@@ -3,6 +3,7 @@ import {
normalizeAccountId,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-resolution";
import { resolveChannelStreamingChunkMode } from "openclaw/plugin-sdk/channel-streaming";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
@@ -34,7 +35,10 @@ function mergeBlueBubblesAccountConfig(
accountId,
omitKeys: ["defaultAccount"],
});
return { ...merged, chunkMode: merged.chunkMode ?? "length" };
return {
...merged,
chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode ?? "length",
};
}
export function resolveBlueBubblesAccount(params: {

View File

@@ -33,15 +33,31 @@ export const discordChannelConfigUiHints = {
label: "Discord Streaming Mode",
help: 'Unified Discord stream preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord. Legacy boolean/streamMode keys are auto-mapped.',
},
"draftChunk.minChars": {
"streaming.mode": {
label: "Discord Streaming Mode",
help: 'Canonical Discord preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord.',
},
"streaming.chunkMode": {
label: "Discord Chunk Mode",
help: 'Chunking mode for outbound Discord text delivery: "length" (default) or "newline".',
},
"streaming.block.enabled": {
label: "Discord Block Streaming Enabled",
help: 'Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode="block".',
},
"streaming.block.coalesce": {
label: "Discord Block Streaming Coalesce",
help: "Merge streamed Discord block replies before final delivery.",
},
"streaming.preview.chunk.minChars": {
label: "Discord Draft Chunk Min Chars",
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming="block" (default: 200).',
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode="block" (default: 200).',
},
"draftChunk.maxChars": {
"streaming.preview.chunk.maxChars": {
label: "Discord Draft Chunk Max Chars",
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming="block" (default: 800; clamped to channels.discord.textChunkLimit).',
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming.mode="block" (default: 800; clamped to channels.discord.textChunkLimit).',
},
"draftChunk.breakPreference": {
"streaming.preview.chunk.breakPreference": {
label: "Discord Draft Chunk Break Preference",
help: "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.",
},

View File

@@ -11,6 +11,14 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asObjectRecord(owner[key]);
if (existing) {
return { ...existing };
}
return {};
}
function normalizeDiscordDmAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
@@ -108,45 +116,100 @@ function normalizeDiscordStreamingAliases(params: {
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveDiscordPreviewStreamMode(updated);
const beforeStreaming = params.entry.streaming;
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
const hasLegacyFlatFields =
params.entry.chunkMode !== undefined ||
params.entry.blockStreaming !== undefined ||
params.entry.draftChunk !== undefined ||
params.entry.blockStreamingCoalesce !== undefined;
const resolved = resolveDiscordPreviewStreamMode(params.entry);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
typeof beforeStreaming === "string" ||
hasLegacyFlatFields;
if (!shouldNormalize) {
return { entry: updated, changed: false };
return { entry: params.entry, changed: false };
}
let updated = { ...params.entry };
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
const streaming = ensureNestedRecord(updated, "streaming");
const block = ensureNestedRecord(streaming, "block");
const preview = ensureNestedRecord(streaming, "preview");
if (
(hadLegacyStreamMode || typeof beforeStreaming === "boolean" || typeof beforeStreaming === "string") &&
streaming.mode === undefined
) {
streaming.mode = resolved;
if (hadLegacyStreamMode) {
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
} else if (typeof beforeStreaming === "string") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
}
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
delete updated.streamMode;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
streaming.chunkMode = updated.chunkMode;
delete updated.chunkMode;
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
);
changed = true;
}
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
block.enabled = updated.blockStreaming;
delete updated.blockStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
);
changed = true;
}
if (updated.draftChunk !== undefined && preview.chunk === undefined) {
preview.chunk = updated.draftChunk;
delete updated.draftChunk;
params.changes.push(
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
);
changed = true;
}
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
block.coalesce = updated.blockStreamingCoalesce;
delete updated.blockStreamingCoalesce;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
);
changed = true;
}
if (Object.keys(preview).length > 0) {
streaming.preview = preview;
}
if (Object.keys(block).length > 0) {
streaming.block = block;
}
updated.streaming = streaming;
if (
params.pathPrefix.startsWith("channels.discord") &&
resolved === "off" &&
hadLegacyStreamMode
) {
params.changes.push(
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming.mode="partial" to opt in explicitly.`,
);
}
return { entry: updated, changed };
@@ -160,8 +223,11 @@ function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
typeof entry.streaming === "string" ||
entry.chunkMode !== undefined ||
entry.blockStreaming !== undefined ||
entry.draftChunk !== undefined ||
entry.blockStreamingCoalesce !== undefined
);
}
@@ -274,13 +340,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
"channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
"channels.discord.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyDiscordAccountStreamingAliases,
},
{

View File

@@ -15,8 +15,11 @@ function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
typeof entry.streaming === "string" ||
entry.chunkMode !== undefined ||
entry.blockStreaming !== undefined ||
entry.draftChunk !== undefined ||
entry.blockStreamingCoalesce !== undefined
);
}
@@ -32,13 +35,13 @@ export const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
"channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyDiscordStreamingAliases,
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming.",
"channels.discord.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyDiscordAccountStreamingAliases,
},
];

View File

@@ -2,11 +2,100 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { describe, expect, it } from "vitest";
import {
collectDiscordNumericIdWarnings,
discordDoctor,
maybeRepairDiscordNumericIds,
scanDiscordNumericIdEntries,
} from "./doctor.js";
describe("discord doctor", () => {
it("normalizes legacy discord streaming aliases into the nested streaming shape", () => {
const normalize = discordDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
discord: {
streamMode: "block",
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
minChars: 120,
},
accounts: {
work: {
streaming: false,
blockStreamingCoalesce: {
idleMs: 250,
},
},
},
},
},
} as never,
});
expect(result.config.channels?.discord?.streaming).toEqual({
mode: "block",
chunkMode: "newline",
block: {
enabled: true,
},
preview: {
chunk: {
minChars: 120,
},
},
});
expect(result.config.channels?.discord?.accounts?.work?.streaming).toEqual({
mode: "off",
block: {
coalesce: {
idleMs: 250,
},
},
});
expect(result.changes).toEqual(
expect.arrayContaining([
"Moved channels.discord.streamMode → channels.discord.streaming.mode (block).",
"Moved channels.discord.chunkMode → channels.discord.streaming.chunkMode.",
"Moved channels.discord.blockStreaming → channels.discord.streaming.block.enabled.",
"Moved channels.discord.draftChunk → channels.discord.streaming.preview.chunk.",
"Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).",
"Moved channels.discord.accounts.work.blockStreamingCoalesce → channels.discord.accounts.work.streaming.block.coalesce.",
]),
);
});
it("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
const normalize = discordDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
discord: {
streamMode: "block",
streaming: false,
},
},
} as never,
});
expect(result.config.channels?.discord?.streaming).toEqual({
mode: "block",
});
expect(
result.changes.filter((change) => change.includes("channels.discord.streaming.mode")),
).toEqual(["Moved channels.discord.streamMode → channels.discord.streaming.mode (block)."]);
});
it("finds numeric id entries across discord scopes", () => {
const cfg = {
channels: {

View File

@@ -21,6 +21,14 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asObjectRecord(owner[key]);
if (existing) {
return { ...existing };
}
return {};
}
function sanitizeForLog(value: string): string {
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
@@ -138,45 +146,101 @@ function normalizeDiscordStreamingAliases(params: {
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveDiscordPreviewStreamMode(updated);
const beforeStreaming = params.entry.streaming;
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
const hasLegacyFlatFields =
params.entry.chunkMode !== undefined ||
params.entry.blockStreaming !== undefined ||
params.entry.draftChunk !== undefined ||
params.entry.blockStreamingCoalesce !== undefined;
const resolved = resolveDiscordPreviewStreamMode(params.entry);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
typeof beforeStreaming === "string" ||
hasLegacyFlatFields;
if (!shouldNormalize) {
return { entry: updated, changed: false };
return { entry: params.entry, changed: false };
}
let updated = { ...params.entry };
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
const streaming = ensureNestedRecord(updated, "streaming");
const block = ensureNestedRecord(streaming, "block");
const preview = ensureNestedRecord(streaming, "preview");
if (
(hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
typeof beforeStreaming === "string") &&
streaming.mode === undefined
) {
streaming.mode = resolved;
if (hadLegacyStreamMode) {
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
} else if (typeof beforeStreaming === "boolean") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
} else if (typeof beforeStreaming === "string") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
}
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
delete updated.streamMode;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
streaming.chunkMode = updated.chunkMode;
delete updated.chunkMode;
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
);
changed = true;
}
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
block.enabled = updated.blockStreaming;
delete updated.blockStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
);
changed = true;
}
if (updated.draftChunk !== undefined && preview.chunk === undefined) {
preview.chunk = updated.draftChunk;
delete updated.draftChunk;
params.changes.push(
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
);
changed = true;
}
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
block.coalesce = updated.blockStreamingCoalesce;
delete updated.blockStreamingCoalesce;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
);
changed = true;
}
if (Object.keys(preview).length > 0) {
streaming.preview = preview;
}
if (Object.keys(block).length > 0) {
streaming.block = block;
}
updated.streaming = streaming;
if (
params.pathPrefix.startsWith("channels.discord") &&
resolved === "off" &&
hadLegacyStreamMode
) {
params.changes.push(
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming.mode="partial" to opt in explicitly.`,
);
}
return { entry: updated, changed };

View File

@@ -1,3 +1,4 @@
import { resolveChannelStreamingPreviewChunk } from "openclaw/plugin-sdk/channel-streaming";
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
@@ -20,7 +21,11 @@ export function resolveDiscordDraftStreamingChunking(
});
const normalizedAccountId = normalizeAccountId(accountId);
const accountCfg = resolveAccountEntry(cfg?.channels?.discord?.accounts, normalizedAccountId);
const draftCfg = accountCfg?.draftChunk ?? cfg?.channels?.discord?.draftChunk;
const draftCfg =
resolveChannelStreamingPreviewChunk(accountCfg) ??
resolveChannelStreamingPreviewChunk(cfg?.channels?.discord) ??
accountCfg?.draftChunk ??
cfg?.channels?.discord?.draftChunk;
const maxRequested = Math.max(
1,

View File

@@ -13,6 +13,7 @@ import {
resolveEnvelopeFormatOptions,
} from "openclaw/plugin-sdk/channel-inbound";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
@@ -544,9 +545,8 @@ export async function processDiscordMessage(
const discordStreamMode = resolveDiscordPreviewStreamMode(discordConfig);
const draftMaxChars = Math.min(textLimit, 2000);
const accountBlockStreamingEnabled =
typeof discordConfig?.blockStreaming === "boolean"
? discordConfig.blockStreaming
: cfg.agents?.defaults?.blockStreamingDefault === "on";
resolveChannelStreamingBlockEnabled(discordConfig) ??
cfg.agents?.defaults?.blockStreamingDefault === "on";
const canStreamDraft = discordStreamMode !== "off" && !accountBlockStreamingEnabled;
const draftReplyToMessageId = () => replyReference.use();
const deliverChannelId = deliverTarget.startsWith("channel:")
@@ -835,6 +835,7 @@ export async function processDiscordMessage(
},
});
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
let dispatchResult: Awaited<ReturnType<typeof dispatchInboundMessage>> | null = null;
let dispatchError = false;
let dispatchAborted = false;
@@ -853,8 +854,8 @@ export async function processDiscordMessage(
skillFilter: channelConfig?.skills,
disableBlockStreaming:
disableBlockStreamingForDraft ??
(typeof discordConfig?.blockStreaming === "boolean"
? !discordConfig.blockStreaming
(typeof resolvedBlockStreamingEnabled === "boolean"
? !resolvedBlockStreamingEnabled
: undefined),
onPartialReply: draftStream ? (payload) => updateDraftFromPartial(payload.text) : undefined,
onAssistantMessageStart: draftStream

View File

@@ -13,6 +13,7 @@ import {
import { ApplicationCommandOptionType } from "discord-api-types/v10";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
import {
resolveCommandAuthorizedFromAuthorizers,
resolveNativeCommandSessionTargets,
@@ -1134,6 +1135,7 @@ async function dispatchDiscordCommandInteraction(params: {
accountId: effectiveRoute.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
let didReply = false;
const dispatchResult = await dispatchReplyWithDispatcherImpl({
@@ -1175,9 +1177,7 @@ async function dispatchDiscordCommandInteraction(params: {
replyOptions: {
skillFilter: channelConfig?.skills,
disableBlockStreaming:
typeof discordConfig?.blockStreaming === "boolean"
? !discordConfig.blockStreaming
: undefined,
typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined,
onModelSelected,
},
});

View File

@@ -1,3 +1,5 @@
import { getChannelStreamingConfigObject } from "openclaw/plugin-sdk/channel-streaming";
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
function normalizeStreamingMode(value: unknown): string | null {
@@ -35,7 +37,9 @@ export function resolveDiscordPreviewStreamMode(
streaming?: unknown;
} = {},
): DiscordPreviewStreamMode {
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
const parsedStreaming = parseDiscordPreviewStreamMode(
getChannelStreamingConfigObject(params)?.mode ?? params.streaming,
);
if (parsedStreaming) {
return parsedStreaming;
}

View File

@@ -1,6 +1,11 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import {
resolveChannelStreamingBlockCoalesce,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingChunkMode,
} from "openclaw/plugin-sdk/channel-streaming";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
import type {
MattermostAccountConfig,
@@ -27,6 +32,7 @@ export type ResolvedMattermostAccount = {
oncharPrefixes?: string[];
requireMention?: boolean;
textChunkLimit?: number;
chunkMode?: MattermostAccountConfig["chunkMode"];
blockStreaming?: boolean;
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
};
@@ -112,8 +118,10 @@ export function resolveMattermostAccount(params: {
oncharPrefixes: merged.oncharPrefixes,
requireMention,
textChunkLimit: merged.textChunkLimit,
blockStreaming: merged.blockStreaming,
blockStreamingCoalesce: merged.blockStreamingCoalesce,
chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode,
blockStreaming: resolveChannelStreamingBlockEnabled(merged) ?? merged.blockStreaming,
blockStreamingCoalesce:
resolveChannelStreamingBlockCoalesce(merged) ?? merged.blockStreamingCoalesce,
};
}

View File

@@ -77,9 +77,25 @@ export const slackChannelConfigUiHints = {
label: "Slack Streaming Mode",
help: 'Unified Slack stream preview mode: "off" | "partial" | "block" | "progress". Legacy boolean/streamMode keys are auto-mapped.',
},
nativeStreaming: {
"streaming.mode": {
label: "Slack Streaming Mode",
help: 'Canonical Slack preview mode: "off" | "partial" | "block" | "progress".',
},
"streaming.chunkMode": {
label: "Slack Chunk Mode",
help: 'Chunking mode for outbound Slack text delivery: "length" (default) or "newline".',
},
"streaming.block.enabled": {
label: "Slack Block Streaming Enabled",
help: 'Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode="block".',
},
"streaming.block.coalesce": {
label: "Slack Block Streaming Coalesce",
help: "Merge streamed Slack block replies before final delivery.",
},
"streaming.nativeTransport": {
label: "Slack Native Streaming",
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming is partial (default: true).",
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true).",
},
"thread.historyScope": {
label: "Slack Thread History Scope",

View File

@@ -4,8 +4,6 @@ import type {
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
formatSlackStreamingBooleanMigrationMessage,
formatSlackStreamModeMigrationMessage,
resolveSlackNativeStreaming,
resolveSlackStreamingMode,
} from "./streaming-compat.js";
@@ -16,55 +14,112 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asObjectRecord(owner[key]);
if (existing) {
return { ...existing };
}
return {};
}
function normalizeSlackStreamingAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const legacyStreaming = updated.streaming;
const beforeStreaming = updated.streaming;
const beforeNativeStreaming = updated.nativeStreaming;
const resolvedStreaming = resolveSlackStreamingMode(updated);
const resolvedNativeStreaming = resolveSlackNativeStreaming(updated);
const beforeStreaming = params.entry.streaming;
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
const hasLegacyFlatFields =
params.entry.chunkMode !== undefined ||
params.entry.blockStreaming !== undefined ||
params.entry.blockStreamingCoalesce !== undefined ||
params.entry.nativeStreaming !== undefined;
const resolvedStreaming = resolveSlackStreamingMode(params.entry);
const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry);
const shouldNormalize =
hadLegacyStreamMode ||
typeof legacyStreaming === "boolean" ||
(typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming);
typeof beforeStreaming === "boolean" ||
typeof beforeStreaming === "string" ||
hasLegacyFlatFields;
if (!shouldNormalize) {
return { entry: updated, changed: false };
return { entry: params.entry, changed: false };
}
let updated = { ...params.entry };
let changed = false;
if (beforeStreaming !== resolvedStreaming) {
updated = { ...updated, streaming: resolvedStreaming };
changed = true;
}
const streaming = ensureNestedRecord(updated, "streaming");
const block = ensureNestedRecord(streaming, "block");
if (
typeof beforeNativeStreaming !== "boolean" ||
beforeNativeStreaming !== resolvedNativeStreaming
(hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
typeof beforeStreaming === "string") &&
streaming.mode === undefined
) {
updated = { ...updated, nativeStreaming: resolvedNativeStreaming };
streaming.mode = resolvedStreaming;
if (hadLegacyStreamMode) {
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
);
} else if (typeof beforeStreaming === "string") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
);
}
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
delete updated.streamMode;
changed = true;
params.changes.push(
formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming),
);
}
if (typeof legacyStreaming === "boolean") {
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
streaming.chunkMode = updated.chunkMode;
delete updated.chunkMode;
params.changes.push(
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
);
} else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) {
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`,
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
);
changed = true;
}
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
block.enabled = updated.blockStreaming;
delete updated.blockStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
);
changed = true;
}
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
block.coalesce = updated.blockStreamingCoalesce;
delete updated.blockStreamingCoalesce;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
);
changed = true;
}
if (updated.nativeStreaming !== undefined && streaming.nativeTransport === undefined) {
streaming.nativeTransport = resolvedNativeStreaming;
delete updated.nativeStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`,
);
changed = true;
} else if (typeof beforeStreaming === "boolean" && streaming.nativeTransport === undefined) {
streaming.nativeTransport = resolvedNativeStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`,
);
changed = true;
}
if (Object.keys(block).length > 0) {
streaming.block = block;
}
updated.streaming = streaming;
return { entry: updated, changed };
}
@@ -77,7 +132,11 @@ function hasLegacySlackStreamingAliases(value: unknown): boolean {
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" && entry.streaming !== resolveSlackStreamingMode(entry))
typeof entry.streaming === "string" ||
entry.chunkMode !== undefined ||
entry.blockStreaming !== undefined ||
entry.blockStreamingCoalesce !== undefined ||
entry.nativeStreaming !== undefined
);
}
@@ -93,13 +152,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "slack"],
message:
"channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming and channels.slack.nativeStreaming.",
"channels.slack.streamMode, channels.slack.streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
match: hasLegacySlackStreamingAliases,
},
{
path: ["channels", "slack", "accounts"],
message:
"channels.slack.accounts.<id>.streamMode and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.streaming and channels.slack.accounts.<id>.nativeStreaming.",
"channels.slack.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
match: hasLegacySlackAccountStreamingAliases,
},
];

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import { slackDoctor } from "./doctor.js";
describe("slack doctor", () => {
it("normalizes legacy slack streaming aliases into the nested streaming shape", () => {
const normalize = slackDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
slack: {
streamMode: "status_final",
chunkMode: "newline",
blockStreaming: true,
blockStreamingCoalesce: {
idleMs: 250,
},
accounts: {
work: {
streaming: false,
nativeStreaming: false,
},
},
},
},
} as never,
});
expect(result.config.channels?.slack?.streaming).toEqual({
mode: "progress",
chunkMode: "newline",
block: {
enabled: true,
coalesce: {
idleMs: 250,
},
},
});
expect(result.config.channels?.slack?.accounts?.work?.streaming).toEqual({
mode: "off",
nativeTransport: false,
});
expect(result.changes).toEqual(
expect.arrayContaining([
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
"Moved channels.slack.chunkMode → channels.slack.streaming.chunkMode.",
"Moved channels.slack.blockStreaming → channels.slack.streaming.block.enabled.",
"Moved channels.slack.blockStreamingCoalesce → channels.slack.streaming.block.coalesce.",
"Moved channels.slack.accounts.work.streaming (boolean) → channels.slack.accounts.work.streaming.mode (off).",
"Moved channels.slack.accounts.work.nativeStreaming → channels.slack.accounts.work.streaming.nativeTransport.",
]),
);
});
it("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
const normalize = slackDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
slack: {
streamMode: "status_final",
streaming: false,
},
},
} as never,
});
expect(result.config.channels?.slack?.streaming).toEqual({
mode: "progress",
nativeTransport: false,
});
expect(
result.changes.filter((change) => change.includes("channels.slack.streaming.mode")),
).toEqual(["Moved channels.slack.streamMode → channels.slack.streaming.mode (progress)."]);
});
});

View File

@@ -6,12 +6,7 @@ import {
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime-doctor";
import { isSlackMutableAllowEntry } from "./security-doctor.js";
import {
formatSlackStreamingBooleanMigrationMessage,
formatSlackStreamModeMigrationMessage,
resolveSlackNativeStreaming,
resolveSlackStreamingMode,
} from "./streaming-compat.js";
import { resolveSlackNativeStreaming, resolveSlackStreamingMode } from "./streaming-compat.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
@@ -19,6 +14,14 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asObjectRecord(owner[key]);
if (existing) {
return { ...existing };
}
return {};
}
function sanitizeForLog(value: string): string {
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
@@ -115,50 +118,98 @@ function normalizeSlackStreamingAliases(params: {
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const legacyStreaming = updated.streaming;
const beforeStreaming = updated.streaming;
const beforeNativeStreaming = updated.nativeStreaming;
const resolvedStreaming = resolveSlackStreamingMode(updated);
const resolvedNativeStreaming = resolveSlackNativeStreaming(updated);
const beforeStreaming = params.entry.streaming;
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
const hasLegacyFlatFields =
params.entry.chunkMode !== undefined ||
params.entry.blockStreaming !== undefined ||
params.entry.blockStreamingCoalesce !== undefined ||
params.entry.nativeStreaming !== undefined;
const resolvedStreaming = resolveSlackStreamingMode(params.entry);
const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry);
const shouldNormalize =
hadLegacyStreamMode ||
typeof legacyStreaming === "boolean" ||
(typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming);
typeof beforeStreaming === "boolean" ||
typeof beforeStreaming === "string" ||
hasLegacyFlatFields;
if (!shouldNormalize) {
return { entry: updated, changed: false };
return { entry: params.entry, changed: false };
}
let updated = { ...params.entry };
let changed = false;
if (beforeStreaming !== resolvedStreaming) {
updated = { ...updated, streaming: resolvedStreaming };
changed = true;
}
const streaming = ensureNestedRecord(updated, "streaming");
const block = ensureNestedRecord(streaming, "block");
if (
typeof beforeNativeStreaming !== "boolean" ||
beforeNativeStreaming !== resolvedNativeStreaming
(hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
typeof beforeStreaming === "string") &&
streaming.mode === undefined
) {
updated = { ...updated, nativeStreaming: resolvedNativeStreaming };
streaming.mode = resolvedStreaming;
if (hadLegacyStreamMode) {
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
);
} else if (typeof beforeStreaming === "boolean") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
);
} else if (typeof beforeStreaming === "string") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
);
}
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
delete updated.streamMode;
changed = true;
params.changes.push(
formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming),
);
}
if (typeof legacyStreaming === "boolean") {
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
streaming.chunkMode = updated.chunkMode;
delete updated.chunkMode;
params.changes.push(
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
);
} else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) {
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`,
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
);
changed = true;
}
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
block.enabled = updated.blockStreaming;
delete updated.blockStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
);
changed = true;
}
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
block.coalesce = updated.blockStreamingCoalesce;
delete updated.blockStreamingCoalesce;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
);
changed = true;
}
if (updated.nativeStreaming !== undefined && streaming.nativeTransport === undefined) {
streaming.nativeTransport = resolvedNativeStreaming;
delete updated.nativeStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`,
);
changed = true;
} else if (typeof beforeStreaming === "boolean" && streaming.nativeTransport === undefined) {
streaming.nativeTransport = resolvedNativeStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`,
);
changed = true;
}
if (Object.keys(block).length > 0) {
streaming.block = block;
}
updated.streaming = streaming;
return { entry: updated, changed };
}
@@ -301,7 +352,11 @@ function hasLegacySlackStreamingAliases(value: unknown): boolean {
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" && entry.streaming !== resolveSlackStreamingMode(entry))
typeof entry.streaming === "string" ||
entry.chunkMode !== undefined ||
entry.blockStreaming !== undefined ||
entry.blockStreamingCoalesce !== undefined ||
entry.nativeStreaming !== undefined
);
}
@@ -317,13 +372,13 @@ const SLACK_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "slack"],
message:
"channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming and channels.slack.nativeStreaming.",
"channels.slack.streamMode, channels.slack.streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
match: hasLegacySlackStreamingAliases,
},
{
path: ["channels", "slack", "accounts"],
message:
"channels.slack.accounts.<id>.streamMode and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.streaming and channels.slack.accounts.<id>.nativeStreaming.",
"channels.slack.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
match: hasLegacySlackAccountStreamingAliases,
},
];

View File

@@ -8,6 +8,10 @@ import {
type StatusReactionAdapter,
} from "openclaw/plugin-sdk/channel-feedback";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingNativeTransport,
} from "openclaw/plugin-sdk/channel-streaming";
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
@@ -319,7 +323,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
const slackStreaming = resolveSlackStreamingConfig({
streaming: account.config.streaming,
nativeStreaming: account.config.nativeStreaming,
nativeStreaming: resolveChannelStreamingNativeTransport(account.config),
});
const streamThreadHint = resolveSlackStreamingThreadHint({
replyToMode: prepared.replyToMode,
@@ -575,8 +579,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
hasRepliedRef,
disableBlockStreaming: useStreaming
? true
: typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: typeof resolveChannelStreamingBlockEnabled(account.config) === "boolean"
? !resolveChannelStreamingBlockEnabled(account.config)
: undefined,
onModelSelected,
onPartialReply: useStreaming

View File

@@ -1,3 +1,8 @@
import {
getChannelStreamingConfigObject,
resolveChannelStreamingNativeTransport,
} from "openclaw/plugin-sdk/channel-streaming";
export type StreamingMode = "off" | "partial" | "block" | "progress";
export type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append";
@@ -56,7 +61,9 @@ export function resolveSlackStreamingMode(
streaming?: unknown;
} = {},
): StreamingMode {
const parsedStreaming = parseStreamingMode(params.streaming);
const parsedStreaming = parseStreamingMode(
getChannelStreamingConfigObject(params)?.mode ?? params.streaming,
);
if (parsedStreaming) {
return parsedStreaming;
}
@@ -76,25 +83,12 @@ export function resolveSlackNativeStreaming(
streaming?: unknown;
} = {},
): boolean {
if (typeof params.nativeStreaming === "boolean") {
return params.nativeStreaming;
const canonical = resolveChannelStreamingNativeTransport(params);
if (typeof canonical === "boolean") {
return canonical;
}
if (typeof params.streaming === "boolean") {
return params.streaming;
}
return true;
}
export function formatSlackStreamModeMigrationMessage(
pathPrefix: string,
resolvedStreaming: string,
): string {
return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`;
}
export function formatSlackStreamingBooleanMigrationMessage(
pathPrefix: string,
resolvedNativeStreaming: boolean,
): string {
return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`;
}

View File

@@ -5,6 +5,7 @@ import {
removeAckReactionAfterReply,
} from "openclaw/plugin-sdk/channel-feedback";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
import type {
OpenClawConfig,
ReplyToMode,
@@ -199,9 +200,8 @@ export const dispatchTelegramMessage = async ({
parseMode: "HTML" as const,
});
const accountBlockStreamingEnabled =
typeof telegramCfg.blockStreaming === "boolean"
? telegramCfg.blockStreaming
: cfg.agents?.defaults?.blockStreamingDefault === "on";
resolveChannelStreamingBlockEnabled(telegramCfg) ??
cfg.agents?.defaults?.blockStreamingDefault === "on";
const resolvedReasoningLevel = resolveTelegramReasoningLevel({
cfg,
sessionKey: ctxPayload.SessionKey,
@@ -389,12 +389,13 @@ export const dispatchTelegramMessage = async ({
await lane.stream.flush();
};
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg);
const disableBlockStreaming = !previewStreamingEnabled
? true
: forceBlockStreamingForReasoning
? false
: typeof telegramCfg.blockStreaming === "boolean"
? !telegramCfg.blockStreaming
: typeof resolvedBlockStreamingEnabled === "boolean"
? !resolvedBlockStreamingEnabled
: canStreamAnswerDraft
? true
: undefined;

View File

@@ -8,6 +8,7 @@ import { pluginCommandMocks, resetPluginCommandMocks } from "./test-support/plug
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
let parseTelegramNativeCommandCallbackData: typeof import("./bot-native-commands.js").parseTelegramNativeCommandCallbackData;
let resolveTelegramNativeCommandDisableBlockStreaming: typeof import("./bot-native-commands.js").resolveTelegramNativeCommandDisableBlockStreaming;
import {
createCommandBot,
createNativeCommandTestParams,
@@ -22,8 +23,11 @@ import {
describe("registerTelegramNativeCommands", () => {
beforeAll(async () => {
({ registerTelegramNativeCommands, parseTelegramNativeCommandCallbackData } =
await import("./bot-native-commands.js"));
({
registerTelegramNativeCommands,
parseTelegramNativeCommandCallbackData,
resolveTelegramNativeCommandDisableBlockStreaming,
} = await import("./bot-native-commands.js"));
});
beforeEach(() => {
@@ -281,6 +285,27 @@ describe("registerTelegramNativeCommands", () => {
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("uses nested streaming.block.enabled for native command block-streaming behavior", () => {
expect(
resolveTelegramNativeCommandDisableBlockStreaming({
streaming: {
block: {
enabled: false,
},
},
} as TelegramAccountConfig),
).toBe(true);
expect(
resolveTelegramNativeCommandDisableBlockStreaming({
streaming: {
block: {
enabled: true,
},
},
} as TelegramAccountConfig),
).toBe(false);
});
it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => {
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();

View File

@@ -1,4 +1,5 @@
import type { Bot, Context } from "grammy";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
import {
resolveCommandAuthorization,
resolveCommandAuthorizedFromAuthorizers,
@@ -213,6 +214,13 @@ export function parseTelegramNativeCommandCallbackData(data?: string | null): st
return commandText.startsWith("/") ? commandText : null;
}
export function resolveTelegramNativeCommandDisableBlockStreaming(
telegramCfg: TelegramAccountConfig,
): boolean | undefined {
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg);
return typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined;
}
export type RegisterTelegramNativeCommandsParams = {
bot: Bot;
cfg: OpenClawConfig;
@@ -900,9 +908,7 @@ export const registerTelegramNativeCommands = ({
});
const disableBlockStreaming =
typeof runtimeTelegramCfg.blockStreaming === "boolean"
? !runtimeTelegramCfg.blockStreaming
: undefined;
resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg);
const deliveryState = {
delivered: false,
skippedNonSilent: 0,

View File

@@ -33,6 +33,34 @@ export const telegramChannelConfigUiHints = {
label: "Telegram Streaming Mode",
help: 'Unified Telegram stream preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram. Legacy boolean/streamMode keys are auto-mapped.',
},
"streaming.mode": {
label: "Telegram Streaming Mode",
help: 'Canonical Telegram preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram.',
},
"streaming.chunkMode": {
label: "Telegram Chunk Mode",
help: 'Chunking mode for outbound Telegram text delivery: "length" (default) or "newline".',
},
"streaming.block.enabled": {
label: "Telegram Block Streaming Enabled",
help: 'Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode="block".',
},
"streaming.block.coalesce": {
label: "Telegram Block Streaming Coalesce",
help: "Merge streamed Telegram block replies before sending final delivery.",
},
"streaming.preview.chunk.minChars": {
label: "Telegram Draft Chunk Min Chars",
help: 'Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode="block".',
},
"streaming.preview.chunk.maxChars": {
label: "Telegram Draft Chunk Max Chars",
help: 'Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode="block".',
},
"streaming.preview.chunk.breakPreference": {
label: "Telegram Draft Chunk Break Preference",
help: "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence).",
},
"retry.attempts": {
label: "Telegram Retry Attempts",
help: "Max retry attempts for outbound Telegram API calls (default: 3).",

View File

@@ -11,43 +11,108 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asObjectRecord(owner[key]);
if (existing) {
return { ...existing };
}
return {};
}
function normalizeTelegramStreamingAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveTelegramPreviewStreamMode(updated);
const beforeStreaming = params.entry.streaming;
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
const hasLegacyFlatFields =
params.entry.chunkMode !== undefined ||
params.entry.blockStreaming !== undefined ||
params.entry.draftChunk !== undefined ||
params.entry.blockStreamingCoalesce !== undefined;
const resolved = resolveTelegramPreviewStreamMode(params.entry);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
typeof beforeStreaming === "string" ||
hasLegacyFlatFields;
if (!shouldNormalize) {
return { entry: updated, changed: false };
return { entry: params.entry, changed: false };
}
let updated = { ...params.entry };
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
const streaming = ensureNestedRecord(updated, "streaming");
const block = ensureNestedRecord(streaming, "block");
const preview = ensureNestedRecord(streaming, "preview");
if (
(hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
typeof beforeStreaming === "string") &&
streaming.mode === undefined
) {
streaming.mode = resolved;
if (hadLegacyStreamMode) {
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
} else if (typeof beforeStreaming === "boolean") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
} else if (typeof beforeStreaming === "string") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
}
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
delete updated.streamMode;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
streaming.chunkMode = updated.chunkMode;
delete updated.chunkMode;
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
);
changed = true;
}
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
block.enabled = updated.blockStreaming;
delete updated.blockStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
);
changed = true;
}
if (updated.draftChunk !== undefined && preview.chunk === undefined) {
preview.chunk = updated.draftChunk;
delete updated.draftChunk;
params.changes.push(
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
);
changed = true;
}
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
block.coalesce = updated.blockStreamingCoalesce;
delete updated.blockStreamingCoalesce;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
);
changed = true;
}
if (Object.keys(preview).length > 0) {
streaming.preview = preview;
}
if (Object.keys(block).length > 0) {
streaming.block = block;
}
updated.streaming = streaming;
return { entry: updated, changed };
}
@@ -59,8 +124,11 @@ function hasLegacyTelegramStreamingAliases(value: unknown): boolean {
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveTelegramPreviewStreamMode(entry))
typeof entry.streaming === "string" ||
entry.chunkMode !== undefined ||
entry.blockStreaming !== undefined ||
entry.draftChunk !== undefined ||
entry.blockStreamingCoalesce !== undefined
);
}
@@ -99,13 +167,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "telegram"],
message:
'channels.telegram.streamMode and boolean channels.telegram.streaming are legacy; use channels.telegram.streaming="off|partial|block".',
"channels.telegram.streamMode, channels.telegram.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyTelegramStreamingAliases,
},
{
path: ["channels", "telegram", "accounts"],
message:
'channels.telegram.accounts.<id>.streamMode and boolean channels.telegram.accounts.<id>.streaming are legacy; use channels.telegram.accounts.<id>.streaming="off|partial|block".',
"channels.telegram.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyTelegramAccountStreamingAliases,
},
];

View File

@@ -6,6 +6,7 @@ import {
collectTelegramGroupPolicyWarnings,
maybeRepairTelegramAllowFromUsernames,
scanTelegramAllowFromUsernameEntries,
telegramDoctor,
} from "./doctor.js";
const resolveCommandSecretRefsViaGatewayMock = vi.hoisted(() => vi.fn());
@@ -66,6 +67,94 @@ describe("telegram doctor", () => {
lookupTelegramChatIdMock.mockReset();
});
it("normalizes legacy telegram streaming aliases into the nested streaming shape", () => {
const normalize = telegramDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
telegram: {
streamMode: "block",
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
minChars: 120,
},
accounts: {
work: {
streaming: false,
blockStreamingCoalesce: {
idleMs: 250,
},
},
},
},
},
} as never,
});
expect(result.config.channels?.telegram?.streaming).toEqual({
mode: "block",
chunkMode: "newline",
block: {
enabled: true,
},
preview: {
chunk: {
minChars: 120,
},
},
});
expect(result.config.channels?.telegram?.accounts?.work?.streaming).toEqual({
mode: "off",
block: {
coalesce: {
idleMs: 250,
},
},
});
expect(result.changes).toEqual(
expect.arrayContaining([
"Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).",
"Moved channels.telegram.chunkMode → channels.telegram.streaming.chunkMode.",
"Moved channels.telegram.blockStreaming → channels.telegram.streaming.block.enabled.",
"Moved channels.telegram.draftChunk → channels.telegram.streaming.preview.chunk.",
"Moved channels.telegram.accounts.work.streaming (boolean) → channels.telegram.accounts.work.streaming.mode (off).",
"Moved channels.telegram.accounts.work.blockStreamingCoalesce → channels.telegram.accounts.work.streaming.block.coalesce.",
]),
);
});
it("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
const normalize = telegramDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
telegram: {
streamMode: "block",
streaming: false,
},
},
} as never,
});
expect(result.config.channels?.telegram?.streaming).toEqual({
mode: "block",
});
expect(
result.changes.filter((change) => change.includes("channels.telegram.streaming.mode")),
).toEqual(["Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block)."]);
});
it("finds username allowFrom entries across scopes", () => {
const hits = scanTelegramAllowFromUsernameEntries({
channels: {

View File

@@ -27,6 +27,14 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
: null;
}
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = asObjectRecord(owner[key]);
if (existing) {
return { ...existing };
}
return {};
}
function sanitizeForLog(value: string): string {
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
@@ -40,38 +48,95 @@ function normalizeTelegramStreamingAliases(params: {
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let updated = params.entry;
const hadLegacyStreamMode = updated.streamMode !== undefined;
const beforeStreaming = updated.streaming;
const resolved = resolveTelegramPreviewStreamMode(updated);
const beforeStreaming = params.entry.streaming;
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
const hasLegacyFlatFields =
params.entry.chunkMode !== undefined ||
params.entry.blockStreaming !== undefined ||
params.entry.draftChunk !== undefined ||
params.entry.blockStreamingCoalesce !== undefined;
const resolved = resolveTelegramPreviewStreamMode(params.entry);
const shouldNormalize =
hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
typeof beforeStreaming === "string" ||
hasLegacyFlatFields;
if (!shouldNormalize) {
return { entry: updated, changed: false };
return { entry: params.entry, changed: false };
}
let updated = { ...params.entry };
let changed = false;
if (beforeStreaming !== resolved) {
updated = { ...updated, streaming: resolved };
const streaming = ensureNestedRecord(updated, "streaming");
const block = ensureNestedRecord(streaming, "block");
const preview = ensureNestedRecord(streaming, "preview");
if (
(hadLegacyStreamMode ||
typeof beforeStreaming === "boolean" ||
typeof beforeStreaming === "string") &&
streaming.mode === undefined
) {
streaming.mode = resolved;
if (hadLegacyStreamMode) {
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
} else if (typeof beforeStreaming === "boolean") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
} else if (typeof beforeStreaming === "string") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
);
}
changed = true;
}
if (hadLegacyStreamMode) {
const { streamMode: _ignored, ...rest } = updated;
updated = rest;
delete updated.streamMode;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof beforeStreaming === "boolean") {
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
streaming.chunkMode = updated.chunkMode;
delete updated.chunkMode;
params.changes.push(
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
);
changed = true;
}
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
block.enabled = updated.blockStreaming;
delete updated.blockStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
);
changed = true;
}
if (updated.draftChunk !== undefined && preview.chunk === undefined) {
preview.chunk = updated.draftChunk;
delete updated.draftChunk;
params.changes.push(
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
);
changed = true;
}
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
block.coalesce = updated.blockStreamingCoalesce;
delete updated.blockStreamingCoalesce;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
);
changed = true;
}
if (Object.keys(preview).length > 0) {
streaming.preview = preview;
}
if (Object.keys(block).length > 0) {
streaming.block = block;
}
updated.streaming = streaming;
return { entry: updated, changed };
}
@@ -458,8 +523,11 @@ function hasLegacyTelegramStreamingAliases(value: unknown): boolean {
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
(typeof entry.streaming === "string" &&
entry.streaming !== resolveTelegramPreviewStreamMode(entry))
typeof entry.streaming === "string" ||
entry.chunkMode !== undefined ||
entry.blockStreaming !== undefined ||
entry.draftChunk !== undefined ||
entry.blockStreamingCoalesce !== undefined
);
}
@@ -475,13 +543,13 @@ const TELEGRAM_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "telegram"],
message:
'channels.telegram.streamMode and boolean channels.telegram.streaming are legacy; use channels.telegram.streaming="off|partial|block".',
"channels.telegram.streamMode, channels.telegram.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyTelegramStreamingAliases,
},
{
path: ["channels", "telegram", "accounts"],
message:
'channels.telegram.accounts.<id>.streamMode and boolean channels.telegram.accounts.<id>.streaming are legacy; use channels.telegram.accounts.<id>.streaming="off|partial|block".',
"channels.telegram.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
match: hasLegacyTelegramAccountStreamingAliases,
},
];

View File

@@ -1,3 +1,4 @@
import { resolveChannelStreamingPreviewChunk } from "openclaw/plugin-sdk/channel-streaming";
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
@@ -20,7 +21,11 @@ export function resolveTelegramDraftStreamingChunking(
});
const normalizedAccountId = normalizeAccountId(accountId);
const accountCfg = resolveAccountEntry(cfg?.channels?.telegram?.accounts, normalizedAccountId);
const draftCfg = accountCfg?.draftChunk ?? cfg?.channels?.telegram?.draftChunk;
const draftCfg =
resolveChannelStreamingPreviewChunk(accountCfg) ??
resolveChannelStreamingPreviewChunk(cfg?.channels?.telegram) ??
accountCfg?.draftChunk ??
cfg?.channels?.telegram?.draftChunk;
const maxRequested = Math.max(
1,

View File

@@ -1,3 +1,5 @@
import { getChannelStreamingConfigObject } from "openclaw/plugin-sdk/channel-streaming";
export type TelegramPreviewStreamMode = "off" | "partial" | "block";
function normalizeStreamingMode(value: unknown): string | null {
@@ -35,7 +37,9 @@ export function resolveTelegramPreviewStreamMode(
streaming?: unknown;
} = {},
): TelegramPreviewStreamMode {
const parsedStreaming = parseStreamingMode(params.streaming);
const parsedStreaming = parseStreamingMode(
getChannelStreamingConfigObject(params)?.mode ?? params.streaming,
);
if (parsedStreaming) {
if (parsedStreaming === "progress") {
return "partial";

View File

@@ -4,6 +4,10 @@ import {
resolveMergedAccountConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-core";
import {
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingChunkMode,
} from "openclaw/plugin-sdk/channel-streaming";
import type { WhatsAppAccountConfig } from "./runtime-api.js";
function resolveWhatsAppAccountConfig(
@@ -28,5 +32,7 @@ export function resolveMergedWhatsAppAccountConfig(params: {
return {
accountId,
...merged,
chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode,
blockStreaming: resolveChannelStreamingBlockEnabled(merged) ?? merged.blockStreaming,
};
}

View File

@@ -95,6 +95,10 @@
"types": "./dist/plugin-sdk/channel-setup.d.ts",
"default": "./dist/plugin-sdk/channel-setup.js"
},
"./plugin-sdk/channel-streaming": {
"types": "./dist/plugin-sdk/channel-streaming.d.ts",
"default": "./dist/plugin-sdk/channel-streaming.js"
},
"./plugin-sdk/setup-tools": {
"types": "./dist/plugin-sdk/setup-tools.d.ts",
"default": "./dist/plugin-sdk/setup-tools.js"

View File

@@ -13,6 +13,7 @@
"setup-adapter-runtime",
"setup-runtime",
"channel-setup",
"channel-streaming",
"setup-tools",
"approval-auth-runtime",
"approval-client-runtime",

View File

@@ -5,6 +5,7 @@
import type { ChannelId } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { findFenceSpanAt, isSafeFenceBreak, parseFenceSpans } from "../markdown/fences.js";
import { resolveChannelStreamingChunkMode } from "../plugin-sdk/channel-streaming.js";
import { resolveAccountEntry } from "../routing/account-lookup.js";
import { normalizeAccountId } from "../routing/session-key.js";
import { chunkTextByBreakResolver } from "../shared/text-chunking.js";
@@ -27,7 +28,11 @@ const DEFAULT_CHUNK_MODE: ChunkMode = "length";
type ProviderChunkConfig = {
textChunkLimit?: number;
chunkMode?: ChunkMode;
accounts?: Record<string, { textChunkLimit?: number; chunkMode?: ChunkMode }>;
streaming?: unknown;
accounts?: Record<
string,
{ textChunkLimit?: number; chunkMode?: ChunkMode; streaming?: unknown }
>;
};
function resolveChunkLimitForProvider(
@@ -84,11 +89,12 @@ function resolveChunkModeForProvider(
const accounts = cfgSection.accounts;
if (accounts && typeof accounts === "object") {
const direct = resolveAccountEntry(accounts, normalizedAccountId);
if (direct?.chunkMode) {
return direct.chunkMode;
const directMode = resolveChannelStreamingChunkMode(direct);
if (directMode) {
return directMode;
}
}
return cfgSection.chunkMode;
return resolveChannelStreamingChunkMode(cfgSection) ?? cfgSection.chunkMode;
}
export function resolveChunkMode(

View File

@@ -1,6 +1,7 @@
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
import { resolveChannelStreamingBlockCoalesce } from "../../plugin-sdk/channel-streaming.js";
import { resolveAccountEntry } from "../../routing/account-lookup.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
@@ -39,7 +40,11 @@ function resolveProviderChunkContext(
type ProviderBlockStreamingConfig = {
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
accounts?: Record<string, { blockStreamingCoalesce?: BlockStreamingCoalesceConfig }>;
streaming?: unknown;
accounts?: Record<
string,
{ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; streaming?: unknown }
>;
};
function resolveProviderBlockStreamingCoalesce(params: {
@@ -58,7 +63,12 @@ function resolveProviderBlockStreamingCoalesce(params: {
const normalizedAccountId = normalizeAccountId(accountId);
const typed = providerCfg as ProviderBlockStreamingConfig;
const accountCfg = resolveAccountEntry(typed.accounts, normalizedAccountId);
return accountCfg?.blockStreamingCoalesce ?? typed.blockStreamingCoalesce;
return (
resolveChannelStreamingBlockCoalesce(accountCfg) ??
resolveChannelStreamingBlockCoalesce(typed) ??
accountCfg?.blockStreamingCoalesce ??
typed.blockStreamingCoalesce
);
}
export type BlockStreamingCoalescing = {

View File

@@ -633,12 +633,14 @@ describe("doctor config flow", () => {
channels: {
discord: {
streamMode?: string;
streaming?: string;
streaming?: {
mode?: string;
};
lifecycle?: unknown;
};
};
};
expect(cfg.channels.discord.streaming).toBe("partial");
expect(cfg.channels.discord.streaming?.mode).toBe("partial");
expect(cfg.channels.discord.streamMode).toBeUndefined();
expect(cfg.channels.discord.lifecycle).toEqual({
enabled: true,
@@ -680,7 +682,7 @@ describe("doctor config flow", () => {
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("channels.telegram:") &&
String(message).includes("channels.telegram.streamMode is legacy"),
String(message).includes("channels.telegram.streamMode, channels.telegram.streaming"),
),
).toBe(true);
expect(
@@ -688,7 +690,7 @@ describe("doctor config flow", () => {
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("channels.discord:") &&
String(message).includes("boolean channels.discord.streaming are legacy"),
String(message).includes("channels.discord.streamMode, channels.discord.streaming"),
),
).toBe(true);
expect(
@@ -704,7 +706,7 @@ describe("doctor config flow", () => {
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("channels.slack:") &&
String(message).includes("boolean channels.slack.streaming are legacy"),
String(message).includes("channels.slack.streamMode, channels.slack.streaming"),
),
).toBe(true);
expect(
@@ -907,22 +909,11 @@ describe("doctor config flow", () => {
const outputs = noteSpy.mock.calls
.filter((call) => call[1] === "Doctor warnings" || call[1] === "Doctor changes")
.map((call) => String(call[0]));
const joinedOutputs = outputs.join("\n");
expect(outputs.filter((line) => line.includes("\u001b"))).toEqual([]);
expect(outputs.filter((line) => line.includes("\nforged"))).toEqual([]);
expect(
outputs.some(
(line) =>
line.includes("channels.slack.accounts.work.allowFrom: aliceforged") &&
line.includes("mutable allowlist"),
),
).toBe(true);
expect(
outputs.some(
(line) =>
line.includes('channels.slack.accounts.opsopen.allowFrom: set to ["*"]') &&
line.includes('required by dmPolicy="open"'),
),
).toBe(true);
expect(joinedOutputs).toContain('channels.slack.accounts.opsopen.allowFrom: set to ["*"]');
expect(joinedOutputs).toContain('required by dmPolicy="open"');
expect(
outputs.some(
(line) =>
@@ -1651,30 +1642,15 @@ describe("doctor config flow", () => {
run: loadAndMaybeMigrateDoctorConfig,
});
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("session.threadBindings:") &&
String(message).includes("session.threadBindings.idleHours"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("channels.discord.threadBindings:") &&
String(message).includes("channels.discord.threadBindings.idleHours"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("channels.discord.accounts:") &&
String(message).includes("channels.discord.accounts.<id>.threadBindings.idleHours"),
),
).toBe(true);
const legacyMessages = noteSpy.mock.calls
.filter(([, title]) => title === "Legacy config keys detected")
.map(([message]) => String(message))
.join("\n");
expect(legacyMessages).toContain("session.threadBindings.ttlHours");
expect(legacyMessages).toContain("session.threadBindings.idleHours");
expect(legacyMessages).toContain("channels.<id>.threadBindings.ttlHours");
expect(legacyMessages).toContain("channels.<id>.threadBindings.idleHours");
expect(
noteSpy.mock.calls.some(
([message, title]) =>

View File

@@ -275,7 +275,7 @@ describe("normalizeCompatibilityConfigValues", () => {
]);
});
it("migrates Discord streaming boolean alias to streaming enum", () => {
it("migrates Discord streaming boolean alias into nested streaming.mode", () => {
const res = normalizeCompatibilityConfigValues(
asLegacyConfig({
channels: {
@@ -291,21 +291,25 @@ describe("normalizeCompatibilityConfigValues", () => {
}),
);
expect(res.config.channels?.discord?.streaming).toBe("partial");
expect(res.config.channels?.discord?.streaming).toEqual({
mode: "partial",
});
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("off");
expect(res.config.channels?.discord?.accounts?.work?.streaming).toEqual({
mode: "off",
});
expect(
getLegacyProperty(res.config.channels?.discord?.accounts?.work, "streamMode"),
).toBeUndefined();
expect(res.changes).toContain(
"Normalized channels.discord.streaming boolean → enum (partial).",
"Moved channels.discord.streaming (boolean)channels.discord.streaming.mode (partial).",
);
expect(res.changes).toContain(
"Normalized channels.discord.accounts.work.streaming boolean → enum (off).",
"Moved channels.discord.accounts.work.streaming (boolean)channels.discord.accounts.work.streaming.mode (off).",
);
});
it("migrates Discord legacy streamMode into streaming enum", () => {
it("migrates Discord legacy streamMode into nested streaming.mode", () => {
const res = normalizeCompatibilityConfigValues(
asLegacyConfig({
channels: {
@@ -317,15 +321,17 @@ describe("normalizeCompatibilityConfigValues", () => {
}),
);
expect(res.config.channels?.discord?.streaming).toBe("block");
expect(res.config.channels?.discord?.streaming).toEqual({
mode: "block",
});
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.discord.streamMode → channels.discord.streaming (block).",
"Normalized channels.discord.streaming boolean → enum (block).",
"Moved channels.discord.streamMode → channels.discord.streaming.mode (block).",
"Moved channels.discord.streaming (boolean)channels.discord.streaming.mode (block).",
]);
});
it("migrates Telegram streamMode into streaming enum", () => {
it("migrates Telegram streamMode into nested streaming.mode", () => {
const res = normalizeCompatibilityConfigValues(
asLegacyConfig({
channels: {
@@ -336,14 +342,16 @@ describe("normalizeCompatibilityConfigValues", () => {
}),
);
expect(res.config.channels?.telegram?.streaming).toBe("block");
expect(res.config.channels?.telegram?.streaming).toEqual({
mode: "block",
});
expect(getLegacyProperty(res.config.channels?.telegram, "streamMode")).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.telegram.streamMode → channels.telegram.streaming (block).",
"Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).",
]);
});
it("migrates Slack legacy streaming keys to unified config", () => {
it("migrates Slack legacy streaming keys into nested streaming config", () => {
const res = normalizeCompatibilityConfigValues(
asLegacyConfig({
channels: {
@@ -355,12 +363,15 @@ describe("normalizeCompatibilityConfigValues", () => {
}),
);
expect(res.config.channels?.slack?.streaming).toBe("progress");
expect(res.config.channels?.slack?.nativeStreaming).toBe(false);
expect(res.config.channels?.slack?.streaming).toEqual({
mode: "progress",
nativeTransport: false,
});
expect(getLegacyProperty(res.config.channels?.slack, "streamMode")).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.slack.streamMode → channels.slack.streaming (progress).",
"Moved channels.slack.streaming (boolean) → channels.slack.nativeStreaming (false).",
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (progress).",
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.nativeTransport.",
]);
});

View File

@@ -13,7 +13,7 @@ function getLegacyProperty(value: unknown, key: string): unknown {
return (value as Record<string, unknown>)[key];
}
describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
it("normalizes telegram boolean streaming aliases to enum", () => {
it("normalizes telegram boolean streaming aliases into nested streaming.mode", () => {
const res = normalizeCompatibilityConfigValues(
asLegacyConfig({
channels: {
@@ -24,12 +24,16 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
}),
);
expect(res.config.channels?.telegram?.streaming).toBe("off");
expect(res.config.channels?.telegram?.streaming).toEqual({
mode: "off",
});
expect(getLegacyProperty(res.config.channels?.telegram, "streamMode")).toBeUndefined();
expect(res.changes).toEqual(["Normalized channels.telegram.streaming boolean → enum (off)."]);
expect(res.changes).toEqual([
"Moved channels.telegram.streaming (boolean) → channels.telegram.streaming.mode (off).",
]);
});
it("normalizes discord boolean streaming aliases to enum", () => {
it("normalizes discord boolean streaming aliases into nested streaming.mode", () => {
const res = normalizeCompatibilityConfigValues(
asLegacyConfig({
channels: {
@@ -40,10 +44,12 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
}),
);
expect(res.config.channels?.discord?.streaming).toBe("partial");
expect(res.config.channels?.discord?.streaming).toEqual({
mode: "partial",
});
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
expect(res.changes).toEqual([
"Normalized channels.discord.streaming boolean → enum (partial).",
"Moved channels.discord.streaming (boolean)channels.discord.streaming.mode (partial).",
]);
});
@@ -58,9 +64,13 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
}),
);
expect(res.config.channels?.discord?.streaming).toBe("off");
expect(res.config.channels?.discord?.streaming).toEqual({
mode: "off",
});
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
expect(res.changes).toEqual(["Normalized channels.discord.streaming boolean → enum (off)."]);
expect(res.changes).toEqual([
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (off).",
]);
});
it("explains why discord preview streaming stays off when legacy config resolves to off", () => {
@@ -74,15 +84,17 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
}),
);
expect(res.config.channels?.discord?.streaming).toBe("off");
expect(res.config.channels?.discord?.streaming).toEqual({
mode: "off",
});
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.discord.streamMode → channels.discord.streaming (off).",
'channels.discord.streaming remains off by default to avoid Discord preview-edit rate limits; set channels.discord.streaming="partial" to opt in explicitly.',
"Moved channels.discord.streamMode → channels.discord.streaming.mode (off).",
'channels.discord.streaming remains off by default to avoid Discord preview-edit rate limits; set channels.discord.streaming.mode="partial" to opt in explicitly.',
]);
});
it("normalizes slack boolean streaming aliases to enum and native streaming", () => {
it("normalizes slack boolean streaming aliases into nested streaming config", () => {
const res = normalizeCompatibilityConfigValues(
asLegacyConfig({
channels: {
@@ -93,11 +105,14 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
}),
);
expect(res.config.channels?.slack?.streaming).toBe("off");
expect(res.config.channels?.slack?.nativeStreaming).toBe(false);
expect(res.config.channels?.slack?.streaming).toEqual({
mode: "off",
nativeTransport: false,
});
expect(getLegacyProperty(res.config.channels?.slack, "streamMode")).toBeUndefined();
expect(res.changes).toEqual([
"Moved channels.slack.streaming (boolean) → channels.slack.nativeStreaming (false).",
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (off).",
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.nativeTransport.",
]);
});
});

View File

@@ -3,7 +3,10 @@ import {
validateConfigObjectRawWithPlugins,
validateConfigObjectWithPlugins,
} from "../../../config/validation.js";
import { migrateLegacyConfig } from "./legacy-config-migrate.js";
import {
applyLegacyDoctorMigrations,
migrateLegacyConfig,
} from "./legacy-config-migrate.js";
describe("legacy migrate audio transcription", () => {
it("does not rewrite removed routing.transcribeAudio migrations", () => {
@@ -238,27 +241,131 @@ describe("legacy migrate sandbox scope aliases", () => {
});
describe("legacy migrate channel streaming aliases", () => {
it("migrates telegram and discord streaming aliases", () => {
it("migrates preview-channel legacy streaming fields into the nested streaming shape", () => {
const res = migrateLegacyConfig({
channels: {
telegram: {
streamMode: "block",
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
minChars: 120,
},
blockStreamingCoalesce: {
idleMs: 250,
},
},
discord: {
streaming: false,
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
maxChars: 900,
},
},
slack: {
streamMode: "status_final",
blockStreaming: true,
blockStreamingCoalesce: {
minChars: 100,
},
nativeStreaming: false,
},
},
});
expect(res.changes).toContain(
"Moved channels.telegram.streamMode → channels.telegram.streaming (block).",
"Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).",
);
expect(res.changes).toContain(
"Moved channels.telegram.chunkMode → channels.telegram.streaming.chunkMode.",
);
expect(res.changes).toContain(
"Moved channels.telegram.blockStreaming → channels.telegram.streaming.block.enabled.",
);
expect(res.changes).toContain(
"Moved channels.telegram.draftChunk → channels.telegram.streaming.preview.chunk.",
);
expect(res.changes).toContain(
"Moved channels.telegram.blockStreamingCoalesce → channels.telegram.streaming.block.coalesce.",
);
expect(res.changes).toContain(
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (off).",
);
expect(res.changes).toContain(
"Moved channels.discord.draftChunk → channels.discord.streaming.preview.chunk.",
);
expect(res.changes).toContain(
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
);
expect(res.changes).toContain(
"Moved channels.slack.nativeStreaming → channels.slack.streaming.nativeTransport.",
);
expect(res.changes).toContain("Normalized channels.discord.streaming boolean → enum (off).");
expect(res.config?.channels?.telegram).toMatchObject({
streaming: "block",
streaming: {
mode: "block",
chunkMode: "newline",
block: {
enabled: true,
coalesce: {
idleMs: 250,
},
},
preview: {
chunk: {
minChars: 120,
},
},
},
});
expect(res.config?.channels?.discord).toMatchObject({
streaming: "off",
streaming: {
mode: "off",
chunkMode: "newline",
block: {
enabled: true,
},
preview: {
chunk: {
maxChars: 900,
},
},
},
});
expect(res.config?.channels?.slack).toMatchObject({
streaming: {
mode: "progress",
block: {
enabled: true,
coalesce: {
minChars: 100,
},
},
nativeTransport: false,
},
});
});
it("preserves slack streaming=false when deriving nativeTransport during migration", () => {
const raw = {
channels: {
slack: {
botToken: "xoxb-test",
streaming: false,
},
},
};
const res = migrateLegacyConfig(raw);
const migrated = applyLegacyDoctorMigrations(raw);
expect(res.changes).toContain(
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (off).",
);
expect((migrated.next as { channels?: { slack?: unknown } }).channels?.slack).toMatchObject({
streaming: {
mode: "off",
nativeTransport: false,
},
});
});
@@ -819,7 +926,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => {
it("does not overwrite existing allowedOrigins — returns null (no migration needed)", () => {
// When allowedOrigins already exists, the migration is a no-op.
// applyLegacyMigrations returns next=null when changes.length===0, so config is null.
// applyLegacyDoctorMigrations returns next=null when changes.length===0, so config is null.
const res = migrateLegacyConfig({
gateway: {
bind: "lan",

View File

@@ -146,17 +146,6 @@ function resolveSlackNativeStreaming(
return true;
}
function formatSlackStreamModeMigrationMessage(pathPrefix: string, resolvedStreaming: string) {
return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`;
}
function formatSlackStreamingBooleanMigrationMessage(
pathPrefix: string,
resolvedNativeStreaming: boolean,
) {
return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`;
}
function hasLegacyThreadBindingTtl(value: unknown): boolean {
const threadBindings = getRecord(value);
return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours"));
@@ -223,7 +212,15 @@ function hasLegacyTelegramStreamingKeys(value: unknown): boolean {
if (!entry) {
return false;
}
return entry.streamMode !== undefined;
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
typeof entry.streaming === "string" ||
hasOwnKey(entry, "chunkMode") ||
hasOwnKey(entry, "blockStreaming") ||
hasOwnKey(entry, "draftChunk") ||
hasOwnKey(entry, "blockStreamingCoalesce")
);
}
function hasLegacyDiscordStreamingKeys(value: unknown): boolean {
@@ -231,7 +228,15 @@ function hasLegacyDiscordStreamingKeys(value: unknown): boolean {
if (!entry) {
return false;
}
return entry.streamMode !== undefined || typeof entry.streaming === "boolean";
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
typeof entry.streaming === "string" ||
hasOwnKey(entry, "chunkMode") ||
hasOwnKey(entry, "blockStreaming") ||
hasOwnKey(entry, "draftChunk") ||
hasOwnKey(entry, "blockStreamingCoalesce")
);
}
function hasLegacySlackStreamingKeys(value: unknown): boolean {
@@ -239,7 +244,177 @@ function hasLegacySlackStreamingKeys(value: unknown): boolean {
if (!entry) {
return false;
}
return entry.streamMode !== undefined || typeof entry.streaming === "boolean";
return (
entry.streamMode !== undefined ||
typeof entry.streaming === "boolean" ||
typeof entry.streaming === "string" ||
hasOwnKey(entry, "chunkMode") ||
hasOwnKey(entry, "blockStreaming") ||
hasOwnKey(entry, "blockStreamingCoalesce") ||
hasOwnKey(entry, "nativeStreaming")
);
}
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
const existing = getRecord(owner[key]);
if (existing) {
return existing;
}
const created: Record<string, unknown> = {};
owner[key] = created;
return created;
}
function moveLegacyStreamingShapeForPath(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
resolveMode?: (entry: Record<string, unknown>) => string;
resolveNativeTransport?: (entry: Record<string, unknown>) => boolean;
}): boolean {
let changed = false;
const legacyStreaming = params.entry.streaming;
const legacyStreamingInput = {
...params.entry,
streaming: legacyStreaming,
};
const legacyNativeTransportInput = {
nativeStreaming: params.entry.nativeStreaming,
streaming: legacyStreaming,
};
const hadLegacyStreamMode = hasOwnKey(params.entry, "streamMode");
const hadLegacyStreamingScalar =
typeof legacyStreaming === "string" || typeof legacyStreaming === "boolean";
if (params.resolveMode && (hadLegacyStreamMode || hadLegacyStreamingScalar)) {
const streaming = ensureNestedRecord(params.entry, "streaming");
if (!hasOwnKey(streaming, "mode")) {
const resolvedMode = params.resolveMode(legacyStreamingInput);
streaming.mode = resolvedMode;
if (hadLegacyStreamMode) {
params.changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolvedMode}).`,
);
}
if (typeof legacyStreaming === "boolean") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolvedMode}).`,
);
} else if (typeof legacyStreaming === "string") {
params.changes.push(
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolvedMode}).`,
);
}
} else {
params.changes.push(
`Removed legacy ${params.pathPrefix}.streaming mode aliases (${params.pathPrefix}.streaming.mode already set).`,
);
}
changed = true;
}
if (hadLegacyStreamMode) {
delete params.entry.streamMode;
changed = true;
}
if (hadLegacyStreamingScalar) {
if (!getRecord(params.entry.streaming)) {
params.entry.streaming = {};
}
changed = true;
}
if (hasOwnKey(params.entry, "chunkMode")) {
const streaming = ensureNestedRecord(params.entry, "streaming");
if (!hasOwnKey(streaming, "chunkMode")) {
streaming.chunkMode = params.entry.chunkMode;
params.changes.push(
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.chunkMode (${params.pathPrefix}.streaming.chunkMode already set).`,
);
}
delete params.entry.chunkMode;
changed = true;
}
if (hasOwnKey(params.entry, "blockStreaming")) {
const block = ensureNestedRecord(ensureNestedRecord(params.entry, "streaming"), "block");
if (!hasOwnKey(block, "enabled")) {
block.enabled = params.entry.blockStreaming;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.blockStreaming (${params.pathPrefix}.streaming.block.enabled already set).`,
);
}
delete params.entry.blockStreaming;
changed = true;
}
if (hasOwnKey(params.entry, "draftChunk")) {
const preview = ensureNestedRecord(ensureNestedRecord(params.entry, "streaming"), "preview");
if (!hasOwnKey(preview, "chunk")) {
preview.chunk = params.entry.draftChunk;
params.changes.push(
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.draftChunk (${params.pathPrefix}.streaming.preview.chunk already set).`,
);
}
delete params.entry.draftChunk;
changed = true;
}
if (hasOwnKey(params.entry, "blockStreamingCoalesce")) {
const block = ensureNestedRecord(ensureNestedRecord(params.entry, "streaming"), "block");
if (!hasOwnKey(block, "coalesce")) {
block.coalesce = params.entry.blockStreamingCoalesce;
params.changes.push(
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.blockStreamingCoalesce (${params.pathPrefix}.streaming.block.coalesce already set).`,
);
}
delete params.entry.blockStreamingCoalesce;
changed = true;
}
if (params.resolveNativeTransport && hasOwnKey(params.entry, "nativeStreaming")) {
const streaming = ensureNestedRecord(params.entry, "streaming");
if (!hasOwnKey(streaming, "nativeTransport")) {
streaming.nativeTransport = params.resolveNativeTransport(legacyNativeTransportInput);
params.changes.push(
`Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`,
);
} else {
params.changes.push(
`Removed ${params.pathPrefix}.nativeStreaming (${params.pathPrefix}.streaming.nativeTransport already set).`,
);
}
delete params.entry.nativeStreaming;
changed = true;
} else if (params.resolveNativeTransport && typeof legacyStreaming === "boolean") {
const streaming = ensureNestedRecord(params.entry, "streaming");
if (!hasOwnKey(streaming, "nativeTransport")) {
streaming.nativeTransport = params.resolveNativeTransport(legacyNativeTransportInput);
params.changes.push(
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`,
);
changed = true;
}
}
return changed;
}
function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
@@ -343,37 +518,37 @@ const CHANNEL_STREAMING_RULES: LegacyConfigRule[] = [
{
path: ["channels", "telegram"],
message:
'channels.telegram.streamMode is legacy; use channels.telegram.streaming instead. Run "openclaw doctor --fix".',
'channels.telegram.streamMode, channels.telegram.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyTelegramStreamingKeys(value),
},
{
path: ["channels", "telegram", "accounts"],
message:
'channels.telegram.accounts.<id>.streamMode is legacy; use channels.telegram.accounts.<id>.streaming instead. Run "openclaw doctor --fix".',
'channels.telegram.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyTelegramStreamingKeys),
},
{
path: ["channels", "discord"],
message:
'channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming with enum values instead. Run "openclaw doctor --fix".',
'channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyDiscordStreamingKeys(value),
},
{
path: ["channels", "discord", "accounts"],
message:
'channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming with enum values instead. Run "openclaw doctor --fix".',
'channels.discord.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyDiscordStreamingKeys),
},
{
path: ["channels", "slack"],
message:
'channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming with enum values instead. Run "openclaw doctor --fix".',
'channels.slack.streamMode, channels.slack.streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacySlackStreamingKeys(value),
},
{
path: ["channels", "slack", "accounts"],
message:
'channels.slack.accounts.<id>.streamMode and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.streaming with enum values instead. Run "openclaw doctor --fix".',
'channels.slack.accounts.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport} instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyKeysInAccounts(value, hasLegacySlackStreamingKeys),
},
];
@@ -501,60 +676,33 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
entry: Record<string, unknown>;
pathPrefix: string;
}) => {
const migrateCommonStreamingMode = (
resolveMode: (entry: Record<string, unknown>) => string,
) => {
const hasLegacyStreamMode = params.entry.streamMode !== undefined;
const legacyStreaming = params.entry.streaming;
if (!hasLegacyStreamMode && typeof legacyStreaming !== "boolean") {
return false;
}
const resolved = resolveMode(params.entry);
params.entry.streaming = resolved;
if (hasLegacyStreamMode) {
delete params.entry.streamMode;
changes.push(
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
);
}
if (typeof legacyStreaming === "boolean") {
changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
}
return true;
};
const hasLegacyStreamMode = params.entry.streamMode !== undefined;
const legacyStreaming = params.entry.streaming;
const legacyNativeStreaming = params.entry.nativeStreaming;
if (params.provider === "telegram") {
migrateCommonStreamingMode(resolveTelegramPreviewStreamMode);
moveLegacyStreamingShapeForPath({
entry: params.entry,
pathPrefix: params.pathPrefix,
changes,
resolveMode: resolveTelegramPreviewStreamMode,
});
return;
}
if (params.provider === "discord") {
migrateCommonStreamingMode(resolveDiscordPreviewStreamMode);
moveLegacyStreamingShapeForPath({
entry: params.entry,
pathPrefix: params.pathPrefix,
changes,
resolveMode: resolveDiscordPreviewStreamMode,
});
return;
}
if (!hasLegacyStreamMode && typeof legacyStreaming !== "boolean") {
return;
}
const resolvedStreaming = resolveSlackStreamingMode(params.entry);
const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry);
params.entry.streaming = resolvedStreaming;
params.entry.nativeStreaming = resolvedNativeStreaming;
if (hasLegacyStreamMode) {
delete params.entry.streamMode;
changes.push(formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming));
}
if (typeof legacyStreaming === "boolean") {
changes.push(
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
);
} else if (typeof legacyNativeStreaming !== "boolean" && hasLegacyStreamMode) {
changes.push(`Set ${params.pathPrefix}.nativeStreaming → ${resolvedNativeStreaming}.`);
}
moveLegacyStreamingShapeForPath({
entry: params.entry,
pathPrefix: params.pathPrefix,
changes,
resolveMode: resolveSlackStreamingMode,
resolveNativeTransport: resolveSlackNativeStreaming,
});
};
const migrateProvider = (provider: "telegram" | "discord" | "slack") => {

View File

@@ -767,66 +767,84 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
blockStreaming: {
type: "boolean",
},
blockStreamingCoalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
streaming: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
draftChunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
mode: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
preview: {
type: "object",
properties: {
chunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
},
{
type: "string",
const: "newline",
},
{
type: "string",
const: "sentence",
},
],
},
},
additionalProperties: false,
},
{
type: "string",
const: "newline",
},
additionalProperties: false,
},
block: {
type: "object",
properties: {
enabled: {
type: "boolean",
},
{
type: "string",
const: "sentence",
coalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
],
},
additionalProperties: false,
},
},
additionalProperties: false,
@@ -1913,66 +1931,84 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
blockStreaming: {
type: "boolean",
},
blockStreamingCoalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
streaming: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
draftChunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
mode: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
preview: {
type: "object",
properties: {
chunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
},
{
type: "string",
const: "newline",
},
{
type: "string",
const: "sentence",
},
],
},
},
additionalProperties: false,
},
{
type: "string",
const: "newline",
},
additionalProperties: false,
},
block: {
type: "object",
properties: {
enabled: {
type: "boolean",
},
{
type: "string",
const: "sentence",
coalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
],
},
additionalProperties: false,
},
},
additionalProperties: false,
@@ -2919,15 +2955,31 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Discord Streaming Mode",
help: 'Unified Discord stream preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord. Legacy boolean/streamMode keys are auto-mapped.',
},
"draftChunk.minChars": {
"streaming.mode": {
label: "Discord Streaming Mode",
help: 'Canonical Discord preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord.',
},
"streaming.chunkMode": {
label: "Discord Chunk Mode",
help: 'Chunking mode for outbound Discord text delivery: "length" (default) or "newline".',
},
"streaming.block.enabled": {
label: "Discord Block Streaming Enabled",
help: 'Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode="block".',
},
"streaming.block.coalesce": {
label: "Discord Block Streaming Coalesce",
help: "Merge streamed Discord block replies before final delivery.",
},
"streaming.preview.chunk.minChars": {
label: "Discord Draft Chunk Min Chars",
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming="block" (default: 200).',
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode="block" (default: 200).',
},
"draftChunk.maxChars": {
"streaming.preview.chunk.maxChars": {
label: "Discord Draft Chunk Max Chars",
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming="block" (default: 800; clamped to channels.discord.textChunkLimit).',
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming.mode="block" (default: 800; clamped to channels.discord.textChunkLimit).',
},
"draftChunk.breakPreference": {
"streaming.preview.chunk.breakPreference": {
label: "Discord Draft Chunk Break Preference",
help: "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.",
},
@@ -10581,41 +10633,91 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
blockStreaming: {
type: "boolean",
},
blockStreamingCoalesce: {
streaming: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
mode: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
preview: {
type: "object",
properties: {
chunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
},
{
type: "string",
const: "newline",
},
{
type: "string",
const: "sentence",
},
],
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
block: {
type: "object",
properties: {
enabled: {
type: "boolean",
},
coalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
nativeTransport: {
type: "boolean",
},
},
additionalProperties: false,
},
streaming: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
nativeStreaming: {
type: "boolean",
},
mediaMaxMb: {
type: "number",
exclusiveMinimum: 0,
@@ -11437,41 +11539,91 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
blockStreaming: {
type: "boolean",
},
blockStreamingCoalesce: {
streaming: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
mode: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
preview: {
type: "object",
properties: {
chunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
},
{
type: "string",
const: "newline",
},
{
type: "string",
const: "sentence",
},
],
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
block: {
type: "object",
properties: {
enabled: {
type: "boolean",
},
coalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
},
additionalProperties: false,
},
nativeTransport: {
type: "boolean",
},
},
additionalProperties: false,
},
streaming: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
nativeStreaming: {
type: "boolean",
},
mediaMaxMb: {
type: "number",
exclusiveMinimum: 0,
@@ -11946,9 +12098,25 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Slack Streaming Mode",
help: 'Unified Slack stream preview mode: "off" | "partial" | "block" | "progress". Legacy boolean/streamMode keys are auto-mapped.',
},
nativeStreaming: {
"streaming.mode": {
label: "Slack Streaming Mode",
help: 'Canonical Slack preview mode: "off" | "partial" | "block" | "progress".',
},
"streaming.chunkMode": {
label: "Slack Chunk Mode",
help: 'Chunking mode for outbound Slack text delivery: "length" (default) or "newline".',
},
"streaming.block.enabled": {
label: "Slack Block Streaming Enabled",
help: 'Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode="block".',
},
"streaming.block.coalesce": {
label: "Slack Block Streaming Coalesce",
help: "Merge streamed Slack block replies before final delivery.",
},
"streaming.nativeTransport": {
label: "Slack Native Streaming",
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming is partial (default: true).",
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true).",
},
"thread.historyScope": {
label: "Slack Thread History Scope",
@@ -12647,66 +12815,84 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
streaming: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
blockStreaming: {
type: "boolean",
},
draftChunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
mode: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
preview: {
type: "object",
properties: {
chunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
},
{
type: "string",
const: "newline",
},
{
type: "string",
const: "sentence",
},
],
},
},
additionalProperties: false,
},
{
type: "string",
const: "newline",
},
additionalProperties: false,
},
block: {
type: "object",
properties: {
enabled: {
type: "boolean",
},
{
type: "string",
const: "sentence",
coalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
],
},
},
additionalProperties: false,
},
blockStreamingCoalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
additionalProperties: false,
},
},
additionalProperties: false,
@@ -13662,66 +13848,84 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
streaming: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
blockStreaming: {
type: "boolean",
},
draftChunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
mode: {
type: "string",
enum: ["off", "partial", "block", "progress"],
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
chunkMode: {
type: "string",
enum: ["length", "newline"],
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
preview: {
type: "object",
properties: {
chunk: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
breakPreference: {
anyOf: [
{
type: "string",
const: "paragraph",
},
{
type: "string",
const: "newline",
},
{
type: "string",
const: "sentence",
},
],
},
},
additionalProperties: false,
},
{
type: "string",
const: "newline",
},
additionalProperties: false,
},
block: {
type: "object",
properties: {
enabled: {
type: "boolean",
},
{
type: "string",
const: "sentence",
coalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
},
additionalProperties: false,
},
],
},
},
additionalProperties: false,
},
blockStreamingCoalesce: {
type: "object",
properties: {
minChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
maxChars: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
},
idleMs: {
type: "integer",
minimum: 0,
maximum: 9007199254740991,
},
additionalProperties: false,
},
},
additionalProperties: false,
@@ -14059,6 +14263,34 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
label: "Telegram Streaming Mode",
help: 'Unified Telegram stream preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram. Legacy boolean/streamMode keys are auto-mapped.',
},
"streaming.mode": {
label: "Telegram Streaming Mode",
help: 'Canonical Telegram preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram.',
},
"streaming.chunkMode": {
label: "Telegram Chunk Mode",
help: 'Chunking mode for outbound Telegram text delivery: "length" (default) or "newline".',
},
"streaming.block.enabled": {
label: "Telegram Block Streaming Enabled",
help: 'Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode="block".',
},
"streaming.block.coalesce": {
label: "Telegram Block Streaming Coalesce",
help: "Merge streamed Telegram block replies before sending final delivery.",
},
"streaming.preview.chunk.minChars": {
label: "Telegram Draft Chunk Min Chars",
help: 'Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode="block".',
},
"streaming.preview.chunk.maxChars": {
label: "Telegram Draft Chunk Max Chars",
help: 'Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode="block".',
},
"streaming.preview.chunk.breakPreference": {
label: "Telegram Draft Chunk Break Preference",
help: "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence).",
},
"retry.attempts": {
label: "Telegram Retry Attempts",
help: "Max retry attempts for outbound Telegram API calls (default: 3).",

View File

@@ -425,3 +425,672 @@ describe("config paths", () => {
expect(getConfigValueAtPath(root, parsed.path)).toBeUndefined();
});
});
describe("config strict validation", () => {
it("rejects unknown fields", async () => {
const res = validateConfigObject({
agents: { list: [{ id: "pi" }] },
customUnknownField: { nested: "value" },
});
expect(res.ok).toBe(false);
});
it("accepts documented agents.list[].params overrides", () => {
const res = validateConfigObject({
agents: {
list: [
{
id: "main",
model: "anthropic/claude-opus-4-6",
params: {
cacheRetention: "none",
temperature: 0.4,
maxTokens: 8192,
},
},
],
},
});
expect(res.ok).toBe(true);
if (res.ok) {
expect(res.config.agents?.list?.[0]?.params).toEqual({
cacheRetention: "none",
temperature: 0.4,
maxTokens: 8192,
});
}
});
it("accepts top-level memorySearch via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
memorySearch: {
provider: "local",
fallback: "none",
query: { maxResults: 7 },
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
expect(snap.sourceConfig.agents?.defaults?.memorySearch).toMatchObject({
provider: "local",
fallback: "none",
query: { maxResults: 7 },
});
expect((snap.sourceConfig as { memorySearch?: unknown }).memorySearch).toBeUndefined();
});
});
it("accepts top-level heartbeat agent settings via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
heartbeat: {
every: "30m",
model: "anthropic/claude-3-5-haiku-20241022",
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
expect(snap.sourceConfig.agents?.defaults?.heartbeat).toMatchObject({
every: "30m",
model: "anthropic/claude-3-5-haiku-20241022",
});
expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toBeUndefined();
});
});
it("accepts top-level heartbeat visibility via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
heartbeat: {
showOk: true,
showAlerts: false,
useIndicator: true,
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
expect(snap.sourceConfig.channels?.defaults?.heartbeat).toMatchObject({
showOk: true,
showAlerts: false,
useIndicator: true,
});
expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toBeUndefined();
});
});
it("accepts legacy messages.tts provider keys via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
messages: {
tts: {
provider: "elevenlabs",
elevenlabs: {
apiKey: "test-key",
voiceId: "voice-1",
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "messages.tts")).toBe(true);
expect(snap.sourceConfig.messages?.tts?.providers?.elevenlabs).toEqual({
apiKey: "test-key",
voiceId: "voice-1",
});
expect(
(snap.sourceConfig.messages?.tts as Record<string, unknown> | undefined)?.elevenlabs,
).toBeUndefined();
});
});
it("accepts legacy talk flat fields via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
talk: {
voiceId: "voice-1",
modelId: "eleven_v3",
apiKey: "test-key",
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "talk")).toBe(true);
expect(snap.sourceConfig.talk?.providers?.elevenlabs).toEqual({
voiceId: "voice-1",
modelId: "eleven_v3",
apiKey: "test-key",
});
expect(
(snap.sourceConfig.talk as Record<string, unknown> | undefined)?.voiceId,
).toBeUndefined();
expect(
(snap.sourceConfig.talk as Record<string, unknown> | undefined)?.modelId,
).toBeUndefined();
expect(
(snap.sourceConfig.talk as Record<string, unknown> | undefined)?.apiKey,
).toBeUndefined();
});
});
it("accepts legacy sandbox perSession via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
agents: {
defaults: {
sandbox: {
perSession: true,
},
},
list: [
{
id: "pi",
sandbox: {
perSession: false,
},
},
],
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "agents.defaults.sandbox")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "agents.list")).toBe(true);
expect(snap.sourceConfig.agents?.defaults?.sandbox).toEqual({
scope: "session",
});
expect(snap.sourceConfig.agents?.list?.[0]?.sandbox).toEqual({
scope: "shared",
});
});
});
it("accepts legacy x_search auth via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
tools: {
web: {
x_search: {
apiKey: "test-key",
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "tools.web.x_search.apiKey")).toBe(
true,
);
expect(snap.sourceConfig.plugins?.entries?.xai?.config?.webSearch).toMatchObject({
apiKey: "test-key",
});
expect(
(snap.sourceConfig.tools?.web?.x_search as Record<string, unknown> | undefined)?.apiKey,
).toBeUndefined();
});
});
it("accepts legacy thread binding ttlHours via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
session: {
threadBindings: {
ttlHours: 24,
},
},
channels: {
discord: {
threadBindings: {
ttlHours: 12,
},
accounts: {
alpha: {
threadBindings: {
ttlHours: 6,
},
},
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "session.threadBindings")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels")).toBe(true);
expect(snap.sourceConfig.session?.threadBindings).toMatchObject({
idleHours: 24,
});
expect(snap.sourceConfig.channels?.discord?.threadBindings).toMatchObject({
idleHours: 12,
});
expect(snap.sourceConfig.channels?.discord?.accounts?.alpha?.threadBindings).toMatchObject({
idleHours: 6,
});
expect(
(snap.sourceConfig.session?.threadBindings as Record<string, unknown> | undefined)
?.ttlHours,
).toBeUndefined();
expect(
(snap.sourceConfig.channels?.discord?.threadBindings as Record<string, unknown> | undefined)
?.ttlHours,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.discord?.accounts?.alpha?.threadBindings as
| Record<string, unknown>
| undefined
)?.ttlHours,
).toBeUndefined();
});
});
it("accepts legacy channel streaming aliases via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
channels: {
telegram: {
streamMode: "block",
chunkMode: "newline",
blockStreaming: true,
draftChunk: {
minChars: 120,
},
},
discord: {
streaming: false,
blockStreamingCoalesce: {
idleMs: 250,
},
accounts: {
work: {
streamMode: "block",
draftChunk: {
maxChars: 900,
},
},
},
},
googlechat: {
streamMode: "append",
accounts: {
work: {
streamMode: "replace",
},
},
},
slack: {
streaming: true,
nativeStreaming: false,
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.telegram")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat.accounts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack")).toBe(true);
expect(snap.sourceConfig.channels?.telegram).toMatchObject({
streaming: {
mode: "block",
chunkMode: "newline",
block: {
enabled: true,
},
preview: {
chunk: {
minChars: 120,
},
},
},
});
expect(
(snap.sourceConfig.channels?.telegram as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
expect(snap.sourceConfig.channels?.discord).toMatchObject({
streaming: {
mode: "off",
block: {
coalesce: {
idleMs: 250,
},
},
},
});
expect(snap.sourceConfig.channels?.discord?.accounts?.work).toMatchObject({
streaming: {
mode: "block",
preview: {
chunk: {
maxChars: 900,
},
},
},
});
expect(
(snap.sourceConfig.channels?.googlechat as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.googlechat?.accounts?.work as
| Record<string, unknown>
| undefined
)?.streamMode,
).toBeUndefined();
expect(snap.sourceConfig.channels?.slack).toMatchObject({
streaming: {
mode: "partial",
nativeTransport: false,
},
});
});
});
it("accepts legacy nested channel allow aliases via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
channels: {
slack: {
channels: {
ops: {
allow: false,
},
},
accounts: {
work: {
channels: {
general: {
allow: true,
},
},
},
},
},
googlechat: {
groups: {
"spaces/aaa": {
allow: false,
},
},
accounts: {
work: {
groups: {
"spaces/bbb": {
allow: true,
},
},
},
},
},
discord: {
guilds: {
"100": {
channels: {
general: {
allow: false,
},
},
},
},
accounts: {
work: {
guilds: {
"200": {
channels: {
help: {
allow: true,
},
},
},
},
},
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack.accounts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat.accounts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe(
true,
);
expect(snap.sourceConfig.channels?.slack?.channels?.ops).toMatchObject({
enabled: false,
});
expect(snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"]).toMatchObject({
enabled: false,
});
expect(snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general).toMatchObject(
{
enabled: false,
},
);
expect(
(snap.sourceConfig.channels?.slack?.channels?.ops as Record<string, unknown> | undefined)
?.allow,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"] as
| Record<string, unknown>
| undefined
)?.allow,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general as
| Record<string, unknown>
| undefined
)?.allow,
).toBeUndefined();
});
});
it("accepts telegram groupMentionsOnly via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
channels: {
telegram: {
groupMentionsOnly: true,
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(
snap.legacyIssues.some((issue) => issue.path === "channels.telegram.groupMentionsOnly"),
).toBe(true);
expect(snap.sourceConfig.channels?.telegram?.groups?.["*"]).toMatchObject({
requireMention: true,
});
expect(
(snap.sourceConfig.channels?.telegram as Record<string, unknown> | undefined)
?.groupMentionsOnly,
).toBeUndefined();
});
});
it("accepts legacy plugins.entries.*.config.tts provider keys via auto-migration", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
plugins: {
entries: {
"voice-call": {
config: {
tts: {
provider: "openai",
openai: {
model: "gpt-4o-mini-tts",
voice: "alloy",
},
},
},
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "plugins.entries")).toBe(true);
const voiceCallTts = (
snap.sourceConfig.plugins?.entries as
| Record<
string,
{
config?: {
tts?: {
providers?: Record<string, unknown>;
openai?: unknown;
};
};
}
>
| undefined
)?.["voice-call"]?.config?.tts;
expect(voiceCallTts?.providers?.openai).toEqual({
model: "gpt-4o-mini-tts",
voice: "alloy",
});
expect(voiceCallTts?.openai).toBeUndefined();
});
});
it("accepts legacy discord voice tts provider keys via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
channels: {
discord: {
voice: {
tts: {
provider: "elevenlabs",
elevenlabs: {
voiceId: "voice-1",
},
},
},
accounts: {
main: {
voice: {
tts: {
edge: {
voice: "en-US-AvaNeural",
},
},
},
},
},
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.voice.tts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe(
true,
);
expect(snap.sourceConfig.channels?.discord?.voice?.tts?.providers?.elevenlabs).toEqual({
voiceId: "voice-1",
});
expect(
snap.sourceConfig.channels?.discord?.accounts?.main?.voice?.tts?.providers?.microsoft,
).toEqual({
voice: "en-US-AvaNeural",
});
expect(
(snap.sourceConfig.channels?.discord?.voice?.tts as Record<string, unknown> | undefined)
?.elevenlabs,
).toBeUndefined();
expect(
(
snap.sourceConfig.channels?.discord?.accounts?.main?.voice?.tts as
| Record<string, unknown>
| undefined
)?.edge,
).toBeUndefined();
});
});
it("does not treat resolved-only gateway.bind aliases as source-literal legacy or invalid", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
gateway: { bind: "${OPENCLAW_BIND}" },
});
const prev = process.env.OPENCLAW_BIND;
process.env.OPENCLAW_BIND = "0.0.0.0";
try {
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues).toHaveLength(0);
expect(snap.issues).toHaveLength(0);
} finally {
if (prev === undefined) {
delete process.env.OPENCLAW_BIND;
} else {
process.env.OPENCLAW_BIND = prev;
}
}
});
});
it("still marks literal gateway.bind host aliases as legacy", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
gateway: { bind: "0.0.0.0" },
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true);
});
});
});

View File

@@ -169,6 +169,89 @@ describe("legacy config detection", () => {
}
},
);
it.each([
{
name: "top-level off",
input: { channels: { telegram: { streamMode: "off" } } },
assert: (config: NonNullable<OpenClawConfig>) => {
expect(config.channels?.telegram?.streaming?.mode).toBe("off");
expect(
(config.channels?.telegram as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
},
},
{
name: "top-level block",
input: { channels: { telegram: { streamMode: "block" } } },
assert: (config: NonNullable<OpenClawConfig>) => {
expect(config.channels?.telegram?.streaming?.mode).toBe("block");
expect(
(config.channels?.telegram as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
},
},
{
name: "per-account off",
input: {
channels: {
telegram: {
accounts: {
ops: {
streamMode: "off",
},
},
},
},
},
assert: (config: NonNullable<OpenClawConfig>) => {
expect(config.channels?.telegram?.accounts?.ops?.streaming?.mode).toBe("off");
expect(
(config.channels?.telegram?.accounts?.ops as Record<string, unknown> | undefined)
?.streamMode,
).toBeUndefined();
},
},
] as const)(
"normalizes telegram legacy streamMode alias during migration: $name",
({ input, assert, name }) => {
const res = migrateLegacyConfig(input);
expect(res.config, name).not.toBeNull();
if (res.config) {
assert(res.config);
}
},
);
it.each([
{
name: "boolean streaming=true",
input: { channels: { discord: { streaming: true } } },
expectedChanges: [
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (partial).",
],
expectedStreaming: "partial",
},
{
name: "streamMode with streaming boolean",
input: { channels: { discord: { streaming: false, streamMode: "block" } } },
expectedChanges: ["Moved channels.discord.streamMode → channels.discord.streaming.mode (block)."],
expectedStreaming: "block",
},
] as const)(
"normalizes discord streaming fields during legacy migration: $name",
({ input, expectedChanges, expectedStreaming, name }) => {
const res = migrateLegacyConfig(input);
for (const expectedChange of expectedChanges) {
expect(res.changes, name).toContain(expectedChange);
}
expect(res.config?.channels?.discord?.streaming?.mode, name).toBe(expectedStreaming);
expect(
(res.config?.channels?.discord as Record<string, unknown> | undefined)?.streamMode,
name,
).toBeUndefined();
},
);
it.each([
{
name: "streaming=true",
@@ -193,11 +276,76 @@ describe("legacy config detection", () => {
if (!res.ok) {
expect(res.issues[0]?.path, name).toBe("channels.discord");
expect(res.issues[0]?.message, name).toContain(
"channels.discord.streamMode and boolean channels.discord.streaming are legacy",
"channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy",
);
}
},
);
it.each([
{
name: "discord account streaming boolean",
input: {
channels: {
discord: {
accounts: {
work: {
streaming: true,
},
},
},
},
},
assert: (config: NonNullable<OpenClawConfig>) => {
expect(config.channels?.discord?.accounts?.work?.streaming?.mode).toBe("partial");
expect(
(config.channels?.discord?.accounts?.work as Record<string, unknown> | undefined)
?.streamMode,
).toBeUndefined();
},
},
{
name: "slack streamMode alias",
input: {
channels: {
slack: {
streamMode: "status_final",
},
},
},
assert: (config: NonNullable<OpenClawConfig>) => {
expect(config.channels?.slack?.streaming?.mode).toBe("progress");
expect(
(config.channels?.slack as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
expect(config.channels?.slack?.streaming?.nativeTransport).toBe(true);
},
},
{
name: "slack streaming boolean legacy",
input: {
channels: {
slack: {
streaming: false,
},
},
},
assert: (config: NonNullable<OpenClawConfig>) => {
expect(config.channels?.slack?.streaming?.mode).toBe("off");
expect(config.channels?.slack?.streaming?.nativeTransport).toBe(false);
},
},
] as const)(
"normalizes account-level discord/slack streaming alias during migration: $name",
({ input, assert, name }) => {
const res = migrateLegacyConfig(input);
expect(res.config, name).not.toBeNull();
if (res.config) {
assert(res.config);
}
},
);
it("accepts historyLimit overrides per provider and account", async () => {
const res = validateConfigObject({
messages: { groupChat: { historyLimit: 12 } },

View File

@@ -8,6 +8,8 @@ export type ReplyToMode = "off" | "first" | "all" | "batched";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type ContextVisibilityMode = "all" | "allowlist" | "allowlist_quote";
export type TextChunkMode = "length" | "newline";
export type StreamingMode = "off" | "partial" | "block" | "progress";
export type OutboundRetryConfig = {
/** Max retry attempts for outbound requests (default: 3). */
@@ -32,6 +34,50 @@ export type BlockStreamingChunkConfig = {
breakPreference?: "paragraph" | "newline" | "sentence";
};
export type ChannelStreamingPreviewConfig = {
/** Chunking thresholds for preview-draft updates while streaming. */
chunk?: BlockStreamingChunkConfig;
};
export type ChannelStreamingBlockConfig = {
/** Enable chunked block-reply delivery for channels that support it. */
enabled?: boolean;
/** Merge streamed block replies before sending. */
coalesce?: BlockStreamingCoalesceConfig;
};
export type ChannelStreamingConfig = {
/**
* Preview streaming mode:
* - "off": disable preview updates
* - "partial": update one preview in place
* - "block": emit larger chunked preview updates
* - "progress": progress/status preview mode for channels that support it
*/
mode?: StreamingMode;
/** Chunking mode for outbound text delivery. */
chunkMode?: TextChunkMode;
/**
* Channel-specific native transport streaming toggle.
* Used today by Slack's native stream API.
*/
nativeTransport?: boolean;
preview?: ChannelStreamingPreviewConfig;
block?: ChannelStreamingBlockConfig;
};
export type ChannelDeliveryStreamingConfig = Pick<ChannelStreamingConfig, "chunkMode" | "block">;
export type ChannelPreviewStreamingConfig = Pick<
ChannelStreamingConfig,
"mode" | "chunkMode" | "preview" | "block"
>;
export type SlackChannelStreamingConfig = Pick<
ChannelStreamingConfig,
"mode" | "chunkMode" | "preview" | "block" | "nativeTransport"
>;
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";
export type MarkdownConfig = {

View File

@@ -1,6 +1,5 @@
import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
ChannelPreviewStreamingConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
@@ -253,22 +252,8 @@ export type DiscordAccountConfig = {
contextVisibility?: ContextVisibilityMode;
/** Outbound text chunk size (chars). Default: 2000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/**
* Live stream preview mode:
* - "off": disable preview updates
* - "partial": edit a single preview message
* - "block": stream in chunked preview updates
* - "progress": alias that maps to "partial" on Discord
*/
streaming?: DiscordStreamMode;
/** Chunking config for Discord stream previews in `streaming: "block"`. */
draftChunk?: BlockStreamingChunkConfig;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */
streaming?: ChannelPreviewStreamingConfig;
/**
* Soft max line count per Discord message.
* Discord clients can clip/collapse very tall messages; splitting by lines

View File

@@ -1,10 +1,10 @@
import type {
BlockStreamingCoalesceConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
MarkdownConfig,
ReplyToMode,
SlackChannelStreamingConfig,
} from "./types.base.js";
import type {
ChannelHealthMonitorConfig,
@@ -149,24 +149,8 @@ export type SlackAccountConfig = {
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/**
* Stream preview mode:
* - "off": disable live preview streaming
* - "partial": replace preview text with the latest partial output (default)
* - "block": append chunked preview updates
* - "progress": show progress status, then send final text
*/
streaming?: SlackStreamingMode;
/**
* Slack native text streaming toggle (`chat.startStream` / `chat.appendStream` / `chat.stopStream`).
* Used when `streaming` is `partial`. Default: true.
*/
nativeStreaming?: boolean;
/** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */
streaming?: SlackChannelStreamingConfig;
mediaMaxMb?: number;
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
reactionNotifications?: SlackReactionNotificationMode;

View File

@@ -1,6 +1,5 @@
import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
ChannelPreviewStreamingConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
@@ -148,22 +147,8 @@ export type TelegramAccountConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/**
* Stream preview mode:
* - "off": disable preview updates
* - "partial": edit a single preview message
* - "block": stream in larger chunked updates
* - "progress": alias that maps to "partial" on Telegram
*/
streaming?: TelegramStreamingMode;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Draft block-stream chunking thresholds for Telegram preview edits. */
draftChunk?: BlockStreamingChunkConfig;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */
streaming?: ChannelPreviewStreamingConfig;
mediaMaxMb?: number;
/** Telegram API client timeout in seconds (grammY ApiClientOptions). */
timeoutSeconds?: number;

View File

@@ -70,6 +70,30 @@ const TelegramCapabilitiesSchema = z.union([
})
.strict(),
]);
const TextChunkModeSchema = z.enum(["length", "newline"]);
const UnifiedStreamingModeSchema = z.enum(["off", "partial", "block", "progress"]);
const ChannelStreamingBlockSchema = z
.object({
enabled: z.boolean().optional(),
coalesce: BlockStreamingCoalesceSchema.optional(),
})
.strict();
const ChannelStreamingPreviewSchema = z
.object({
chunk: BlockStreamingChunkSchema.optional(),
})
.strict();
const ChannelPreviewStreamingConfigSchema = z
.object({
mode: UnifiedStreamingModeSchema.optional(),
chunkMode: TextChunkModeSchema.optional(),
preview: ChannelStreamingPreviewSchema.optional(),
block: ChannelStreamingBlockSchema.optional(),
})
.strict();
const SlackStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
nativeTransport: z.boolean().optional(),
}).strict();
const SlackCapabilitiesSchema = z.union([
z.array(z.string()),
z
@@ -205,11 +229,7 @@ export const TelegramAccountSchemaBase = z
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
streaming: z.enum(["off", "partial", "block", "progress"]).optional(),
blockStreaming: z.boolean().optional(),
draftChunk: BlockStreamingChunkSchema.optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
streaming: ChannelPreviewStreamingConfigSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
timeoutSeconds: z.number().int().positive().optional(),
retry: RetryConfigSchema,
@@ -499,11 +519,7 @@ export const DiscordAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
streaming: z.enum(["off", "partial", "block", "progress"]).optional(),
draftChunk: BlockStreamingChunkSchema.optional(),
streaming: ChannelPreviewStreamingConfigSchema.optional(),
maxLinesPerMessage: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
retry: RetryConfigSchema,
@@ -894,11 +910,7 @@ export const SlackAccountSchema = z
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
streaming: z.enum(["off", "partial", "block", "progress"]).optional(),
nativeStreaming: z.boolean().optional(),
streaming: SlackStreamingConfigSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
getChannelStreamingConfigObject,
resolveChannelStreamingBlockCoalesce,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingChunkMode,
resolveChannelStreamingNativeTransport,
resolveChannelStreamingPreviewChunk,
} from "./channel-streaming.js";
describe("channel-streaming", () => {
it("reads canonical nested streaming config first", () => {
const entry = {
streaming: {
chunkMode: "newline",
nativeTransport: true,
block: {
enabled: true,
coalesce: { minChars: 40, maxChars: 80, idleMs: 250 },
},
preview: {
chunk: { minChars: 10, maxChars: 20, breakPreference: "sentence" },
},
},
chunkMode: "length",
blockStreaming: false,
nativeStreaming: false,
blockStreamingCoalesce: { minChars: 5, maxChars: 15, idleMs: 100 },
draftChunk: { minChars: 2, maxChars: 4, breakPreference: "paragraph" },
} as const;
expect(getChannelStreamingConfigObject(entry)).toEqual(entry.streaming);
expect(resolveChannelStreamingChunkMode(entry)).toBe("newline");
expect(resolveChannelStreamingNativeTransport(entry)).toBe(true);
expect(resolveChannelStreamingBlockEnabled(entry)).toBe(true);
expect(resolveChannelStreamingBlockCoalesce(entry)).toEqual({
minChars: 40,
maxChars: 80,
idleMs: 250,
});
expect(resolveChannelStreamingPreviewChunk(entry)).toEqual({
minChars: 10,
maxChars: 20,
breakPreference: "sentence",
});
});
it("falls back to legacy flat fields when the canonical object is absent", () => {
const entry = {
chunkMode: "newline",
blockStreaming: true,
nativeStreaming: true,
blockStreamingCoalesce: { minChars: 120, maxChars: 240, idleMs: 500 },
draftChunk: { minChars: 8, maxChars: 16, breakPreference: "newline" },
} as const;
expect(getChannelStreamingConfigObject(entry)).toBeUndefined();
expect(resolveChannelStreamingChunkMode(entry)).toBe("newline");
expect(resolveChannelStreamingNativeTransport(entry)).toBe(true);
expect(resolveChannelStreamingBlockEnabled(entry)).toBe(true);
expect(resolveChannelStreamingBlockCoalesce(entry)).toEqual({
minChars: 120,
maxChars: 240,
idleMs: 500,
});
expect(resolveChannelStreamingPreviewChunk(entry)).toEqual({
minChars: 8,
maxChars: 16,
breakPreference: "newline",
});
});
});

View File

@@ -0,0 +1,101 @@
import type {
BlockStreamingChunkConfig,
BlockStreamingCoalesceConfig,
ChannelDeliveryStreamingConfig,
ChannelPreviewStreamingConfig,
ChannelStreamingConfig,
SlackChannelStreamingConfig,
TextChunkMode,
} from "../config/types.base.js";
export type {
ChannelDeliveryStreamingConfig,
ChannelPreviewStreamingConfig,
ChannelStreamingBlockConfig,
ChannelStreamingConfig,
ChannelStreamingPreviewConfig,
SlackChannelStreamingConfig,
StreamingMode,
TextChunkMode,
} from "../config/types.base.js";
type StreamingCompatEntry = {
streaming?: unknown;
chunkMode?: unknown;
blockStreaming?: unknown;
draftChunk?: unknown;
blockStreamingCoalesce?: unknown;
nativeStreaming?: unknown;
};
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function asTextChunkMode(value: unknown): TextChunkMode | undefined {
return value === "length" || value === "newline" ? value : undefined;
}
function asBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function asBlockStreamingCoalesceConfig(value: unknown): BlockStreamingCoalesceConfig | undefined {
return asObjectRecord(value) as BlockStreamingCoalesceConfig | undefined;
}
function asBlockStreamingChunkConfig(value: unknown): BlockStreamingChunkConfig | undefined {
return asObjectRecord(value) as BlockStreamingChunkConfig | undefined;
}
export function getChannelStreamingConfigObject(
entry: StreamingCompatEntry | null | undefined,
): ChannelStreamingConfig | undefined {
const streaming = asObjectRecord(entry?.streaming);
return streaming ? (streaming as ChannelStreamingConfig) : undefined;
}
export function resolveChannelStreamingChunkMode(
entry: StreamingCompatEntry | null | undefined,
): TextChunkMode | undefined {
return (
asTextChunkMode(getChannelStreamingConfigObject(entry)?.chunkMode) ??
asTextChunkMode(entry?.chunkMode)
);
}
export function resolveChannelStreamingBlockEnabled(
entry: StreamingCompatEntry | null | undefined,
): boolean | undefined {
const config = getChannelStreamingConfigObject(entry);
return asBoolean(config?.block?.enabled) ?? asBoolean(entry?.blockStreaming);
}
export function resolveChannelStreamingBlockCoalesce(
entry: StreamingCompatEntry | null | undefined,
): BlockStreamingCoalesceConfig | undefined {
const config = getChannelStreamingConfigObject(entry);
return (
asBlockStreamingCoalesceConfig(config?.block?.coalesce) ??
asBlockStreamingCoalesceConfig(entry?.blockStreamingCoalesce)
);
}
export function resolveChannelStreamingPreviewChunk(
entry: StreamingCompatEntry | null | undefined,
): BlockStreamingChunkConfig | undefined {
const config = getChannelStreamingConfigObject(entry);
return (
asBlockStreamingChunkConfig(config?.preview?.chunk) ??
asBlockStreamingChunkConfig(entry?.draftChunk)
);
}
export function resolveChannelStreamingNativeTransport(
entry: StreamingCompatEntry | null | undefined,
): boolean | undefined {
const config = getChannelStreamingConfigObject(entry);
return asBoolean(config?.nativeTransport) ?? asBoolean(entry?.nativeStreaming);
}

View File

@@ -68,8 +68,20 @@ describe("config footprint guardrails", () => {
"hooks.internal.handlers",
"channels.telegram.groupMentionsOnly",
"channels.telegram.streamMode",
"channels.telegram.chunkMode",
"channels.telegram.blockStreaming",
"channels.telegram.draftChunk",
"channels.telegram.blockStreamingCoalesce",
"channels.slack.streamMode",
"channels.slack.chunkMode",
"channels.slack.blockStreaming",
"channels.slack.blockStreamingCoalesce",
"channels.slack.nativeStreaming",
"channels.discord.streamMode",
"channels.discord.chunkMode",
"channels.discord.blockStreaming",
"channels.discord.draftChunk",
"channels.discord.blockStreamingCoalesce",
"channels.googlechat.streamMode",
"channels.slack.channels.*.allow",
"channels.slack.accounts.*.channels.*.allow",
@@ -100,6 +112,16 @@ describe("config footprint guardrails", () => {
}
});
it("keeps canonical nested streaming paths in the public core channel schema", () => {
const source = readSource("src/config/zod-schema.providers-core.ts");
expect(source).toContain("streaming: ChannelPreviewStreamingConfigSchema.optional(),");
expect(source).toContain("streaming: SlackStreamingConfigSchema.optional(),");
expect(source).not.toContain('streamMode: z.enum(["replace", "status_final", "append"])');
expect(source).not.toContain("draftChunk:");
expect(source).not.toContain("nativeStreaming:");
});
it("keeps shared setup input canonical-first", () => {
const source = readSource("src/channels/plugins/types.core.ts");