diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index ed966042aff..4b047be156d 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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 { diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 18d05ac9990..647d93f09bf 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -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 | 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, { diff --git a/src/config/legacy-migrate.test.ts b/src/config/legacy-migrate.test.ts index d389a32df70..54ee2d171a8 100644 --- a/src/config/legacy-migrate.test.ts +++ b/src/config/legacy-migrate.test.ts @@ -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({ diff --git a/src/config/legacy.migrations.channels.ts b/src/config/legacy.migrations.channels.ts index 0849577e5b3..019769ef764 100644 --- a/src/config/legacy.migrations.channels.ts +++ b/src/config/legacy.migrations.channels.ts @@ -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) => 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..streamMode is legacy; use channels.telegram.accounts..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..streamMode and boolean channels.discord.accounts..streaming are legacy; use channels.discord.accounts..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..streamMode and boolean channels.slack.accounts..streaming are legacy; use channels.slack.accounts..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..streaming (Telegram/Discord/Slack)", + legacyRules: CHANNEL_STREAMING_RULES, apply: (raw, changes) => { const channels = getRecord(raw.channels); if (!channels) {