diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 913175147cf..73c097ce6b8 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/extensions/bluebubbles/src/accounts.ts b/extensions/bluebubbles/src/accounts.ts index b8fdd644f5a..f9f485f974f 100644 --- a/extensions/bluebubbles/src/accounts.ts +++ b/extensions/bluebubbles/src/accounts.ts @@ -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: { diff --git a/extensions/discord/src/config-ui-hints.ts b/extensions/discord/src/config-ui-hints.ts index f7831b2292e..71dae50a1e6 100644 --- a/extensions/discord/src/config-ui-hints.ts +++ b/extensions/discord/src/config-ui-hints.ts @@ -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.", }, diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts index 49469ec77c1..9954e24d9a5 100644 --- a/extensions/discord/src/doctor-contract.ts +++ b/extensions/discord/src/doctor-contract.ts @@ -11,6 +11,14 @@ function asObjectRecord(value: unknown): Record | null { : null; } +function ensureNestedRecord(owner: Record, key: string): Record { + const existing = asObjectRecord(owner[key]); + if (existing) { + return { ...existing }; + } + return {}; +} + function normalizeDiscordDmAliases(params: { entry: Record; pathPrefix: string; @@ -108,45 +116,100 @@ function normalizeDiscordStreamingAliases(params: { pathPrefix: string; changes: string[]; }): { entry: Record; 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..streamMode and boolean channels.discord.accounts..streaming are legacy; use channels.discord.accounts..streaming.", + "channels.discord.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts..streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.", match: hasLegacyDiscordAccountStreamingAliases, }, { diff --git a/extensions/discord/src/doctor-shared.ts b/extensions/discord/src/doctor-shared.ts index f9700acf039..c0978c71074 100644 --- a/extensions/discord/src/doctor-shared.ts +++ b/extensions/discord/src/doctor-shared.ts @@ -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..streamMode and boolean channels.discord.accounts..streaming are legacy; use channels.discord.accounts..streaming.", + "channels.discord.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts..streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.", match: hasLegacyDiscordAccountStreamingAliases, }, ]; diff --git a/extensions/discord/src/doctor.test.ts b/extensions/discord/src/doctor.test.ts index 1cafcdcb582..a83e0f43585 100644 --- a/extensions/discord/src/doctor.test.ts +++ b/extensions/discord/src/doctor.test.ts @@ -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: { diff --git a/extensions/discord/src/doctor.ts b/extensions/discord/src/doctor.ts index 3e8e2b5b7bd..5187e591eab 100644 --- a/extensions/discord/src/doctor.ts +++ b/extensions/discord/src/doctor.ts @@ -21,6 +21,14 @@ function asObjectRecord(value: unknown): Record | null { : null; } +function ensureNestedRecord(owner: Record, key: string): Record { + 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; 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 }; diff --git a/extensions/discord/src/draft-chunking.ts b/extensions/discord/src/draft-chunking.ts index 49eda20bfe3..f3205b46d18 100644 --- a/extensions/discord/src/draft-chunking.ts +++ b/extensions/discord/src/draft-chunking.ts @@ -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, diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index f24cf619e36..56aad4d02e0 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -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> | 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 diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 44650d83522..c83c3defc65 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -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, }, }); diff --git a/extensions/discord/src/preview-streaming.ts b/extensions/discord/src/preview-streaming.ts index 4734840a481..67e2b982280 100644 --- a/extensions/discord/src/preview-streaming.ts +++ b/extensions/discord/src/preview-streaming.ts @@ -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; } diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index c8b083a9a04..112960c0b0c 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -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, }; } diff --git a/extensions/slack/src/config-ui-hints.ts b/extensions/slack/src/config-ui-hints.ts index 0c3011981ea..457a93e3dcb 100644 --- a/extensions/slack/src/config-ui-hints.ts +++ b/extensions/slack/src/config-ui-hints.ts @@ -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", diff --git a/extensions/slack/src/doctor-contract.ts b/extensions/slack/src/doctor-contract.ts index 1e201c7a4db..5d7d0694f00 100644 --- a/extensions/slack/src/doctor-contract.ts +++ b/extensions/slack/src/doctor-contract.ts @@ -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 | null { : null; } +function ensureNestedRecord(owner: Record, key: string): Record { + const existing = asObjectRecord(owner[key]); + if (existing) { + return { ...existing }; + } + return {}; +} + function normalizeSlackStreamingAliases(params: { entry: Record; pathPrefix: string; changes: string[]; }): { entry: Record; 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..streamMode and boolean channels.slack.accounts..streaming are legacy; use channels.slack.accounts..streaming and channels.slack.accounts..nativeStreaming.", + "channels.slack.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts..streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.", match: hasLegacySlackAccountStreamingAliases, }, ]; diff --git a/extensions/slack/src/doctor.test.ts b/extensions/slack/src/doctor.test.ts new file mode 100644 index 00000000000..1d3445f93f0 --- /dev/null +++ b/extensions/slack/src/doctor.test.ts @@ -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)."]); + }); +}); diff --git a/extensions/slack/src/doctor.ts b/extensions/slack/src/doctor.ts index 97da44c4432..ec656a95168 100644 --- a/extensions/slack/src/doctor.ts +++ b/extensions/slack/src/doctor.ts @@ -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 | null { return value && typeof value === "object" && !Array.isArray(value) @@ -19,6 +14,14 @@ function asObjectRecord(value: unknown): Record | null { : null; } +function ensureNestedRecord(owner: Record, key: string): Record { + 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; 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..streamMode and boolean channels.slack.accounts..streaming are legacy; use channels.slack.accounts..streaming and channels.slack.accounts..nativeStreaming.", + "channels.slack.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts..streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.", match: hasLegacySlackAccountStreamingAliases, }, ]; diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index c6ec5bd8aef..9e704da52d4 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -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 diff --git a/extensions/slack/src/streaming-compat.ts b/extensions/slack/src/streaming-compat.ts index 2c6fd085627..a781a60055b 100644 --- a/extensions/slack/src/streaming-compat.ts +++ b/extensions/slack/src/streaming-compat.ts @@ -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}).`; -} diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index ed5d3653fe1..9888a8f668e 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -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; diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 0738fc27c6c..10847b2c3af 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -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(); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 08e5ba22fc9..f847f893092 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -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, diff --git a/extensions/telegram/src/config-ui-hints.ts b/extensions/telegram/src/config-ui-hints.ts index 13b4ecacbfd..604c0ff16ef 100644 --- a/extensions/telegram/src/config-ui-hints.ts +++ b/extensions/telegram/src/config-ui-hints.ts @@ -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).", diff --git a/extensions/telegram/src/doctor-contract.ts b/extensions/telegram/src/doctor-contract.ts index e29a439d857..3a3f910044f 100644 --- a/extensions/telegram/src/doctor-contract.ts +++ b/extensions/telegram/src/doctor-contract.ts @@ -11,43 +11,108 @@ function asObjectRecord(value: unknown): Record | null { : null; } +function ensureNestedRecord(owner: Record, key: string): Record { + const existing = asObjectRecord(owner[key]); + if (existing) { + return { ...existing }; + } + return {}; +} + function normalizeTelegramStreamingAliases(params: { entry: Record; pathPrefix: string; changes: string[]; }): { entry: Record; 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..streamMode and boolean channels.telegram.accounts..streaming are legacy; use channels.telegram.accounts..streaming="off|partial|block".', + "channels.telegram.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts..streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.", match: hasLegacyTelegramAccountStreamingAliases, }, ]; diff --git a/extensions/telegram/src/doctor.test.ts b/extensions/telegram/src/doctor.test.ts index f8293e2fc83..cae4b54fb39 100644 --- a/extensions/telegram/src/doctor.test.ts +++ b/extensions/telegram/src/doctor.test.ts @@ -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: { diff --git a/extensions/telegram/src/doctor.ts b/extensions/telegram/src/doctor.ts index e015ec9067b..d58899cab68 100644 --- a/extensions/telegram/src/doctor.ts +++ b/extensions/telegram/src/doctor.ts @@ -27,6 +27,14 @@ function asObjectRecord(value: unknown): Record | null { : null; } +function ensureNestedRecord(owner: Record, key: string): Record { + 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; 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..streamMode and boolean channels.telegram.accounts..streaming are legacy; use channels.telegram.accounts..streaming="off|partial|block".', + "channels.telegram.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts..streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.", match: hasLegacyTelegramAccountStreamingAliases, }, ]; diff --git a/extensions/telegram/src/draft-chunking.ts b/extensions/telegram/src/draft-chunking.ts index aab0e9d45ad..ed297c4ee39 100644 --- a/extensions/telegram/src/draft-chunking.ts +++ b/extensions/telegram/src/draft-chunking.ts @@ -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, diff --git a/extensions/telegram/src/preview-streaming.ts b/extensions/telegram/src/preview-streaming.ts index 86e679ddd64..f9e17e93dfb 100644 --- a/extensions/telegram/src/preview-streaming.ts +++ b/extensions/telegram/src/preview-streaming.ts @@ -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"; diff --git a/extensions/whatsapp/src/account-config.ts b/extensions/whatsapp/src/account-config.ts index 74bd6c5c89a..9debba3315e 100644 --- a/extensions/whatsapp/src/account-config.ts +++ b/extensions/whatsapp/src/account-config.ts @@ -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, }; } diff --git a/package.json b/package.json index 1d93d996225..260ba61793c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 77a046beb44..9237c2ec769 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -13,6 +13,7 @@ "setup-adapter-runtime", "setup-runtime", "channel-setup", + "channel-streaming", "setup-tools", "approval-auth-runtime", "approval-client-runtime", diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts index 92be0035472..5ad097d01ba 100644 --- a/src/auto-reply/chunk.ts +++ b/src/auto-reply/chunk.ts @@ -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; + 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( diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index df1582846ff..4142216d77d 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -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; + 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 = { diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 4e507c4f99c..ad7c6253b26 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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..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..threadBindings.ttlHours"); + expect(legacyMessages).toContain("channels..threadBindings.idleHours"); expect( noteSpy.mock.calls.some( ([message, title]) => diff --git a/src/commands/doctor-legacy-config.migrations.test.ts b/src/commands/doctor-legacy-config.migrations.test.ts index dea2067133a..4c922d0f3f9 100644 --- a/src/commands/doctor-legacy-config.migrations.test.ts +++ b/src/commands/doctor-legacy-config.migrations.test.ts @@ -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.", ]); }); diff --git a/src/commands/doctor-legacy-config.test.ts b/src/commands/doctor-legacy-config.test.ts index 64b80c4f47d..1368fbcb5cc 100644 --- a/src/commands/doctor-legacy-config.test.ts +++ b/src/commands/doctor-legacy-config.test.ts @@ -13,7 +13,7 @@ function getLegacyProperty(value: unknown, key: string): unknown { return (value as Record)[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.", ]); }); }); diff --git a/src/commands/doctor/shared/legacy-config-migrate.test.ts b/src/commands/doctor/shared/legacy-config-migrate.test.ts index 457c4b81032..38cf2d1cde0 100644 --- a/src/commands/doctor/shared/legacy-config-migrate.test.ts +++ b/src/commands/doctor/shared/legacy-config-migrate.test.ts @@ -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", diff --git a/src/commands/doctor/shared/legacy-config-migrations.channels.ts b/src/commands/doctor/shared/legacy-config-migrations.channels.ts index 6eb59f3cb83..fb6f8aaadc6 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.channels.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.channels.ts @@ -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, key: string): Record { + const existing = getRecord(owner[key]); + if (existing) { + return existing; + } + const created: Record = {}; + owner[key] = created; + return created; +} + +function moveLegacyStreamingShapeForPath(params: { + entry: Record; + pathPrefix: string; + changes: string[]; + resolveMode?: (entry: Record) => string; + resolveNativeTransport?: (entry: Record) => 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..streamMode is legacy; use channels.telegram.accounts..streaming instead. Run "openclaw doctor --fix".', + 'channels.telegram.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts..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..streamMode and boolean channels.discord.accounts..streaming are legacy; use channels.discord.accounts..streaming with enum values instead. Run "openclaw doctor --fix".', + 'channels.discord.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts..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..streamMode and boolean channels.slack.accounts..streaming are legacy; use channels.slack.accounts..streaming with enum values instead. Run "openclaw doctor --fix".', + 'channels.slack.accounts..streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts..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; pathPrefix: string; }) => { - const migrateCommonStreamingMode = ( - resolveMode: (entry: Record) => 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") => { diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index a62ec3e8013..6cd1402574b 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -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).", diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 7319d184783..3055e6777bc 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -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 | 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 | undefined)?.voiceId, + ).toBeUndefined(); + expect( + (snap.sourceConfig.talk as Record | undefined)?.modelId, + ).toBeUndefined(); + expect( + (snap.sourceConfig.talk as Record | 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 | 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 | undefined) + ?.ttlHours, + ).toBeUndefined(); + expect( + (snap.sourceConfig.channels?.discord?.threadBindings as Record | undefined) + ?.ttlHours, + ).toBeUndefined(); + expect( + ( + snap.sourceConfig.channels?.discord?.accounts?.alpha?.threadBindings as + | Record + | 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 | 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 | undefined)?.streamMode, + ).toBeUndefined(); + expect( + ( + snap.sourceConfig.channels?.googlechat?.accounts?.work as + | Record + | 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 | undefined) + ?.allow, + ).toBeUndefined(); + expect( + ( + snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"] as + | Record + | undefined + )?.allow, + ).toBeUndefined(); + expect( + ( + snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general as + | Record + | 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 | 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; + 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 | undefined) + ?.elevenlabs, + ).toBeUndefined(); + expect( + ( + snap.sourceConfig.channels?.discord?.accounts?.main?.voice?.tts as + | Record + | 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); + }); + }); +}); diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index 1ed7170ca9c..d6d80b30880 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -169,6 +169,89 @@ describe("legacy config detection", () => { } }, ); + it.each([ + { + name: "top-level off", + input: { channels: { telegram: { streamMode: "off" } } }, + assert: (config: NonNullable) => { + expect(config.channels?.telegram?.streaming?.mode).toBe("off"); + expect( + (config.channels?.telegram as Record | undefined)?.streamMode, + ).toBeUndefined(); + }, + }, + { + name: "top-level block", + input: { channels: { telegram: { streamMode: "block" } } }, + assert: (config: NonNullable) => { + expect(config.channels?.telegram?.streaming?.mode).toBe("block"); + expect( + (config.channels?.telegram as Record | undefined)?.streamMode, + ).toBeUndefined(); + }, + }, + { + name: "per-account off", + input: { + channels: { + telegram: { + accounts: { + ops: { + streamMode: "off", + }, + }, + }, + }, + }, + assert: (config: NonNullable) => { + expect(config.channels?.telegram?.accounts?.ops?.streaming?.mode).toBe("off"); + expect( + (config.channels?.telegram?.accounts?.ops as Record | 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 | 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) => { + expect(config.channels?.discord?.accounts?.work?.streaming?.mode).toBe("partial"); + expect( + (config.channels?.discord?.accounts?.work as Record | undefined) + ?.streamMode, + ).toBeUndefined(); + }, + }, + { + name: "slack streamMode alias", + input: { + channels: { + slack: { + streamMode: "status_final", + }, + }, + }, + assert: (config: NonNullable) => { + expect(config.channels?.slack?.streaming?.mode).toBe("progress"); + expect( + (config.channels?.slack as Record | undefined)?.streamMode, + ).toBeUndefined(); + expect(config.channels?.slack?.streaming?.nativeTransport).toBe(true); + }, + }, + { + name: "slack streaming boolean legacy", + input: { + channels: { + slack: { + streaming: false, + }, + }, + }, + assert: (config: NonNullable) => { + 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 } }, diff --git a/src/config/types.base.ts b/src/config/types.base.ts index d6e8f33631e..c3020f1af73 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -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; + +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 = { diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 776d413add0..6e4b658fffd 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -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 diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index f2a431d135c..02ce0ba353f 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -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; 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; diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 7bbdaa1b2ee..50d073d6f3f 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -1,6 +1,5 @@ import type { - BlockStreamingChunkConfig, - BlockStreamingCoalesceConfig, + ChannelPreviewStreamingConfig, ContextVisibilityMode, DmPolicy, GroupPolicy, @@ -148,22 +147,8 @@ export type TelegramAccountConfig = { dms?: Record; /** 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; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 60797eb3d71..6fbc666174d 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -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(), diff --git a/src/plugin-sdk/channel-streaming.test.ts b/src/plugin-sdk/channel-streaming.test.ts new file mode 100644 index 00000000000..316a02f790d --- /dev/null +++ b/src/plugin-sdk/channel-streaming.test.ts @@ -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", + }); + }); +}); diff --git a/src/plugin-sdk/channel-streaming.ts b/src/plugin-sdk/channel-streaming.ts new file mode 100644 index 00000000000..7dfa7277a16 --- /dev/null +++ b/src/plugin-sdk/channel-streaming.ts @@ -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 | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : 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); +} diff --git a/src/plugins/contracts/config-footprint-guardrails.test.ts b/src/plugins/contracts/config-footprint-guardrails.test.ts index 0962ae93378..566754cd355 100644 --- a/src/plugins/contracts/config-footprint-guardrails.test.ts +++ b/src/plugins/contracts/config-footprint-guardrails.test.ts @@ -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");