mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(config): declarative doctor alias-migration DSL with inheritance-aware account seeding (#105636)
* fix(config): seed inherited root streaming into doctor-materialized account streaming objects * feat(plugin-sdk): add defineChannelAliasMigration doctor alias-migration DSL * refactor(plugins): migrate channel doctor contracts onto the alias-migration DSL * refactor(config): drop the prod-unused offMode legacy notice option * fix(config): make account streaming seeding opt-in for wholesale-replace channels * test(imessage): keep account streaming account-local for deep-merge runtime
This commit is contained in:
committed by
GitHub
parent
5fd7b5c40a
commit
6556d1d173
@@ -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
|
||||
|
||||
@@ -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<OpenClawConfig["bindings"]>[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.<id>.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.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}. 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<string, unknown> | undefined)?.discord);
|
||||
const changes: string[] = [];
|
||||
const bindingsToAdd: AgentBindingConfig[] = [];
|
||||
|
||||
const aliases = streamingAliasMigration.normalizeChannelConfig({ cfg, changes });
|
||||
const rawEntry = asObjectRecord(
|
||||
(aliases.config.channels as Record<string, unknown> | 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<string, unknown>) =>
|
||||
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:
|
||||
|
||||
@@ -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.",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -54,7 +54,12 @@ describe("imessage normalizeCompatibilityConfig streaming aliases", () => {
|
||||
(imessage.accounts as Record<string, Record<string, unknown>>).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.",
|
||||
|
||||
@@ -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.<id>.chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.accounts.<id>.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 };
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | 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 = {
|
||||
|
||||
@@ -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.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.streaming.{mode,chunkMode,block.enabled,block.coalesce,nativeTransport}.",
|
||||
match: (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<string, unknown> | undefined)?.slack);
|
||||
const changes: string[] = [];
|
||||
const aliases = streamingAliasMigration.normalizeChannelConfig({ cfg, changes });
|
||||
const rawEntry = asObjectRecord(
|
||||
(aliases.config.channels as Record<string, unknown> | 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<OpenClawConfig["channels"]>["slack"],
|
||||
} as OpenClawConfig["channels"],
|
||||
},
|
||||
|
||||
@@ -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.<id>.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.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.telegram.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}.",
|
||||
match: (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<string, unknown> | undefined)?.telegram);
|
||||
const changes: string[] = [];
|
||||
const aliases = streamingAliasMigration.normalizeChannelConfig({ cfg, changes });
|
||||
const rawEntry = asObjectRecord(
|
||||
(aliases.config.channels as Record<string, unknown> | 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<OpenClawConfig["channels"]>["telegram"],
|
||||
} as OpenClawConfig["channels"],
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown>): unknown {
|
||||
return (entry.accounts as { work: Record<string, unknown> }).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<string, unknown>;
|
||||
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[] = [];
|
||||
|
||||
@@ -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<string, unknown>): 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.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy; use channels.discord.accounts.<id>.streaming.{mode,chunkMode,preview.chunk,block.enabled,block.coalesce}. 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.<id>.streamMode, streaming (scalar), chunkMode, blockStreaming, blockStreamingCoalesce, and nativeStreaming are legacy; use channels.slack.accounts.<id>.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.<id>.chunkMode, blockStreaming, and blockStreamingCoalesce are legacy; use channels.imessage.accounts.<id>.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<string, unknown>).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<string, unknown>).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.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => 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<string, unknown>) => 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.<id>`; 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<string, unknown>,
|
||||
): 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<string, unknown> | 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.<id>`,
|
||||
root: false,
|
||||
}),
|
||||
match: (value) => hasLegacyAccountStreamingAliases(value, hasLegacyAliases),
|
||||
},
|
||||
],
|
||||
hasLegacyAliases,
|
||||
normalizeChannelConfig,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
source: Record<string, unknown>,
|
||||
): { value: Record<string, unknown>; 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<string, unknown>) => 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user