diff --git a/extensions/discord/src/doctor-contract.ts b/extensions/discord/src/doctor-contract.ts index 5ae6fc6233d..5354f199e52 100644 --- a/extensions/discord/src/doctor-contract.ts +++ b/extensions/discord/src/doctor-contract.ts @@ -3,7 +3,12 @@ import type { ChannelDoctorLegacyConfigRule, } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { asObjectRecord, normalizeLegacyDmAliases } from "openclaw/plugin-sdk/runtime-doctor"; +import { + asObjectRecord, + normalizeLegacyDmAliases, + normalizeLegacyStreamingAliases, +} from "openclaw/plugin-sdk/runtime-doctor"; +import { resolveDiscordPreviewStreamMode } from "./preview-streaming.js"; const LEGACY_TTS_PROVIDER_KEYS = ["openai", "elevenlabs", "microsoft", "edge"] as const; @@ -141,6 +146,16 @@ export function normalizeCompatibilityConfig({ updated = dm.entry; changed = changed || dm.changed; + const streaming = normalizeLegacyStreamingAliases({ + entry: updated, + pathPrefix: "channels.discord", + changes, + resolvedMode: resolveDiscordPreviewStreamMode(updated), + includePreviewChunk: true, + }); + updated = streaming.entry; + changed = changed || streaming.changed; + const rawAccounts = asObjectRecord(updated.accounts); if (rawAccounts) { let accountsChanged = false; @@ -159,6 +174,15 @@ export function normalizeCompatibilityConfig({ }); accountEntry = accountDm.entry; accountChanged = accountDm.changed; + const accountStreaming = normalizeLegacyStreamingAliases({ + entry: accountEntry, + pathPrefix: `channels.discord.accounts.${accountId}`, + changes, + resolvedMode: resolveDiscordPreviewStreamMode(accountEntry), + includePreviewChunk: true, + }); + accountEntry = accountStreaming.entry; + accountChanged = accountChanged || accountStreaming.changed; const accountVoice = asObjectRecord(accountEntry.voice); if ( accountVoice && diff --git a/extensions/discord/src/doctor.test.ts b/extensions/discord/src/doctor.test.ts index 082ca0aea15..2facf450235 100644 --- a/extensions/discord/src/doctor.test.ts +++ b/extensions/discord/src/doctor.test.ts @@ -8,7 +8,7 @@ import { } from "./doctor.js"; describe("discord doctor", () => { - it("leaves legacy discord streaming aliases untouched during doctor normalization", () => { + it("normalizes legacy discord streaming aliases for runtime config", () => { const normalize = discordDoctor.normalizeCompatibilityConfig; expect(normalize).toBeDefined(); if (!normalize) { @@ -39,22 +39,39 @@ describe("discord doctor", () => { }); expect(result.config.channels?.discord).toEqual({ - streamMode: "block", - chunkMode: "newline", - blockStreaming: true, - draftChunk: { - minChars: 120, + streaming: { + mode: "block", + chunkMode: "newline", + block: { + enabled: true, + }, + preview: { + chunk: { + minChars: 120, + }, + }, }, accounts: { work: { - streaming: false, - blockStreamingCoalesce: { - idleMs: 250, + streaming: { + mode: "off", + block: { + coalesce: { + idleMs: 250, + }, + }, }, }, }, }); - expect(result.changes).toEqual([]); + expect(result.changes).toEqual([ + "Moved channels.discord.streamMode → channels.discord.streaming.mode (block).", + "Moved channels.discord.chunkMode → channels.discord.streaming.chunkMode.", + "Moved channels.discord.blockStreaming → channels.discord.streaming.block.enabled.", + "Moved channels.discord.draftChunk → channels.discord.streaming.preview.chunk.", + "Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).", + "Moved channels.discord.accounts.work.blockStreamingCoalesce → channels.discord.accounts.work.streaming.block.coalesce.", + ]); }); it("moves account voice.tts.edge into providers.microsoft", () => { diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 747c4be62ea..0e0cfc2bb3e 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -77,6 +77,122 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { ); } + function resolveDiscordStreamMode(entry: Record): string { + if ( + entry.streamMode === "block" || + entry.streamMode === "partial" || + entry.streamMode === "off" + ) { + return entry.streamMode; + } + if (entry.streaming === true) { + return "partial"; + } + if (entry.streaming === false) { + return "off"; + } + return "off"; + } + + function normalizeDiscordStreamingEntry( + entry: Record, + pathPrefix: string, + changes: string[], + ): boolean { + const hasLegacyStreaming = + "streamMode" in entry || + typeof entry.streaming === "boolean" || + typeof entry.streaming === "string" || + "chunkMode" in entry || + "blockStreaming" in entry || + "draftChunk" in entry || + "blockStreamingCoalesce" in entry; + if (!hasLegacyStreaming) { + return false; + } + + let changed = false; + const streaming = asRecord(entry.streaming) ?? {}; + if (!("mode" in streaming) && ("streamMode" in entry || typeof entry.streaming !== "object")) { + const mode = resolveDiscordStreamMode(entry); + streaming.mode = mode; + changes.push( + "streamMode" in entry + ? `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming.mode (${mode}).` + : `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.streaming.mode (${mode}).`, + ); + changed = true; + } + if ("streamMode" in entry) { + delete entry.streamMode; + changed = true; + } + if ("chunkMode" in entry && !("chunkMode" in streaming)) { + streaming.chunkMode = entry.chunkMode; + delete entry.chunkMode; + changes.push(`Moved ${pathPrefix}.chunkMode → ${pathPrefix}.streaming.chunkMode.`); + changed = true; + } + const block = asRecord(streaming.block) ?? {}; + if ("blockStreaming" in entry && !("enabled" in block)) { + block.enabled = entry.blockStreaming; + delete entry.blockStreaming; + changes.push(`Moved ${pathPrefix}.blockStreaming → ${pathPrefix}.streaming.block.enabled.`); + changed = true; + } + if ("blockStreamingCoalesce" in entry && !("coalesce" in block)) { + block.coalesce = entry.blockStreamingCoalesce; + delete entry.blockStreamingCoalesce; + changes.push( + `Moved ${pathPrefix}.blockStreamingCoalesce → ${pathPrefix}.streaming.block.coalesce.`, + ); + changed = true; + } + if (Object.keys(block).length > 0) { + streaming.block = block; + } + const preview = asRecord(streaming.preview) ?? {}; + if ("draftChunk" in entry && !("chunk" in preview)) { + preview.chunk = entry.draftChunk; + delete entry.draftChunk; + changes.push(`Moved ${pathPrefix}.draftChunk → ${pathPrefix}.streaming.preview.chunk.`); + changed = true; + } + if (Object.keys(preview).length > 0) { + streaming.preview = preview; + } + entry.streaming = streaming; + return changed; + } + + function normalizeDiscordStreamingAliasesForTest(cfg: unknown): { + config: unknown; + changes: string[]; + } { + const root = asRecord(cfg); + const discord = asRecord(asRecord(root?.channels)?.discord); + if (!root || !discord) { + return { config: cfg, changes: [] }; + } + + const next = structuredClone(root); + const nextDiscord = asRecord(asRecord(next.channels)?.discord); + if (!nextDiscord) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + normalizeDiscordStreamingEntry(nextDiscord, "channels.discord", changes); + const accounts = asRecord(nextDiscord.accounts); + for (const [accountId, accountRaw] of Object.entries(accounts ?? {})) { + const account = asRecord(accountRaw); + if (account) { + normalizeDiscordStreamingEntry(account, `channels.discord.accounts.${accountId}`, changes); + } + } + return changes.length > 0 ? { config: next, changes } : { config: cfg, changes: [] }; + } + return { collectRelevantDoctorPluginIds: (raw: unknown): string[] => { const ids = new Set(); @@ -92,7 +208,7 @@ vi.mock("../plugins/doctor-contract-registry.js", () => { } return [...ids].toSorted(); }, - applyPluginDoctorCompatibilityMigrations: (cfg: unknown) => ({ config: cfg, changes: [] }), + applyPluginDoctorCompatibilityMigrations: normalizeDiscordStreamingAliasesForTest, listPluginDoctorLegacyConfigRules: () => [ { path: ["channels", "telegram", "groupMentionsOnly"], @@ -404,6 +520,10 @@ vi.mock("./doctor-config-preflight.js", async () => { await import("../plugins/doctor-contract-registry.js"); const { findLegacyConfigIssues }: typeof import("../config/legacy.js") = await import("../config/legacy.js"); + const { + applyRuntimeLegacyConfigMigrations, + }: typeof import("./doctor/shared/runtime-compat-api.js") = + await import("./doctor/shared/runtime-compat-api.js"); function resolveConfigPath() { const stateDir = @@ -430,18 +550,20 @@ vi.mock("./doctor-config-preflight.js", async () => { pluginIds: collectRelevantDoctorPluginIds(parsed), }), ); + const compat = applyRuntimeLegacyConfigMigrations(parsed); + const effectiveConfig = compat.next ?? parsed; return { snapshot: { exists, path: configPath, parsed, - config: parsed, - sourceConfig: parsed, + config: effectiveConfig, + sourceConfig: effectiveConfig, valid: legacyIssues.length === 0, warnings: [], legacyIssues, }, - baseConfig: parsed, + baseConfig: effectiveConfig, }; }), };