fix(config): surface legacy channel streaming aliases (#60358)

This commit is contained in:
Vincent Koc
2026-04-04 00:00:38 +09:00
committed by GitHub
parent 3257136160
commit fbd361d338
4 changed files with 206 additions and 0 deletions

View File

@@ -652,6 +652,62 @@ describe("doctor config flow", () => {
});
});
it("warns clearly about legacy channel streaming aliases and points to doctor --fix", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await runDoctorConfigWithInput({
config: {
channels: {
telegram: {
streamMode: "block",
},
discord: {
streaming: false,
},
slack: {
streaming: true,
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("channels.telegram:") &&
String(message).includes("channels.telegram.streamMode is legacy"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("channels.discord:") &&
String(message).includes("boolean channels.discord.streaming are legacy"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("channels.slack:") &&
String(message).includes("boolean channels.slack.streaming are legacy"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
});
it("sanitizes config-derived doctor warnings and changes before logging", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {

View File

@@ -591,6 +591,55 @@ describe("config strict validation", () => {
});
});
it("accepts legacy channel streaming aliases via auto-migration and reports legacyIssues", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {
channels: {
telegram: {
streamMode: "block",
},
discord: {
streaming: false,
accounts: {
work: {
streamMode: "block",
},
},
},
slack: {
streaming: true,
},
},
});
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.telegram")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord")).toBe(true);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe(
true,
);
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack")).toBe(true);
expect(snap.sourceConfig.channels?.telegram).toMatchObject({
streaming: "block",
});
expect(
(snap.sourceConfig.channels?.telegram as Record<string, unknown> | undefined)?.streamMode,
).toBeUndefined();
expect(snap.sourceConfig.channels?.discord).toMatchObject({
streaming: "off",
});
expect(snap.sourceConfig.channels?.discord?.accounts?.work).toMatchObject({
streaming: "block",
});
expect(snap.sourceConfig.channels?.slack).toMatchObject({
streaming: "partial",
nativeStreaming: true,
});
});
});
it("accepts legacy plugins.entries.*.config.tts provider keys via auto-migration", async () => {
await withTempHome(async (home) => {
await writeOpenClawConfig(home, {

View File

@@ -482,6 +482,32 @@ describe("legacy migrate sandbox scope aliases", () => {
});
});
describe("legacy migrate channel streaming aliases", () => {
it("migrates telegram and discord streaming aliases", () => {
const res = migrateLegacyConfig({
channels: {
telegram: {
streamMode: "block",
},
discord: {
streaming: false,
},
},
});
expect(res.changes).toContain(
"Moved channels.telegram.streamMode → channels.telegram.streaming (block).",
);
expect(res.changes).toContain("Normalized channels.discord.streaming boolean → enum (off).");
expect(res.config?.channels?.telegram).toMatchObject({
streaming: "block",
});
expect(res.config?.channels?.discord).toMatchObject({
streaming: "off",
});
});
});
describe("legacy migrate x_search auth", () => {
it("moves only legacy x_search auth into plugin-owned xai config", () => {
const res = migrateLegacyConfig({

View File

@@ -61,6 +61,41 @@ function migrateThreadBindingsTtlHoursForPath(params: {
return true;
}
function hasLegacyTelegramStreamingKeys(value: unknown): boolean {
const entry = getRecord(value);
if (!entry) {
return false;
}
return entry.streamMode !== undefined;
}
function hasLegacyDiscordStreamingKeys(value: unknown): boolean {
const entry = getRecord(value);
if (!entry) {
return false;
}
return entry.streamMode !== undefined || typeof entry.streaming === "boolean";
}
function hasLegacySlackStreamingKeys(value: unknown): boolean {
const entry = getRecord(value);
if (!entry) {
return false;
}
return entry.streamMode !== undefined || typeof entry.streaming === "boolean";
}
function hasLegacyStreamingKeysInAccounts(
value: unknown,
matchEntry: (entry: Record<string, unknown>) => boolean,
): boolean {
const accounts = getRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((entry) => matchEntry(getRecord(entry) ?? {}));
}
const THREAD_BINDING_RULES: LegacyConfigRule[] = [
{
path: ["session", "threadBindings"],
@@ -82,6 +117,45 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [
},
];
const CHANNEL_STREAMING_RULES: LegacyConfigRule[] = [
{
path: ["channels", "telegram"],
message:
"channels.telegram.streamMode is legacy; use channels.telegram.streaming instead (auto-migrated on load).",
match: (value) => hasLegacyTelegramStreamingKeys(value),
},
{
path: ["channels", "telegram", "accounts"],
message:
"channels.telegram.accounts.<id>.streamMode is legacy; use channels.telegram.accounts.<id>.streaming instead (auto-migrated on load).",
match: (value) => hasLegacyStreamingKeysInAccounts(value, hasLegacyTelegramStreamingKeys),
},
{
path: ["channels", "discord"],
message:
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming with enum values instead (auto-migrated on load).",
match: (value) => hasLegacyDiscordStreamingKeys(value),
},
{
path: ["channels", "discord", "accounts"],
message:
"channels.discord.accounts.<id>.streamMode and boolean channels.discord.accounts.<id>.streaming are legacy; use channels.discord.accounts.<id>.streaming with enum values instead (auto-migrated on load).",
match: (value) => hasLegacyStreamingKeysInAccounts(value, hasLegacyDiscordStreamingKeys),
},
{
path: ["channels", "slack"],
message:
"channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming with enum values instead (auto-migrated on load).",
match: (value) => hasLegacySlackStreamingKeys(value),
},
{
path: ["channels", "slack", "accounts"],
message:
"channels.slack.accounts.<id>.streamMode and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.streaming with enum values instead (auto-migrated on load).",
match: (value) => hasLegacyStreamingKeysInAccounts(value, hasLegacySlackStreamingKeys),
},
];
export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
defineLegacyConfigMigration({
id: "thread-bindings.ttlHours->idleHours",
@@ -136,6 +210,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
id: "channels.streaming-keys->channels.streaming",
describe:
"Normalize legacy streaming keys to channels.<provider>.streaming (Telegram/Discord/Slack)",
legacyRules: CHANNEL_STREAMING_RULES,
apply: (raw, changes) => {
const channels = getRecord(raw.channels);
if (!channels) {