diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 8b63c485e385..3b8a28b41f85 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -4d48d01820bdf49966c3a6bdc69da909779d81db7214ed5b54651a8cd544a24c plugin-sdk-api-baseline.json -72bad044a66391e838dcbdab5ef6897a40a7026d53c86d7130a55d53d37b15f2 plugin-sdk-api-baseline.jsonl +8f362012108b8b2db08e836883fac095f88e70a4c1ef66a713980e1361ec48b6 plugin-sdk-api-baseline.json +ddbb02207930b419658209039696e8008d76c8f839d7ecde95e1c8ba91c19c7a plugin-sdk-api-baseline.jsonl diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts index fbe71e650e43..7c4c7e2ecc67 100644 --- a/extensions/discord/src/doctor-contract.ts +++ b/extensions/discord/src/doctor-contract.ts @@ -8,20 +8,46 @@ import { isSupportedRealtimeVoiceActivationName, normalizeRealtimeVoiceActivationNamePrefix, } from "openclaw/plugin-sdk/realtime-voice"; -import { - asObjectRecord, - hasLegacyAccountStreamingAliases, - hasLegacyStreamingAliases, - normalizeLegacyChannelAliases, - resolveLegacyAliasStreamingMode, -} from "openclaw/plugin-sdk/runtime-doctor"; +import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor"; const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const; type AgentBindingConfig = NonNullable[number]; -function hasLegacyDiscordStreamingAliases(value: unknown): boolean { - return hasLegacyStreamingAliases(value, { includePreviewChunk: true }); -} +const streamingAliasMigration = defineChannelAliasMigration({ + channelId: "discord", + streaming: { + // Runtime mode resolution dropped legacy streamMode reads; the doctor + // resolver keeps them so migration preserves configured intent. + defaultMode: "off", + // Discord previews default to progress only while `streaming` is absent; + // any present object (even without mode) resolves off, so migration pins + // progress when delivery-only aliases create the object with no root + // streaming object to inherit from. + absentObjectDefault: "progress", + includePreviewChunk: true, + }, + // Discord's account merge replaces the root streaming object wholesale + // (`streaming` not in mergeDiscordAccountConfig nestedObjectKeys), so doctor + // must seed materialized account objects with the inherited root settings. + accountStreamingReplacesRoot: true, + dm: { root: true, accounts: true }, + normalizeAccountExtra: ({ account, pathPrefix, changes }) => { + const accountVoice = asObjectRecord(account.voice); + if ( + !accountVoice || + !migrateLegacyTtsConfig(asObjectRecord(accountVoice.tts), `${pathPrefix}.voice.tts`, changes) + ) { + return { entry: account, changed: false }; + } + return { + entry: { + ...account, + voice: accountVoice, + }, + changed: true, + }; + }, +}); function hasLegacyTtsProviderKeys(value: unknown): boolean { const tts = asObjectRecord(value); @@ -475,18 +501,7 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ 'channels.discord.accounts..voice.realtime.wakeNames entries longer than two words are unsupported; use one- or two-word activation names. Run "openclaw doctor --fix".', match: hasUnsupportedDiscordAccountRealtimeWakeNames, }, - { - path: ["channels", "discord"], - message: - '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}. Run "openclaw doctor --fix".', - match: hasLegacyDiscordStreamingAliases, - }, - { - path: ["channels", "discord", "accounts"], - message: - '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}. Run "openclaw doctor --fix".', - match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyDiscordStreamingAliases), - }, + ...streamingAliasMigration.legacyConfigRules, ]; export function normalizeCompatibilityConfig({ @@ -494,66 +509,18 @@ export function normalizeCompatibilityConfig({ }: { cfg: OpenClawConfig; }): ChannelDoctorConfigMutation { - const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.discord); + const changes: string[] = []; + const bindingsToAdd: AgentBindingConfig[] = []; + + const aliases = streamingAliasMigration.normalizeChannelConfig({ cfg, changes }); + const rawEntry = asObjectRecord( + (aliases.config.channels as Record | undefined)?.discord, + ); if (!rawEntry) { return { config: cfg, changes: [] }; } - - const changes: string[] = []; - let updated; - let changed; - const bindingsToAdd: AgentBindingConfig[] = []; - - // Discord previews default to progress only while `streaming` is absent; - // any present object (even without `mode`) resolves off. Migration must pin - // the entry's previous effective mode when delivery-only aliases create the - // object, or doctor silently flips preview behavior. - const discordEffectiveMode = (entry: Record) => - entry.streaming === undefined && entry.streamMode === undefined - ? "progress" - : resolveLegacyAliasStreamingMode(entry, "off"); - const rootEffectiveMode = discordEffectiveMode(rawEntry); - - const aliases = normalizeLegacyChannelAliases({ - entry: rawEntry, - pathPrefix: "channels.discord", - changes, - normalizeDm: true, - normalizeAccountDm: true, - resolveStreamingOptions: (entry) => ({ - // Runtime mode resolution dropped legacy streamMode reads; the doctor - // resolver keeps them so migration preserves configured intent. - resolvedMode: resolveLegacyAliasStreamingMode(entry, "off"), - // Accounts without their own mode source inherit the root's effective - // mode at runtime (account `streaming` objects replace the root object - // wholesale on merge), so pin that. The root only hits the alias-only - // branch when both mode sources are absent, where this is "progress". - aliasOnlyMode: rootEffectiveMode, - includePreviewChunk: true, - }), - normalizeAccountExtra: ({ account, pathPrefix }) => { - const accountVoice = asObjectRecord(account.voice); - if ( - !accountVoice || - !migrateLegacyTtsConfig( - asObjectRecord(accountVoice.tts), - `${pathPrefix}.voice.tts`, - changes, - ) - ) { - return { entry: account, changed: false }; - } - return { - entry: { - ...account, - voice: accountVoice, - }, - changed: true, - }; - }, - }); - updated = aliases.entry; - changed = aliases.changed; + let updated = rawEntry; + let changed = aliases.config !== cfg; const guildAliases = normalizeDiscordGuildChannelAllowAliases({ entry: updated, @@ -639,9 +606,9 @@ export function normalizeCompatibilityConfig({ } return { config: { - ...cfg, + ...aliases.config, channels: { - ...cfg.channels, + ...aliases.config.channels, discord: updated, } as OpenClawConfig["channels"], bindings: diff --git a/extensions/discord/src/doctor.test.ts b/extensions/discord/src/doctor.test.ts index 4370387f6fc7..48e1a83d3d4b 100644 --- a/extensions/discord/src/doctor.test.ts +++ b/extensions/discord/src/doctor.test.ts @@ -108,16 +108,16 @@ describe("discord doctor", () => { ]); }); - it("pins the inherited root mode when migrating account delivery aliases", () => { + it("seeds the inherited root streaming settings when migrating account delivery aliases", () => { const normalize = getDiscordCompatibilityNormalizer(); // Account `streaming` objects replace the root object wholesale on merge, - // so the migrated account must carry the root mode it previously inherited. + // so the migrated account must carry the settings it previously inherited. const result = normalize({ cfg: { channels: { discord: { - streaming: { mode: "off" }, + streaming: { mode: "off", block: { coalesce: { idleMs: 5 } } }, accounts: { work: { chunkMode: "newline" } }, }, }, @@ -125,14 +125,20 @@ describe("discord doctor", () => { }); expect(result.config.channels?.discord).toEqual({ - streaming: { mode: "off" }, + streaming: { mode: "off", block: { coalesce: { idleMs: 5 } } }, accounts: { - work: { streaming: { mode: "off", chunkMode: "newline" } }, + work: { + streaming: { + mode: "off", + chunkMode: "newline", + block: { coalesce: { idleMs: 5 } }, + }, + }, }, }); expect(result.changes).toEqual([ "Moved channels.discord.accounts.work.chunkMode → channels.discord.accounts.work.streaming.chunkMode.", - "Set channels.discord.accounts.work.streaming.mode (off) to keep the previous default while migrating flat streaming keys.", + "Copied channels.discord.streaming into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.", ]); }); diff --git a/extensions/imessage/doctor-contract-api.test.ts b/extensions/imessage/doctor-contract-api.test.ts index bfb90e42b6b0..3a27af1a7dbd 100644 --- a/extensions/imessage/doctor-contract-api.test.ts +++ b/extensions/imessage/doctor-contract-api.test.ts @@ -54,7 +54,12 @@ describe("imessage normalizeCompatibilityConfig streaming aliases", () => { (imessage.accounts as Record>).personal, "personal iMessage account", ); - expect(personal.streaming).toEqual({ block: { coalesce: { idleMs: 250 } } }); + // iMessage deep-merges root+account streaming at runtime + // (mergeIMessageStreamingConfig), so migration keeps the account object + // account-local instead of seeding root values into it. + expect(personal.streaming).toEqual({ + block: { coalesce: { idleMs: 250 } }, + }); expect(personal.blockStreamingCoalesce).toBeUndefined(); for (const change of [ "Moved channels.imessage.chunkMode → channels.imessage.streaming.chunkMode.", diff --git a/extensions/imessage/doctor-contract-api.ts b/extensions/imessage/doctor-contract-api.ts index 101d47f9d9dc..9e973578ea32 100644 --- a/extensions/imessage/doctor-contract-api.ts +++ b/extensions/imessage/doctor-contract-api.ts @@ -4,11 +4,7 @@ import type { ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - hasLegacyAccountStreamingAliases, - normalizeLegacyChannelAliases, - resolveLegacyAliasStreamingMode, -} from "openclaw/plugin-sdk/runtime-doctor"; +import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; // Disabled `channels.imessage.catchup` blocks are retired. Enabled blocks stay @@ -38,16 +34,10 @@ function imessageEntryHasRetiredCatchup(entry: unknown): boolean { // iMessage's nested streaming schema is delivery-only ({chunkMode, block}); it // has no preview mode, so only the delivery flat aliases are legal legacy input. -function hasLegacyIMessageStreamingAliases(value: unknown): boolean { - if (!isRecord(value)) { - return false; - } - return ( - value.chunkMode !== undefined || - value.blockStreaming !== undefined || - value.blockStreamingCoalesce !== undefined - ); -} +const streamingAliasMigration = defineChannelAliasMigration({ + channelId: "imessage", + streaming: { defaultMode: "partial", deliveryOnly: true }, +}); export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ { @@ -57,18 +47,7 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ 'Run "openclaw doctor --fix" to remove disabled catchup blocks.', match: (value) => imessageEntryHasRetiredCatchup(value), }, - { - path: ["channels", "imessage"], - message: - 'channels.imessage.chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.streaming.{chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".', - match: hasLegacyIMessageStreamingAliases, - }, - { - path: ["channels", "imessage", "accounts"], - message: - 'channels.imessage.accounts..chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.accounts..streaming.{chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".', - match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyIMessageStreamingAliases), - }, + ...streamingAliasMigration.legacyConfigRules, ]; export function normalizeCompatibilityConfig({ @@ -111,32 +90,15 @@ export function normalizeCompatibilityConfig({ } } - // Only run the shared alias migration when the delivery flat aliases exist; - // iMessage has no streaming mode, so scalar `streaming` values are plain - // validation errors rather than migratable legacy shapes. - const hasStreamingAliases = - hasLegacyIMessageStreamingAliases(nextImessage) || - hasLegacyAccountStreamingAliases(nextImessage.accounts, hasLegacyIMessageStreamingAliases); - if (hasStreamingAliases) { - const aliases = normalizeLegacyChannelAliases({ - entry: nextImessage, - pathPrefix: "channels.imessage", - changes, - resolveStreamingOptions: (entry) => ({ - resolvedMode: resolveLegacyAliasStreamingMode(entry, "partial"), - }), - }); - nextImessage = aliases.entry; - } - + const aliases = streamingAliasMigration.normalizeChannelConfig({ + cfg: + nextImessage === imessage + ? cfg + : ({ ...cfg, channels: { ...channels, imessage: nextImessage } } as OpenClawConfig), + changes, + }); if (changes.length === 0) { return { config: cfg, changes: [] }; } - return { - config: { - ...cfg, - channels: { ...channels, imessage: nextImessage }, - } as OpenClawConfig, - changes, - }; + return { config: aliases.config, changes }; } diff --git a/extensions/msteams/doctor-contract-api.ts b/extensions/msteams/doctor-contract-api.ts index 6cdda08305b9..d339e5b994c5 100644 --- a/extensions/msteams/doctor-contract-api.ts +++ b/extensions/msteams/doctor-contract-api.ts @@ -10,9 +10,7 @@ import type { import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { archiveLegacyStateSource, - hasLegacyStreamingAliases, - normalizeLegacyChannelAliases, - resolveLegacyAliasStreamingMode, + defineChannelAliasMigration, type PluginDoctorStateMigration, } from "openclaw/plugin-sdk/runtime-doctor"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; @@ -55,46 +53,22 @@ import { type MSTeamsSsoStoredToken, } from "./src/sso-token-store.js"; -export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ - { - path: ["channels", "msteams"], - message: - 'channels.msteams.streamMode, channels.msteams.streaming (scalar), chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.msteams.streaming.{mode,chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".', - match: (value) => hasLegacyStreamingAliases(value), - }, -]; +const streamingAliasMigration = defineChannelAliasMigration({ + channelId: "msteams", + // Teams previews default to partial streaming, matching the runtime default + // in reply-dispatcher when no mode is configured. + streaming: { defaultMode: "partial" }, +}); + +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = + streamingAliasMigration.legacyConfigRules; export function normalizeCompatibilityConfig({ cfg, }: { cfg: OpenClawConfig; }): ChannelDoctorConfigMutation { - const channels = cfg.channels as Record | undefined; - const msteams = channels?.msteams; - if (!isRecord(msteams)) { - return { config: cfg, changes: [] }; - } - const changes: string[] = []; - const aliases = normalizeLegacyChannelAliases({ - entry: msteams, - pathPrefix: "channels.msteams", - changes, - resolveStreamingOptions: (entry) => ({ - // Teams previews default to partial streaming, matching the runtime - // default in reply-dispatcher when no mode is configured. - resolvedMode: resolveLegacyAliasStreamingMode(entry, "partial"), - }), - }); - if (!aliases.changed) { - return { config: cfg, changes: [] }; - } - return { - config: { - ...cfg, - channels: { ...channels, msteams: aliases.entry }, - } as OpenClawConfig, - changes, - }; + return streamingAliasMigration.normalizeChannelConfig({ cfg }); } type FeedbackLearningEntry = { diff --git a/extensions/slack/src/doctor-contract.ts b/extensions/slack/src/doctor-contract.ts index bf6398f05a1b..627fc5b41b52 100644 --- a/extensions/slack/src/doctor-contract.ts +++ b/extensions/slack/src/doctor-contract.ts @@ -4,17 +4,20 @@ import type { ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { - asObjectRecord, - hasLegacyAccountStreamingAliases, - hasLegacyStreamingAliases, - normalizeLegacyChannelAliases, -} from "openclaw/plugin-sdk/runtime-doctor"; +import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor"; import { resolveSlackNativeStreaming, resolveSlackStreamingMode } from "./streaming-compat.js"; -function hasLegacySlackStreamingAliases(value: unknown): boolean { - return hasLegacyStreamingAliases(value, { includeNativeTransport: true }); -} +const streamingAliasMigration = defineChannelAliasMigration({ + channelId: "slack", + streaming: { + // Slack maps its legacy draft stream modes (replace/status_final/append) + // through its own resolver instead of the generic mode parser. + defaultMode: "partial", + resolveMode: resolveSlackStreamingMode, + resolveNativeTransport: resolveSlackNativeStreaming, + }, + dm: { root: true, accounts: true }, +}); function hasLegacySlackChannelAllowAlias(value: unknown): boolean { const channels = asObjectRecord(asObjectRecord(value)?.channels); @@ -57,18 +60,7 @@ function normalizeSlackChannelAllowAliases(params: { } export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ - { - path: ["channels", "slack"], - message: - "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, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts..streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.", - match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacySlackStreamingAliases), - }, + ...streamingAliasMigration.legacyConfigRules, { path: ["channels", "slack"], message: @@ -94,28 +86,16 @@ export function normalizeCompatibilityConfig({ }: { cfg: OpenClawConfig; }): ChannelDoctorConfigMutation { - const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.slack); + const changes: string[] = []; + const aliases = streamingAliasMigration.normalizeChannelConfig({ cfg, changes }); + const rawEntry = asObjectRecord( + (aliases.config.channels as Record | undefined)?.slack, + ); if (!rawEntry) { return { config: cfg, changes: [] }; } - - const changes: string[] = []; - let updated; - let changed; - - const aliases = normalizeLegacyChannelAliases({ - entry: rawEntry, - pathPrefix: "channels.slack", - changes, - normalizeDm: true, - normalizeAccountDm: true, - resolveStreamingOptions: (entry) => ({ - resolvedMode: resolveSlackStreamingMode(entry), - resolvedNativeTransport: resolveSlackNativeStreaming(entry), - }), - }); - updated = aliases.entry; - changed = aliases.changed; + let updated = rawEntry; + let changed = aliases.config !== cfg; const channels = asObjectRecord(updated.channels); if (channels) { @@ -162,9 +142,9 @@ export function normalizeCompatibilityConfig({ } return { config: { - ...cfg, + ...aliases.config, channels: { - ...cfg.channels, + ...aliases.config.channels, slack: updated as unknown as NonNullable["slack"], } as OpenClawConfig["channels"], }, diff --git a/extensions/telegram/src/doctor-contract.ts b/extensions/telegram/src/doctor-contract.ts index 99dec52e5ca0..7d75be81efb9 100644 --- a/extensions/telegram/src/doctor-contract.ts +++ b/extensions/telegram/src/doctor-contract.ts @@ -5,17 +5,14 @@ import type { } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history"; -import { - asObjectRecord, - hasLegacyAccountStreamingAliases, - hasLegacyStreamingAliases, - normalizeLegacyChannelAliases, - resolveLegacyAliasStreamingMode, -} from "openclaw/plugin-sdk/runtime-doctor"; +import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor"; -function hasLegacyTelegramStreamingAliases(value: unknown): boolean { - return hasLegacyStreamingAliases(value, { includePreviewChunk: true }); -} +const streamingAliasMigration = defineChannelAliasMigration({ + channelId: "telegram", + // Runtime mode resolution dropped legacy streamMode reads; the doctor + // resolver keeps them so migration preserves configured intent. + streaming: { defaultMode: "partial", includePreviewChunk: true }, +}); function hasRetiredTelegramDmConfig(value: unknown): boolean { const entry = asObjectRecord(value); @@ -241,18 +238,7 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ 'channels.telegram.accounts..includeGroupHistoryContext was removed; Telegram group history is always on for groups and bounded by historyLimit. Run "openclaw doctor --fix".', match: hasRetiredTelegramAccountGroupHistoryContextConfig, }, - { - path: ["channels", "telegram"], - message: - "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, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts..streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.", - match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyTelegramStreamingAliases), - }, + ...streamingAliasMigration.legacyConfigRules, ]; export function normalizeCompatibilityConfig({ @@ -260,14 +246,17 @@ export function normalizeCompatibilityConfig({ }: { cfg: OpenClawConfig; }): ChannelDoctorConfigMutation { - const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.telegram); + const changes: string[] = []; + const aliases = streamingAliasMigration.normalizeChannelConfig({ cfg, changes }); + const rawEntry = asObjectRecord( + (aliases.config.channels as Record | undefined)?.telegram, + ); if (!rawEntry) { return { config: cfg, changes: [] }; } - const changes: string[] = []; let updated = rawEntry; - let changed = false; + let changed = aliases.config !== cfg; const rootGroupHistoryContextMode = updated.includeGroupHistoryContext; const rootGroupHistoryLimitBeforeMigration = typeof updated.historyLimit === "number" @@ -324,20 +313,6 @@ export function normalizeCompatibilityConfig({ } } - const aliases = normalizeLegacyChannelAliases({ - entry: updated, - pathPrefix: "channels.telegram", - changes, - resolveStreamingOptions: (entry) => ({ - includePreviewChunk: true, - // Runtime mode resolution dropped legacy streamMode reads; the doctor - // resolver keeps them so migration preserves configured intent. - resolvedMode: resolveLegacyAliasStreamingMode(entry, "partial"), - }), - }); - updated = aliases.entry; - changed = changed || aliases.changed; - const accounts = asObjectRecord(updated.accounts); if (accounts) { let accountsChanged = false; @@ -389,9 +364,9 @@ export function normalizeCompatibilityConfig({ } return { config: { - ...cfg, + ...aliases.config, channels: { - ...cfg.channels, + ...aliases.config.channels, telegram: updated as unknown as NonNullable["telegram"], } as OpenClawConfig["channels"], }, diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index f0e48d0068e7..9f3340062f89 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -199,12 +199,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10636, + 10639, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5354, + 5355, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/commands/doctor-legacy-config.test.ts b/src/commands/doctor-legacy-config.test.ts index fc08f00e037e..fbf0a7d6c4ae 100644 --- a/src/commands/doctor-legacy-config.test.ts +++ b/src/commands/doctor-legacy-config.test.ts @@ -1,6 +1,10 @@ // Doctor legacy-config tests cover compatibility normalizers for old channel, browser, and config shapes. import { describe, expect, it } from "vitest"; -import { normalizeLegacyStreamingAliases } from "../config/channel-compat-normalization.js"; +import { + normalizeLegacyChannelAliases, + normalizeLegacyStreamingAliases, + resolveLegacyAliasStreamingMode, +} from "../config/channel-compat-normalization.js"; import type { OpenClawConfig } from "../config/config.js"; import { normalizeLegacyBrowserConfig } from "./doctor/shared/legacy-config-core-normalizers.js"; @@ -21,7 +25,6 @@ function normalizeStreaming(params: { resolvedMode: string; aliasOnlyMode?: string; resolvedNativeTransport?: unknown; - offModeLegacyNotice?: (pathPrefix: string) => string; }) { const changes: string[] = []; const result = normalizeLegacyStreamingAliases({ @@ -80,15 +83,12 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => { entry: { streamMode: "off" }, pathPrefix: "channels.discord", resolvedMode: "off", - offModeLegacyNotice: (pathPrefix) => - `${pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${pathPrefix}.streaming.mode="partial" to opt in explicitly.`, }); expect(res.entry.streaming).toEqual({ mode: "off" }); expect(getLegacyProperty(res.entry, "streamMode")).toBeUndefined(); expect(res.changes).toEqual([ "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.', ]); }); @@ -173,6 +173,133 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => { }); }); +describe("normalizeLegacyChannelAliases account inheritance seeding", () => { + // Discord-shaped options: object-without-mode default "off", absent default + // "progress", account merge replaces the root streaming object wholesale. + function normalizeChannel( + entry: Record, + options?: { seedAccountStreamingFromRoot?: boolean }, + ) { + const changes: string[] = []; + const result = normalizeLegacyChannelAliases({ + entry, + pathPrefix: "channels.discord", + changes, + seedAccountStreamingFromRoot: options?.seedAccountStreamingFromRoot ?? true, + resolveStreamingOptions: (value) => ({ + resolvedMode: resolveLegacyAliasStreamingMode(value, "off"), + aliasOnlyMode: "progress", + includePreviewChunk: true, + }), + }); + return { entry: result.entry, changes }; + } + + function workStreaming(entry: Record): unknown { + return (entry.accounts as { work: Record }).work.streaming; + } + + it("pins the absent-object default when no root streaming object exists", () => { + // Truth table row 1: root absent → account previously resolved the + // channel's streaming-absent default, so migration pins it explicitly. + const res = normalizeChannel({ + accounts: { work: { blockStreaming: true } }, + }); + + expect(workStreaming(res.entry)).toEqual({ + mode: "progress", + block: { enabled: true }, + }); + expect(res.changes).toEqual([ + "Moved channels.discord.accounts.work.blockStreaming → channels.discord.accounts.work.streaming.block.enabled.", + "Set channels.discord.accounts.work.streaming.mode (progress) to keep the previous default while migrating flat streaming keys.", + ]); + }); + + it("seeds the root object's mode and subfields when the root has a mode", () => { + // Truth table row 2: account previously inherited the root object wholesale, + // so the created account object copies mode plus subfields; no pin needed. + const res = normalizeChannel({ + streaming: { mode: "block", block: { coalesce: { idleMs: 5 } } }, + accounts: { work: { chunkMode: "newline" } }, + }); + + expect(workStreaming(res.entry)).toEqual({ + mode: "block", + chunkMode: "newline", + block: { coalesce: { idleMs: 5 } }, + }); + expect(res.entry.streaming).toEqual({ mode: "block", block: { coalesce: { idleMs: 5 } } }); + expect(res.changes).toEqual([ + "Moved channels.discord.accounts.work.chunkMode → channels.discord.accounts.work.streaming.chunkMode.", + "Copied channels.discord.streaming into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.", + ]); + }); + + it("seeds subfields without pinning a mode when the root object has no mode", () => { + // Truth table row 3: the account previously resolved the root object's + // object-without-mode default; pinning absentObjectDefault would change it. + const res = normalizeChannel({ + streaming: { chunkMode: "word" }, + accounts: { work: { blockStreaming: true } }, + }); + + const streaming = workStreaming(res.entry) as Record; + expect(streaming).toEqual({ + chunkMode: "word", + block: { enabled: true }, + }); + expect(streaming.mode).toBeUndefined(); + expect(res.changes).toEqual([ + "Moved channels.discord.accounts.work.blockStreaming → channels.discord.accounts.work.streaming.block.enabled.", + "Copied channels.discord.streaming into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.", + ]); + }); + + it("keeps account values over seeded root values on conflict", () => { + const res = normalizeChannel({ + streaming: { mode: "block", chunkMode: "word" }, + accounts: { work: { streamMode: "partial", chunkMode: "newline" } }, + }); + + expect(workStreaming(res.entry)).toEqual({ + mode: "partial", + chunkMode: "newline", + }); + }); + + it("does not seed accounts whose streaming key already existed", () => { + const res = normalizeChannel({ + streaming: { mode: "block", chunkMode: "word" }, + accounts: { work: { streaming: false } }, + }); + + expect(workStreaming(res.entry)).toEqual({ + mode: "off", + }); + expect(res.changes).toEqual([ + "Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).", + ]); + }); + + it("does not seed for deep-merge channels so runtime inheritance keeps composing", () => { + // Slack/iMessage deep-merge root+account streaming at runtime; copying root + // values into the account config would freeze inheritance at fix time. + const res = normalizeChannel( + { + streaming: { mode: "block", block: { coalesce: { idleMs: 5 } } }, + accounts: { work: { chunkMode: "newline" } }, + }, + { seedAccountStreamingFromRoot: false }, + ); + + expect(workStreaming(res.entry)).toEqual({ chunkMode: "newline" }); + expect(res.changes).toEqual([ + "Moved channels.discord.accounts.work.chunkMode → channels.discord.accounts.work.streaming.chunkMode.", + ]); + }); +}); + describe("normalizeCompatibilityConfigValues browser compatibility aliases", () => { it("removes legacy browser relay bind host and stale extension relay cdpUrl", () => { const changes: string[] = []; diff --git a/src/config/channel-alias-migration.test.ts b/src/config/channel-alias-migration.test.ts new file mode 100644 index 000000000000..a5008d538ccd --- /dev/null +++ b/src/config/channel-alias-migration.test.ts @@ -0,0 +1,198 @@ +// Tests for the declarative channel doctor alias-migration DSL. +import { describe, expect, it } from "vitest"; +import { defineChannelAliasMigration } from "./channel-alias-migration.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +function cfgWith(channelId: string, entry: Record): OpenClawConfig { + return { channels: { [channelId]: entry } } as never; +} + +describe("defineChannelAliasMigration message generation", () => { + it("generates preview-chunk channel messages (discord shape)", () => { + const migration = defineChannelAliasMigration({ + channelId: "discord", + streaming: { defaultMode: "off", absentObjectDefault: "progress", includePreviewChunk: true }, + }); + + expect(migration.legacyConfigRules.map((rule) => rule.message)).toEqual([ + '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}. 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}. Run "openclaw doctor --fix".', + ]); + expect(migration.legacyConfigRules.map((rule) => rule.path)).toEqual([ + ["channels", "discord"], + ["channels", "discord", "accounts"], + ]); + }); + + it("generates native-transport channel messages (slack shape)", () => { + const migration = defineChannelAliasMigration({ + channelId: "slack", + streaming: { defaultMode: "partial", resolveNativeTransport: () => true }, + }); + + expect(migration.legacyConfigRules.map((rule) => rule.message)).toEqual([ + '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}. 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}. Run "openclaw doctor --fix".', + ]); + }); + + it("generates delivery-only channel messages (imessage shape)", () => { + const migration = defineChannelAliasMigration({ + channelId: "imessage", + streaming: { defaultMode: "partial", deliveryOnly: true }, + }); + + expect(migration.legacyConfigRules.map((rule) => rule.message)).toEqual([ + 'channels.imessage.chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.streaming.{chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".', + 'channels.imessage.accounts..chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.accounts..streaming.{chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".', + ]); + }); + + it("generates plain mode channel messages (msteams shape)", () => { + const migration = defineChannelAliasMigration({ + channelId: "msteams", + streaming: { defaultMode: "partial" }, + }); + + expect(migration.legacyConfigRules[0]?.message).toBe( + 'channels.msteams.streamMode, channels.msteams.streaming (scalar), chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.msteams.streaming.{mode,chunkMode,block.enabled,block.coalesce}. Run "openclaw doctor --fix".', + ); + }); +}); + +describe("defineChannelAliasMigration rule matching", () => { + it("matches root and account entries per spec options", () => { + const migration = defineChannelAliasMigration({ + channelId: "discord", + streaming: { defaultMode: "off", includePreviewChunk: true }, + }); + const [rootRule, accountsRule] = migration.legacyConfigRules; + + expect(rootRule?.match?.({ streamMode: "block" }, {})).toBe(true); + expect(rootRule?.match?.({ streaming: false }, {})).toBe(true); + expect(rootRule?.match?.({ draftChunk: { minChars: 5 } }, {})).toBe(true); + expect(rootRule?.match?.({ nativeStreaming: false }, {})).toBe(false); + expect(rootRule?.match?.({ streaming: { mode: "off" } }, {})).toBe(false); + expect(accountsRule?.match?.({ work: { blockStreaming: true } }, {})).toBe(true); + expect(accountsRule?.match?.({ work: { streaming: { mode: "off" } } }, {})).toBe(false); + }); + + it("matches nativeStreaming only for native-transport channels", () => { + const migration = defineChannelAliasMigration({ + channelId: "slack", + streaming: { defaultMode: "partial", resolveNativeTransport: () => true }, + }); + + expect(migration.hasLegacyAliases({ nativeStreaming: false })).toBe(true); + expect(migration.hasLegacyAliases({ draftChunk: {} })).toBe(false); + }); + + it("excludes mode sources for delivery-only channels", () => { + const migration = defineChannelAliasMigration({ + channelId: "imessage", + streaming: { defaultMode: "partial", deliveryOnly: true }, + }); + + expect(migration.hasLegacyAliases({ chunkMode: "newline" })).toBe(true); + expect(migration.hasLegacyAliases({ streamMode: "block" })).toBe(false); + expect(migration.hasLegacyAliases({ streaming: "partial" })).toBe(false); + expect(migration.hasLegacyAliases({ streaming: false })).toBe(false); + }); +}); + +describe("defineChannelAliasMigration normalizeChannelConfig", () => { + it("migrates root and account aliases with dm normalization", () => { + const migration = defineChannelAliasMigration({ + channelId: "discord", + streaming: { defaultMode: "off", absentObjectDefault: "progress", includePreviewChunk: true }, + accountStreamingReplacesRoot: true, + dm: { root: true, accounts: true }, + }); + + const changes: string[] = []; + const result = migration.normalizeChannelConfig({ + cfg: cfgWith("discord", { + streamMode: "block", + dm: { policy: "open" }, + accounts: { work: { draftChunk: { minChars: 9 } } }, + }), + changes, + }); + + expect(result.changes).toBe(changes); + expect((result.config.channels as Record).discord).toEqual({ + dmPolicy: "open", + streaming: { mode: "block" }, + accounts: { + work: { + streaming: { mode: "block", preview: { chunk: { minChars: 9 } } }, + }, + }, + }); + expect(changes).toEqual([ + "Moved channels.discord.dm.policy → channels.discord.dmPolicy.", + "Removed empty channels.discord.dm after migration.", + "Moved channels.discord.streamMode → channels.discord.streaming.mode (block).", + "Moved channels.discord.accounts.work.draftChunk → channels.discord.accounts.work.streaming.preview.chunk.", + "Copied channels.discord.streaming into channels.discord.accounts.work.streaming to keep inherited settings while migrating flat streaming keys.", + ]); + }); + + it("routes the escape hatch into per-account migration", () => { + const migration = defineChannelAliasMigration({ + channelId: "discord", + streaming: { defaultMode: "off" }, + normalizeAccountExtra: ({ account, pathPrefix, changes }) => { + if (account.legacyFlag === undefined) { + return { entry: account, changed: false }; + } + const { legacyFlag: _ignored, ...rest } = account; + changes.push(`Removed ${pathPrefix}.legacyFlag.`); + return { entry: rest, changed: true }; + }, + }); + + const result = migration.normalizeChannelConfig({ + cfg: cfgWith("discord", { accounts: { work: { legacyFlag: true } } }), + }); + + expect(result.changes).toEqual(["Removed channels.discord.accounts.work.legacyFlag."]); + expect((result.config.channels as Record).discord).toEqual({ + accounts: { work: {} }, + }); + }); + + it("returns the unchanged sentinel when nothing matches", () => { + const migration = defineChannelAliasMigration({ + channelId: "msteams", + streaming: { defaultMode: "partial" }, + }); + + const modern = cfgWith("msteams", { streaming: { mode: "partial" } }); + const untouched = migration.normalizeChannelConfig({ cfg: modern }); + expect(untouched.config).toBe(modern); + expect(untouched.changes).toEqual([]); + + const missing = { channels: {} } as never; + expect(migration.normalizeChannelConfig({ cfg: missing }).config).toBe(missing); + }); + + it("skips scalar streaming values entirely for delivery-only channels", () => { + const migration = defineChannelAliasMigration({ + channelId: "imessage", + streaming: { defaultMode: "partial", deliveryOnly: true }, + }); + + // Scalar `streaming` is a validation error for delivery-only channels, not + // a migratable legacy shape, so the migration must not touch it. + const scalarOnly = cfgWith("imessage", { streaming: "partial" }); + expect(migration.normalizeChannelConfig({ cfg: scalarOnly }).config).toBe(scalarOnly); + + const result = migration.normalizeChannelConfig({ + cfg: cfgWith("imessage", { accounts: { work: { chunkMode: "newline" } } }), + }); + expect(result.changes).toEqual([ + "Moved channels.imessage.accounts.work.chunkMode → channels.imessage.accounts.work.streaming.chunkMode.", + ]); + }); +}); diff --git a/src/config/channel-alias-migration.ts b/src/config/channel-alias-migration.ts new file mode 100644 index 000000000000..292a5195ee43 --- /dev/null +++ b/src/config/channel-alias-migration.ts @@ -0,0 +1,196 @@ +// Declarative front for channel doctor streaming/dm alias migrations. +import { + asObjectRecord, + hasLegacyAccountStreamingAliases, + hasLegacyStreamingAliases, + normalizeLegacyChannelAliases, + resolveLegacyAliasStreamingMode, + type CompatMutationResult, + type LegacyStreamingAliasOptions, + type NormalizeLegacyChannelAccountParams, +} from "./channel-compat-normalization.js"; +import type { LegacyConfigRule } from "./legacy.shared.js"; +import type { OpenClawConfig } from "./types.openclaw.js"; + +export type StreamingAliasMode = "off" | "partial" | "block" | "progress"; + +/** Streaming half of a channel alias-migration spec. */ +type StreamingAliasSpec = { + /** Default passed to resolveLegacyAliasStreamingMode for mode-source migration. */ + defaultMode: StreamingAliasMode; + /** Channel-specific mode resolver override (Slack maps legacy draft stream modes). */ + resolveMode?: (entry: Record) => StreamingAliasMode; + /** + * The channel's runtime default when `streaming` is entirely absent, if it + * differs from the object-without-mode default (Discord: progress vs off). + * Pinned when delivery-only aliases materialize the object and no root + * streaming object exists to seed inherited settings from. + */ + absentObjectDefault?: StreamingAliasMode; + /** Channel accepts flat `draftChunk` (Discord, Telegram). */ + includePreviewChunk?: boolean; + /** Channel accepts flat `nativeStreaming`; returns the resolved nativeTransport (Slack). */ + resolveNativeTransport?: (entry: Record) => unknown; + /** + * Channel has no streaming mode: only delivery flat aliases migrate, and + * scalar `streaming` values are plain validation errors (iMessage). The + * detection matcher excludes streamMode/scalar streaming, and the migration + * only runs when a delivery flat alias exists somewhere in the entry. + */ + deliveryOnly?: boolean; +}; + +export type ChannelAliasMigrationSpec = { + /** Channel id under `channels.`; also the doctor message path prefix. */ + channelId: string; + streaming: StreamingAliasSpec; + /** + * Set when the channel's runtime account merge replaces the root `streaming` + * object wholesale (Discord). Migration then seeds account objects it + * materializes with the inherited root settings. Leave unset for channels + * that deep-merge streaming at runtime (Slack, iMessage) — seeding there + * would freeze inheritance into the account config. + */ + accountStreamingReplacesRoot?: boolean; + dm?: { + root?: boolean; + accounts?: boolean; + rootPromoteAllowFrom?: boolean; + }; + /** Escape hatch for channel-specific per-account migrations (Discord voice.tts). */ + normalizeAccountExtra?: (params: NormalizeLegacyChannelAccountParams) => CompatMutationResult; +}; + +function buildAliasRuleMessage(params: { + streaming: StreamingAliasSpec; + prefix: string; + root: boolean; +}): string { + const { streaming, prefix } = params; + const native = streaming.resolveNativeTransport !== undefined; + const flat = [ + ...(streaming.deliveryOnly ? [] : ["streamMode", "streaming (scalar)"]), + "chunkMode", + "blockStreaming", + ...(streaming.includePreviewChunk ? ["draftChunk"] : []), + "blockStreamingCoalesce", + ...(native ? ["nativeStreaming"] : []), + ]; + const nested = [ + ...(streaming.deliveryOnly ? [] : ["mode"]), + "chunkMode", + ...(streaming.includePreviewChunk ? ["preview.chunk"] : []), + "block.enabled", + "block.coalesce", + ...(native ? ["nativeTransport"] : []), + ]; + // Root messages spell out the ambiguous scalar `streaming` key with its full + // path; account messages prefix only the first key. Matches the established + // hand-written doctor message format. + const prefixedCount = params.root && !streaming.deliveryOnly ? 2 : 1; + const keys = flat.map((key, index) => (index < prefixedCount ? `${prefix}.${key}` : key)); + const keyList = `${keys.slice(0, -1).join(", ")}, and ${keys.at(-1)}`; + return `${keyList} are legacy; use ${prefix}.streaming.{${nested.join(",")}}. Run "openclaw doctor --fix".`; +} + +/** + * Builds the standard channel doctor alias-migration surface from a small spec: + * detection rules (root + accounts), the per-entry matcher, and the config + * normalizer. Channels with additional migrations compose around these pieces. + */ +export function defineChannelAliasMigration(spec: ChannelAliasMigrationSpec): { + legacyConfigRules: LegacyConfigRule[]; + hasLegacyAliases: (value: unknown) => boolean; + normalizeChannelConfig: (params: { cfg: OpenClawConfig; changes?: string[] }) => { + config: OpenClawConfig; + changes: string[]; + }; +} { + const { streaming } = spec; + const pathPrefix = `channels.${spec.channelId}`; + + const hasLegacyAliases = (value: unknown): boolean => { + if (streaming.deliveryOnly === true) { + const entry = asObjectRecord(value); + return ( + entry !== null && + (entry.chunkMode !== undefined || + entry.blockStreaming !== undefined || + entry.blockStreamingCoalesce !== undefined) + ); + } + return hasLegacyStreamingAliases(value, { + includePreviewChunk: streaming.includePreviewChunk, + includeNativeTransport: streaming.resolveNativeTransport !== undefined, + }); + }; + + const resolveStreamingOptions = ( + entry: Record, + ): LegacyStreamingAliasOptions => ({ + resolvedMode: + streaming.resolveMode?.(entry) ?? + resolveLegacyAliasStreamingMode(entry, streaming.defaultMode), + aliasOnlyMode: streaming.absentObjectDefault, + includePreviewChunk: streaming.includePreviewChunk, + resolvedNativeTransport: streaming.resolveNativeTransport?.(entry), + }); + + const normalizeChannelConfig = (params: { cfg: OpenClawConfig; changes?: string[] }) => { + const changes = params.changes ?? []; + const channels = params.cfg.channels as Record | undefined; + const entry = asObjectRecord(channels?.[spec.channelId]); + if (!entry) { + return { config: params.cfg, changes }; + } + if ( + streaming.deliveryOnly === true && + !hasLegacyAliases(entry) && + !hasLegacyAccountStreamingAliases(entry.accounts, hasLegacyAliases) + ) { + return { config: params.cfg, changes }; + } + const result = normalizeLegacyChannelAliases({ + entry, + pathPrefix, + changes, + normalizeDm: spec.dm?.root, + rootDmPromoteAllowFrom: spec.dm?.rootPromoteAllowFrom, + normalizeAccountDm: spec.dm?.accounts, + seedAccountStreamingFromRoot: spec.accountStreamingReplacesRoot, + resolveStreamingOptions, + normalizeAccountExtra: spec.normalizeAccountExtra, + }); + if (!result.changed) { + return { config: params.cfg, changes }; + } + return { + config: { + ...params.cfg, + channels: { ...channels, [spec.channelId]: result.entry }, + } as OpenClawConfig, + changes, + }; + }; + + return { + legacyConfigRules: [ + { + path: ["channels", spec.channelId], + message: buildAliasRuleMessage({ streaming, prefix: pathPrefix, root: true }), + match: hasLegacyAliases, + }, + { + path: ["channels", spec.channelId, "accounts"], + message: buildAliasRuleMessage({ + streaming, + prefix: `${pathPrefix}.accounts.`, + root: false, + }), + match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyAliases), + }, + ], + hasLegacyAliases, + normalizeChannelConfig, + }; +} diff --git a/src/config/channel-compat-normalization.ts b/src/config/channel-compat-normalization.ts index b07550319aa0..6fb4b0c9102f 100644 --- a/src/config/channel-compat-normalization.ts +++ b/src/config/channel-compat-normalization.ts @@ -19,7 +19,6 @@ export type LegacyStreamingAliasOptions = { aliasOnlyMode?: string; includePreviewChunk?: boolean; resolvedNativeTransport?: unknown; - offModeLegacyNotice?: (pathPrefix: string) => string; }; /** Account-level channel config passed to channel-specific doctor migrations. */ @@ -227,7 +226,10 @@ export function normalizeLegacyStreamingAliases( // object without `mode` (Discord defaults to progress only when the whole // object is absent). Pin the previous effective mode so migration never // changes behavior. Guarded on `changed` so entries with no movable alias - // stay a no-op instead of minting a mode-only mutation. + // stay a no-op instead of minting a mode-only mutation. Account callers + // suppress aliasOnlyMode when a root streaming object exists: the seed in + // normalizeLegacyChannelAliases carries the inherited settings instead, and + // pinning the absent-object default there would change effective behavior. if ( changed && beforeStreaming === undefined && @@ -248,16 +250,41 @@ export function normalizeLegacyStreamingAliases( streaming.block = block; } updated.streaming = streaming; - if ( - hadLegacyStreamMode && - params.resolvedMode === "off" && - params.offModeLegacyNotice !== undefined - ) { - params.changes.push(params.offModeLegacyNotice(params.pathPrefix)); - } return { entry: updated, changed }; } +/** Deep-fills record fields missing from target with copies of source values. */ +function fillMissingRecordFields( + target: Record, + source: Record, +): { value: Record; filled: boolean } { + let filled = false; + const value = { ...target }; + for (const [key, sourceValue] of Object.entries(source)) { + if (sourceValue === undefined) { + continue; + } + const existing = value[key]; + if (existing === undefined) { + // Copy so later account-level edits never alias the root config object. + value[key] = structuredClone(sourceValue); + filled = true; + continue; + } + const existingRecord = asObjectRecord(existing); + const sourceRecord = asObjectRecord(sourceValue); + if (!existingRecord || !sourceRecord) { + continue; + } + const merged = fillMissingRecordFields(existingRecord, sourceRecord); + if (merged.filled) { + value[key] = merged.value; + filled = true; + } + } + return { value, filled }; +} + /** * Runs generic channel doctor alias migration for the root entry and accounts. * @@ -271,6 +298,14 @@ export function normalizeLegacyChannelAliases(params: { normalizeDm?: boolean; rootDmPromoteAllowFrom?: boolean; normalizeAccountDm?: boolean; + /** + * Set for channels whose runtime account merge replaces the root `streaming` + * object wholesale (`streaming` not deep-merged). Doctor then seeds account + * objects it materializes with the inherited root settings. Channels that + * deep-merge streaming (slack, imessage) must NOT seed: their runtime keeps + * composing root+account, and seeded copies would freeze inheritance. + */ + seedAccountStreamingFromRoot?: boolean; resolveStreamingOptions: (entry: Record) => LegacyStreamingAliasOptions; normalizeAccountExtra?: (params: NormalizeLegacyChannelAccountParams) => CompatMutationResult; }): CompatMutationResult { @@ -302,6 +337,12 @@ export function normalizeLegacyChannelAliases(params: { return { entry: updated, changed }; } + // For replace-semantics channels (seedAccountStreamingFromRoot), an account + // object materialized by migration must be seeded with the settings the + // account previously inherited from the root object, or `doctor --fix` + // silently changes effective delivery/preview behavior for that account. + const rootStreaming = asObjectRecord(updated.streaming); + let accountsChanged = false; const accounts = { ...rawAccounts }; for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { @@ -323,15 +364,39 @@ export function normalizeLegacyChannelAliases(params: { accountChanged = accountDm.changed; } + const accountStreamingOptions = { ...params.resolveStreamingOptions(accountEntry) }; + if (rootStreaming) { + // Truth table rows 2-3: with a root object to seed from, the account + // previously resolved that object's semantics (its mode, or the + // object-without-mode default), so pinning absentObjectDefault is wrong. + delete accountStreamingOptions.aliasOnlyMode; + } + const beforeAccountStreaming = accountEntry.streaming; const accountStreaming = normalizeLegacyStreamingAliases({ entry: accountEntry, pathPrefix: accountPathPrefix, changes: params.changes, - ...params.resolveStreamingOptions(accountEntry), + ...accountStreamingOptions, }); accountEntry = accountStreaming.entry; accountChanged = accountChanged || accountStreaming.changed; + if ( + params.seedAccountStreamingFromRoot === true && + accountStreaming.changed && + beforeAccountStreaming === undefined && + rootStreaming + ) { + const created = asObjectRecord(accountEntry.streaming); + const seeded = created ? fillMissingRecordFields(created, rootStreaming) : null; + if (seeded?.filled) { + accountEntry = { ...accountEntry, streaming: seeded.value }; + params.changes.push( + `Copied ${params.pathPrefix}.streaming into ${accountPathPrefix}.streaming to keep inherited settings while migrating flat streaming keys.`, + ); + } + } + const accountExtra = params.normalizeAccountExtra?.({ account: accountEntry, accountId, diff --git a/src/plugin-sdk/runtime-doctor.ts b/src/plugin-sdk/runtime-doctor.ts index 8a904eace553..2cff80970a47 100644 --- a/src/plugin-sdk/runtime-doctor.ts +++ b/src/plugin-sdk/runtime-doctor.ts @@ -2,6 +2,11 @@ * Runtime SDK subpath for plugin doctor migrations, compat checks, and uninstall helpers. */ export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; +export { defineChannelAliasMigration } from "../config/channel-alias-migration.js"; +export type { + ChannelAliasMigrationSpec, + StreamingAliasMode, +} from "../config/channel-alias-migration.js"; export { asObjectRecord, hasLegacyAccountStreamingAliases,