mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
fix(config): normalize channel streaming config shape (#61381)
* feat(config): add canonical streaming config helpers * refactor(runtime): prefer canonical streaming accessors * feat(config): normalize preview channel streaming shape * test(config): lock streaming normalization followups * fix(config): polish streaming migration edges * chore(config): refresh streaming baseline hash
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
cce3a44a277150049986fa95d0fee3afd27804ddaa4af45323c2d7ca03679401 config-baseline.json
|
||||
433dc1a6776b3c782524489d6bb22c770015d4915f6886da89bb3538698f0057 config-baseline.json
|
||||
71414a189b62e3a362443068cb911372b2fe326a0bf43237a36d475533508499 config-baseline.core.json
|
||||
5e45b930d3518f34a05cacd7128ad459dd26ae21394a99936c30d2d7201db9e6 config-baseline.channel.json
|
||||
a2c4233e7884f8510f7bc3dc4587ddd9389bf36fd18450b6d1a63e33f906cfb3 config-baseline.plugin.json
|
||||
66edc86a9d16db1b9e9e7dd99b7032e2d9bcfb9ff210256a21f4b4f088cb3dc1 config-baseline.channel.json
|
||||
d6ebc4948499b997c4a3727cf31849d4a598de9f1a4c197417dcc0b0ec1b734f config-baseline.plugin.json
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
normalizeAccountId,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import { resolveChannelStreamingChunkMode } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||
@@ -34,7 +35,10 @@ function mergeBlueBubblesAccountConfig(
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
});
|
||||
return { ...merged, chunkMode: merged.chunkMode ?? "length" };
|
||||
return {
|
||||
...merged,
|
||||
chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode ?? "length",
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesAccount(params: {
|
||||
|
||||
@@ -33,15 +33,31 @@ export const discordChannelConfigUiHints = {
|
||||
label: "Discord Streaming Mode",
|
||||
help: 'Unified Discord stream preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord. Legacy boolean/streamMode keys are auto-mapped.',
|
||||
},
|
||||
"draftChunk.minChars": {
|
||||
"streaming.mode": {
|
||||
label: "Discord Streaming Mode",
|
||||
help: 'Canonical Discord preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord.',
|
||||
},
|
||||
"streaming.chunkMode": {
|
||||
label: "Discord Chunk Mode",
|
||||
help: 'Chunking mode for outbound Discord text delivery: "length" (default) or "newline".',
|
||||
},
|
||||
"streaming.block.enabled": {
|
||||
label: "Discord Block Streaming Enabled",
|
||||
help: 'Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode="block".',
|
||||
},
|
||||
"streaming.block.coalesce": {
|
||||
label: "Discord Block Streaming Coalesce",
|
||||
help: "Merge streamed Discord block replies before final delivery.",
|
||||
},
|
||||
"streaming.preview.chunk.minChars": {
|
||||
label: "Discord Draft Chunk Min Chars",
|
||||
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming="block" (default: 200).',
|
||||
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode="block" (default: 200).',
|
||||
},
|
||||
"draftChunk.maxChars": {
|
||||
"streaming.preview.chunk.maxChars": {
|
||||
label: "Discord Draft Chunk Max Chars",
|
||||
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming="block" (default: 800; clamped to channels.discord.textChunkLimit).',
|
||||
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming.mode="block" (default: 800; clamped to channels.discord.textChunkLimit).',
|
||||
},
|
||||
"draftChunk.breakPreference": {
|
||||
"streaming.preview.chunk.breakPreference": {
|
||||
label: "Discord Draft Chunk Break Preference",
|
||||
help: "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.",
|
||||
},
|
||||
|
||||
@@ -11,6 +11,14 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asObjectRecord(owner[key]);
|
||||
if (existing) {
|
||||
return { ...existing };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeDiscordDmAliases(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
@@ -108,45 +116,100 @@ function normalizeDiscordStreamingAliases(params: {
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
const hadLegacyStreamMode = updated.streamMode !== undefined;
|
||||
const beforeStreaming = updated.streaming;
|
||||
const resolved = resolveDiscordPreviewStreamMode(updated);
|
||||
const beforeStreaming = params.entry.streaming;
|
||||
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
|
||||
const hasLegacyFlatFields =
|
||||
params.entry.chunkMode !== undefined ||
|
||||
params.entry.blockStreaming !== undefined ||
|
||||
params.entry.draftChunk !== undefined ||
|
||||
params.entry.blockStreamingCoalesce !== undefined;
|
||||
const resolved = resolveDiscordPreviewStreamMode(params.entry);
|
||||
const shouldNormalize =
|
||||
hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
|
||||
typeof beforeStreaming === "string" ||
|
||||
hasLegacyFlatFields;
|
||||
if (!shouldNormalize) {
|
||||
return { entry: updated, changed: false };
|
||||
return { entry: params.entry, changed: false };
|
||||
}
|
||||
|
||||
let updated = { ...params.entry };
|
||||
let changed = false;
|
||||
if (beforeStreaming !== resolved) {
|
||||
updated = { ...updated, streaming: resolved };
|
||||
const streaming = ensureNestedRecord(updated, "streaming");
|
||||
const block = ensureNestedRecord(streaming, "block");
|
||||
const preview = ensureNestedRecord(streaming, "preview");
|
||||
|
||||
if (
|
||||
(hadLegacyStreamMode || typeof beforeStreaming === "boolean" || typeof beforeStreaming === "string") &&
|
||||
streaming.mode === undefined
|
||||
) {
|
||||
streaming.mode = resolved;
|
||||
if (hadLegacyStreamMode) {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "string") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (hadLegacyStreamMode) {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
delete updated.streamMode;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
|
||||
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
|
||||
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
|
||||
streaming.chunkMode = updated.chunkMode;
|
||||
delete updated.chunkMode;
|
||||
params.changes.push(
|
||||
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
|
||||
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
|
||||
block.enabled = updated.blockStreaming;
|
||||
delete updated.blockStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.draftChunk !== undefined && preview.chunk === undefined) {
|
||||
preview.chunk = updated.draftChunk;
|
||||
delete updated.draftChunk;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
|
||||
block.coalesce = updated.blockStreamingCoalesce;
|
||||
delete updated.blockStreamingCoalesce;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (Object.keys(preview).length > 0) {
|
||||
streaming.preview = preview;
|
||||
}
|
||||
if (Object.keys(block).length > 0) {
|
||||
streaming.block = block;
|
||||
}
|
||||
updated.streaming = streaming;
|
||||
if (
|
||||
params.pathPrefix.startsWith("channels.discord") &&
|
||||
resolved === "off" &&
|
||||
hadLegacyStreamMode
|
||||
) {
|
||||
params.changes.push(
|
||||
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
|
||||
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming.mode="partial" to opt in explicitly.`,
|
||||
);
|
||||
}
|
||||
return { entry: updated, changed };
|
||||
@@ -160,8 +223,11 @@ function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" &&
|
||||
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
|
||||
typeof entry.streaming === "string" ||
|
||||
entry.chunkMode !== undefined ||
|
||||
entry.blockStreaming !== undefined ||
|
||||
entry.draftChunk !== undefined ||
|
||||
entry.blockStreamingCoalesce !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -274,13 +340,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "discord"],
|
||||
message:
|
||||
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
|
||||
"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}.",
|
||||
match: hasLegacyDiscordStreamingAliases,
|
||||
},
|
||||
{
|
||||
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.",
|
||||
"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}.",
|
||||
match: hasLegacyDiscordAccountStreamingAliases,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,8 +15,11 @@ function hasLegacyDiscordStreamingAliases(value: unknown): boolean {
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" &&
|
||||
entry.streaming !== resolveDiscordPreviewStreamMode(entry))
|
||||
typeof entry.streaming === "string" ||
|
||||
entry.chunkMode !== undefined ||
|
||||
entry.blockStreaming !== undefined ||
|
||||
entry.draftChunk !== undefined ||
|
||||
entry.blockStreamingCoalesce !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,13 +35,13 @@ export const DISCORD_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "discord"],
|
||||
message:
|
||||
"channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming.",
|
||||
"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}.",
|
||||
match: hasLegacyDiscordStreamingAliases,
|
||||
},
|
||||
{
|
||||
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.",
|
||||
"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}.",
|
||||
match: hasLegacyDiscordAccountStreamingAliases,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,11 +2,100 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectDiscordNumericIdWarnings,
|
||||
discordDoctor,
|
||||
maybeRepairDiscordNumericIds,
|
||||
scanDiscordNumericIdEntries,
|
||||
} from "./doctor.js";
|
||||
|
||||
describe("discord doctor", () => {
|
||||
it("normalizes legacy discord streaming aliases into the nested streaming shape", () => {
|
||||
const normalize = discordDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
streamMode: "block",
|
||||
chunkMode: "newline",
|
||||
blockStreaming: true,
|
||||
draftChunk: {
|
||||
minChars: 120,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
streaming: false,
|
||||
blockStreamingCoalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.discord?.streaming).toEqual({
|
||||
mode: "block",
|
||||
chunkMode: "newline",
|
||||
block: {
|
||||
enabled: true,
|
||||
},
|
||||
preview: {
|
||||
chunk: {
|
||||
minChars: 120,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.config.channels?.discord?.accounts?.work?.streaming).toEqual({
|
||||
mode: "off",
|
||||
block: {
|
||||
coalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"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("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
|
||||
const normalize = discordDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
streamMode: "block",
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.discord?.streaming).toEqual({
|
||||
mode: "block",
|
||||
});
|
||||
expect(
|
||||
result.changes.filter((change) => change.includes("channels.discord.streaming.mode")),
|
||||
).toEqual(["Moved channels.discord.streamMode → channels.discord.streaming.mode (block)."]);
|
||||
});
|
||||
|
||||
it("finds numeric id entries across discord scopes", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -21,6 +21,14 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asObjectRecord(owner[key]);
|
||||
if (existing) {
|
||||
return { ...existing };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function sanitizeForLog(value: string): string {
|
||||
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
||||
}
|
||||
@@ -138,45 +146,101 @@ function normalizeDiscordStreamingAliases(params: {
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
const hadLegacyStreamMode = updated.streamMode !== undefined;
|
||||
const beforeStreaming = updated.streaming;
|
||||
const resolved = resolveDiscordPreviewStreamMode(updated);
|
||||
const beforeStreaming = params.entry.streaming;
|
||||
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
|
||||
const hasLegacyFlatFields =
|
||||
params.entry.chunkMode !== undefined ||
|
||||
params.entry.blockStreaming !== undefined ||
|
||||
params.entry.draftChunk !== undefined ||
|
||||
params.entry.blockStreamingCoalesce !== undefined;
|
||||
const resolved = resolveDiscordPreviewStreamMode(params.entry);
|
||||
const shouldNormalize =
|
||||
hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
|
||||
typeof beforeStreaming === "string" ||
|
||||
hasLegacyFlatFields;
|
||||
if (!shouldNormalize) {
|
||||
return { entry: updated, changed: false };
|
||||
return { entry: params.entry, changed: false };
|
||||
}
|
||||
|
||||
let updated = { ...params.entry };
|
||||
let changed = false;
|
||||
if (beforeStreaming !== resolved) {
|
||||
updated = { ...updated, streaming: resolved };
|
||||
const streaming = ensureNestedRecord(updated, "streaming");
|
||||
const block = ensureNestedRecord(streaming, "block");
|
||||
const preview = ensureNestedRecord(streaming, "preview");
|
||||
|
||||
if (
|
||||
(hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
typeof beforeStreaming === "string") &&
|
||||
streaming.mode === undefined
|
||||
) {
|
||||
streaming.mode = resolved;
|
||||
if (hadLegacyStreamMode) {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "string") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (hadLegacyStreamMode) {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
delete updated.streamMode;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
|
||||
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
|
||||
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
|
||||
streaming.chunkMode = updated.chunkMode;
|
||||
delete updated.chunkMode;
|
||||
params.changes.push(
|
||||
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
|
||||
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
|
||||
block.enabled = updated.blockStreaming;
|
||||
delete updated.blockStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.draftChunk !== undefined && preview.chunk === undefined) {
|
||||
preview.chunk = updated.draftChunk;
|
||||
delete updated.draftChunk;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
|
||||
block.coalesce = updated.blockStreamingCoalesce;
|
||||
delete updated.blockStreamingCoalesce;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (Object.keys(preview).length > 0) {
|
||||
streaming.preview = preview;
|
||||
}
|
||||
if (Object.keys(block).length > 0) {
|
||||
streaming.block = block;
|
||||
}
|
||||
updated.streaming = streaming;
|
||||
if (
|
||||
params.pathPrefix.startsWith("channels.discord") &&
|
||||
resolved === "off" &&
|
||||
hadLegacyStreamMode
|
||||
) {
|
||||
params.changes.push(
|
||||
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
|
||||
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming.mode="partial" to opt in explicitly.`,
|
||||
);
|
||||
}
|
||||
return { entry: updated, changed };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveChannelStreamingPreviewChunk } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
|
||||
@@ -20,7 +21,11 @@ export function resolveDiscordDraftStreamingChunking(
|
||||
});
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const accountCfg = resolveAccountEntry(cfg?.channels?.discord?.accounts, normalizedAccountId);
|
||||
const draftCfg = accountCfg?.draftChunk ?? cfg?.channels?.discord?.draftChunk;
|
||||
const draftCfg =
|
||||
resolveChannelStreamingPreviewChunk(accountCfg) ??
|
||||
resolveChannelStreamingPreviewChunk(cfg?.channels?.discord) ??
|
||||
accountCfg?.draftChunk ??
|
||||
cfg?.channels?.discord?.draftChunk;
|
||||
|
||||
const maxRequested = Math.max(
|
||||
1,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
resolveEnvelopeFormatOptions,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveChannelContextVisibilityMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
@@ -544,9 +545,8 @@ export async function processDiscordMessage(
|
||||
const discordStreamMode = resolveDiscordPreviewStreamMode(discordConfig);
|
||||
const draftMaxChars = Math.min(textLimit, 2000);
|
||||
const accountBlockStreamingEnabled =
|
||||
typeof discordConfig?.blockStreaming === "boolean"
|
||||
? discordConfig.blockStreaming
|
||||
: cfg.agents?.defaults?.blockStreamingDefault === "on";
|
||||
resolveChannelStreamingBlockEnabled(discordConfig) ??
|
||||
cfg.agents?.defaults?.blockStreamingDefault === "on";
|
||||
const canStreamDraft = discordStreamMode !== "off" && !accountBlockStreamingEnabled;
|
||||
const draftReplyToMessageId = () => replyReference.use();
|
||||
const deliverChannelId = deliverTarget.startsWith("channel:")
|
||||
@@ -835,6 +835,7 @@ export async function processDiscordMessage(
|
||||
},
|
||||
});
|
||||
|
||||
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
|
||||
let dispatchResult: Awaited<ReturnType<typeof dispatchInboundMessage>> | null = null;
|
||||
let dispatchError = false;
|
||||
let dispatchAborted = false;
|
||||
@@ -853,8 +854,8 @@ export async function processDiscordMessage(
|
||||
skillFilter: channelConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
disableBlockStreamingForDraft ??
|
||||
(typeof discordConfig?.blockStreaming === "boolean"
|
||||
? !discordConfig.blockStreaming
|
||||
(typeof resolvedBlockStreamingEnabled === "boolean"
|
||||
? !resolvedBlockStreamingEnabled
|
||||
: undefined),
|
||||
onPartialReply: draftStream ? (payload) => updateDraftFromPartial(payload.text) : undefined,
|
||||
onAssistantMessageStart: draftStream
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { ApplicationCommandOptionType } from "discord-api-types/v10";
|
||||
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import {
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveNativeCommandSessionTargets,
|
||||
@@ -1134,6 +1135,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
accountId: effectiveRoute.accountId,
|
||||
});
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
|
||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
|
||||
|
||||
let didReply = false;
|
||||
const dispatchResult = await dispatchReplyWithDispatcherImpl({
|
||||
@@ -1175,9 +1177,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
replyOptions: {
|
||||
skillFilter: channelConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof discordConfig?.blockStreaming === "boolean"
|
||||
? !discordConfig.blockStreaming
|
||||
: undefined,
|
||||
typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getChannelStreamingConfigObject } from "openclaw/plugin-sdk/channel-streaming";
|
||||
|
||||
export type DiscordPreviewStreamMode = "off" | "partial" | "block";
|
||||
|
||||
function normalizeStreamingMode(value: unknown): string | null {
|
||||
@@ -35,7 +37,9 @@ export function resolveDiscordPreviewStreamMode(
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): DiscordPreviewStreamMode {
|
||||
const parsedStreaming = parseDiscordPreviewStreamMode(params.streaming);
|
||||
const parsedStreaming = parseDiscordPreviewStreamMode(
|
||||
getChannelStreamingConfigObject(params)?.mode ?? params.streaming,
|
||||
);
|
||||
if (parsedStreaming) {
|
||||
return parsedStreaming;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
|
||||
import {
|
||||
resolveChannelStreamingBlockCoalesce,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingChunkMode,
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
|
||||
import type {
|
||||
MattermostAccountConfig,
|
||||
@@ -27,6 +32,7 @@ export type ResolvedMattermostAccount = {
|
||||
oncharPrefixes?: string[];
|
||||
requireMention?: boolean;
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: MattermostAccountConfig["chunkMode"];
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
|
||||
};
|
||||
@@ -112,8 +118,10 @@ export function resolveMattermostAccount(params: {
|
||||
oncharPrefixes: merged.oncharPrefixes,
|
||||
requireMention,
|
||||
textChunkLimit: merged.textChunkLimit,
|
||||
blockStreaming: merged.blockStreaming,
|
||||
blockStreamingCoalesce: merged.blockStreamingCoalesce,
|
||||
chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode,
|
||||
blockStreaming: resolveChannelStreamingBlockEnabled(merged) ?? merged.blockStreaming,
|
||||
blockStreamingCoalesce:
|
||||
resolveChannelStreamingBlockCoalesce(merged) ?? merged.blockStreamingCoalesce,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -77,9 +77,25 @@ export const slackChannelConfigUiHints = {
|
||||
label: "Slack Streaming Mode",
|
||||
help: 'Unified Slack stream preview mode: "off" | "partial" | "block" | "progress". Legacy boolean/streamMode keys are auto-mapped.',
|
||||
},
|
||||
nativeStreaming: {
|
||||
"streaming.mode": {
|
||||
label: "Slack Streaming Mode",
|
||||
help: 'Canonical Slack preview mode: "off" | "partial" | "block" | "progress".',
|
||||
},
|
||||
"streaming.chunkMode": {
|
||||
label: "Slack Chunk Mode",
|
||||
help: 'Chunking mode for outbound Slack text delivery: "length" (default) or "newline".',
|
||||
},
|
||||
"streaming.block.enabled": {
|
||||
label: "Slack Block Streaming Enabled",
|
||||
help: 'Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode="block".',
|
||||
},
|
||||
"streaming.block.coalesce": {
|
||||
label: "Slack Block Streaming Coalesce",
|
||||
help: "Merge streamed Slack block replies before final delivery.",
|
||||
},
|
||||
"streaming.nativeTransport": {
|
||||
label: "Slack Native Streaming",
|
||||
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming is partial (default: true).",
|
||||
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true).",
|
||||
},
|
||||
"thread.historyScope": {
|
||||
label: "Slack Thread History Scope",
|
||||
|
||||
@@ -4,8 +4,6 @@ import type {
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
formatSlackStreamingBooleanMigrationMessage,
|
||||
formatSlackStreamModeMigrationMessage,
|
||||
resolveSlackNativeStreaming,
|
||||
resolveSlackStreamingMode,
|
||||
} from "./streaming-compat.js";
|
||||
@@ -16,55 +14,112 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asObjectRecord(owner[key]);
|
||||
if (existing) {
|
||||
return { ...existing };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeSlackStreamingAliases(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
const hadLegacyStreamMode = updated.streamMode !== undefined;
|
||||
const legacyStreaming = updated.streaming;
|
||||
const beforeStreaming = updated.streaming;
|
||||
const beforeNativeStreaming = updated.nativeStreaming;
|
||||
const resolvedStreaming = resolveSlackStreamingMode(updated);
|
||||
const resolvedNativeStreaming = resolveSlackNativeStreaming(updated);
|
||||
const beforeStreaming = params.entry.streaming;
|
||||
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
|
||||
const hasLegacyFlatFields =
|
||||
params.entry.chunkMode !== undefined ||
|
||||
params.entry.blockStreaming !== undefined ||
|
||||
params.entry.blockStreamingCoalesce !== undefined ||
|
||||
params.entry.nativeStreaming !== undefined;
|
||||
const resolvedStreaming = resolveSlackStreamingMode(params.entry);
|
||||
const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry);
|
||||
const shouldNormalize =
|
||||
hadLegacyStreamMode ||
|
||||
typeof legacyStreaming === "boolean" ||
|
||||
(typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming);
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
typeof beforeStreaming === "string" ||
|
||||
hasLegacyFlatFields;
|
||||
if (!shouldNormalize) {
|
||||
return { entry: updated, changed: false };
|
||||
return { entry: params.entry, changed: false };
|
||||
}
|
||||
|
||||
let updated = { ...params.entry };
|
||||
let changed = false;
|
||||
if (beforeStreaming !== resolvedStreaming) {
|
||||
updated = { ...updated, streaming: resolvedStreaming };
|
||||
changed = true;
|
||||
}
|
||||
const streaming = ensureNestedRecord(updated, "streaming");
|
||||
const block = ensureNestedRecord(streaming, "block");
|
||||
|
||||
if (
|
||||
typeof beforeNativeStreaming !== "boolean" ||
|
||||
beforeNativeStreaming !== resolvedNativeStreaming
|
||||
(hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
typeof beforeStreaming === "string") &&
|
||||
streaming.mode === undefined
|
||||
) {
|
||||
updated = { ...updated, nativeStreaming: resolvedNativeStreaming };
|
||||
streaming.mode = resolvedStreaming;
|
||||
if (hadLegacyStreamMode) {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
|
||||
);
|
||||
}
|
||||
if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "string") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (hadLegacyStreamMode) {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
delete updated.streamMode;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming),
|
||||
);
|
||||
}
|
||||
if (typeof legacyStreaming === "boolean") {
|
||||
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
|
||||
streaming.chunkMode = updated.chunkMode;
|
||||
delete updated.chunkMode;
|
||||
params.changes.push(
|
||||
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
|
||||
);
|
||||
} else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) {
|
||||
params.changes.push(
|
||||
`Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`,
|
||||
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
|
||||
block.enabled = updated.blockStreaming;
|
||||
delete updated.blockStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
|
||||
block.coalesce = updated.blockStreamingCoalesce;
|
||||
delete updated.blockStreamingCoalesce;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.nativeStreaming !== undefined && streaming.nativeTransport === undefined) {
|
||||
streaming.nativeTransport = resolvedNativeStreaming;
|
||||
delete updated.nativeStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`,
|
||||
);
|
||||
changed = true;
|
||||
} else if (typeof beforeStreaming === "boolean" && streaming.nativeTransport === undefined) {
|
||||
streaming.nativeTransport = resolvedNativeStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (Object.keys(block).length > 0) {
|
||||
streaming.block = block;
|
||||
}
|
||||
updated.streaming = streaming;
|
||||
|
||||
return { entry: updated, changed };
|
||||
}
|
||||
@@ -77,7 +132,11 @@ function hasLegacySlackStreamingAliases(value: unknown): boolean {
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" && entry.streaming !== resolveSlackStreamingMode(entry))
|
||||
typeof entry.streaming === "string" ||
|
||||
entry.chunkMode !== undefined ||
|
||||
entry.blockStreaming !== undefined ||
|
||||
entry.blockStreamingCoalesce !== undefined ||
|
||||
entry.nativeStreaming !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,13 +152,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "slack"],
|
||||
message:
|
||||
"channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming and channels.slack.nativeStreaming.",
|
||||
"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 and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.streaming and channels.slack.accounts.<id>.nativeStreaming.",
|
||||
"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: hasLegacySlackAccountStreamingAliases,
|
||||
},
|
||||
];
|
||||
|
||||
85
extensions/slack/src/doctor.test.ts
Normal file
85
extensions/slack/src/doctor.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { slackDoctor } from "./doctor.js";
|
||||
|
||||
describe("slack doctor", () => {
|
||||
it("normalizes legacy slack streaming aliases into the nested streaming shape", () => {
|
||||
const normalize = slackDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
streamMode: "status_final",
|
||||
chunkMode: "newline",
|
||||
blockStreaming: true,
|
||||
blockStreamingCoalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
streaming: false,
|
||||
nativeStreaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.slack?.streaming).toEqual({
|
||||
mode: "progress",
|
||||
chunkMode: "newline",
|
||||
block: {
|
||||
enabled: true,
|
||||
coalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.config.channels?.slack?.accounts?.work?.streaming).toEqual({
|
||||
mode: "off",
|
||||
nativeTransport: false,
|
||||
});
|
||||
expect(result.changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
|
||||
"Moved channels.slack.chunkMode → channels.slack.streaming.chunkMode.",
|
||||
"Moved channels.slack.blockStreaming → channels.slack.streaming.block.enabled.",
|
||||
"Moved channels.slack.blockStreamingCoalesce → channels.slack.streaming.block.coalesce.",
|
||||
"Moved channels.slack.accounts.work.streaming (boolean) → channels.slack.accounts.work.streaming.mode (off).",
|
||||
"Moved channels.slack.accounts.work.nativeStreaming → channels.slack.accounts.work.streaming.nativeTransport.",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
|
||||
const normalize = slackDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
streamMode: "status_final",
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.slack?.streaming).toEqual({
|
||||
mode: "progress",
|
||||
nativeTransport: false,
|
||||
});
|
||||
expect(
|
||||
result.changes.filter((change) => change.includes("channels.slack.streaming.mode")),
|
||||
).toEqual(["Moved channels.slack.streamMode → channels.slack.streaming.mode (progress)."]);
|
||||
});
|
||||
});
|
||||
@@ -6,12 +6,7 @@ import {
|
||||
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { isSlackMutableAllowEntry } from "./security-doctor.js";
|
||||
import {
|
||||
formatSlackStreamingBooleanMigrationMessage,
|
||||
formatSlackStreamModeMigrationMessage,
|
||||
resolveSlackNativeStreaming,
|
||||
resolveSlackStreamingMode,
|
||||
} from "./streaming-compat.js";
|
||||
import { resolveSlackNativeStreaming, resolveSlackStreamingMode } from "./streaming-compat.js";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
@@ -19,6 +14,14 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asObjectRecord(owner[key]);
|
||||
if (existing) {
|
||||
return { ...existing };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function sanitizeForLog(value: string): string {
|
||||
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
||||
}
|
||||
@@ -115,50 +118,98 @@ function normalizeSlackStreamingAliases(params: {
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
const hadLegacyStreamMode = updated.streamMode !== undefined;
|
||||
const legacyStreaming = updated.streaming;
|
||||
const beforeStreaming = updated.streaming;
|
||||
const beforeNativeStreaming = updated.nativeStreaming;
|
||||
const resolvedStreaming = resolveSlackStreamingMode(updated);
|
||||
const resolvedNativeStreaming = resolveSlackNativeStreaming(updated);
|
||||
const beforeStreaming = params.entry.streaming;
|
||||
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
|
||||
const hasLegacyFlatFields =
|
||||
params.entry.chunkMode !== undefined ||
|
||||
params.entry.blockStreaming !== undefined ||
|
||||
params.entry.blockStreamingCoalesce !== undefined ||
|
||||
params.entry.nativeStreaming !== undefined;
|
||||
const resolvedStreaming = resolveSlackStreamingMode(params.entry);
|
||||
const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry);
|
||||
const shouldNormalize =
|
||||
hadLegacyStreamMode ||
|
||||
typeof legacyStreaming === "boolean" ||
|
||||
(typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming);
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
typeof beforeStreaming === "string" ||
|
||||
hasLegacyFlatFields;
|
||||
if (!shouldNormalize) {
|
||||
return { entry: updated, changed: false };
|
||||
return { entry: params.entry, changed: false };
|
||||
}
|
||||
|
||||
let updated = { ...params.entry };
|
||||
let changed = false;
|
||||
if (beforeStreaming !== resolvedStreaming) {
|
||||
updated = { ...updated, streaming: resolvedStreaming };
|
||||
changed = true;
|
||||
}
|
||||
const streaming = ensureNestedRecord(updated, "streaming");
|
||||
const block = ensureNestedRecord(streaming, "block");
|
||||
|
||||
if (
|
||||
typeof beforeNativeStreaming !== "boolean" ||
|
||||
beforeNativeStreaming !== resolvedNativeStreaming
|
||||
(hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
typeof beforeStreaming === "string") &&
|
||||
streaming.mode === undefined
|
||||
) {
|
||||
updated = { ...updated, nativeStreaming: resolvedNativeStreaming };
|
||||
streaming.mode = resolvedStreaming;
|
||||
if (hadLegacyStreamMode) {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "string") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolvedStreaming}).`,
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (hadLegacyStreamMode) {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
delete updated.streamMode;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming),
|
||||
);
|
||||
}
|
||||
if (typeof legacyStreaming === "boolean") {
|
||||
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
|
||||
streaming.chunkMode = updated.chunkMode;
|
||||
delete updated.chunkMode;
|
||||
params.changes.push(
|
||||
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
|
||||
);
|
||||
} else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) {
|
||||
params.changes.push(
|
||||
`Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`,
|
||||
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
|
||||
block.enabled = updated.blockStreaming;
|
||||
delete updated.blockStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
|
||||
block.coalesce = updated.blockStreamingCoalesce;
|
||||
delete updated.blockStreamingCoalesce;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.nativeStreaming !== undefined && streaming.nativeTransport === undefined) {
|
||||
streaming.nativeTransport = resolvedNativeStreaming;
|
||||
delete updated.nativeStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`,
|
||||
);
|
||||
changed = true;
|
||||
} else if (typeof beforeStreaming === "boolean" && streaming.nativeTransport === undefined) {
|
||||
streaming.nativeTransport = resolvedNativeStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (Object.keys(block).length > 0) {
|
||||
streaming.block = block;
|
||||
}
|
||||
updated.streaming = streaming;
|
||||
|
||||
return { entry: updated, changed };
|
||||
}
|
||||
@@ -301,7 +352,11 @@ function hasLegacySlackStreamingAliases(value: unknown): boolean {
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" && entry.streaming !== resolveSlackStreamingMode(entry))
|
||||
typeof entry.streaming === "string" ||
|
||||
entry.chunkMode !== undefined ||
|
||||
entry.blockStreaming !== undefined ||
|
||||
entry.blockStreamingCoalesce !== undefined ||
|
||||
entry.nativeStreaming !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -317,13 +372,13 @@ const SLACK_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "slack"],
|
||||
message:
|
||||
"channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming and channels.slack.nativeStreaming.",
|
||||
"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 and boolean channels.slack.accounts.<id>.streaming are legacy; use channels.slack.accounts.<id>.streaming and channels.slack.accounts.<id>.nativeStreaming.",
|
||||
"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: hasLegacySlackAccountStreamingAliases,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
type StatusReactionAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-feedback";
|
||||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import {
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingNativeTransport,
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
@@ -319,7 +323,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
|
||||
const slackStreaming = resolveSlackStreamingConfig({
|
||||
streaming: account.config.streaming,
|
||||
nativeStreaming: account.config.nativeStreaming,
|
||||
nativeStreaming: resolveChannelStreamingNativeTransport(account.config),
|
||||
});
|
||||
const streamThreadHint = resolveSlackStreamingThreadHint({
|
||||
replyToMode: prepared.replyToMode,
|
||||
@@ -575,8 +579,8 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
|
||||
hasRepliedRef,
|
||||
disableBlockStreaming: useStreaming
|
||||
? true
|
||||
: typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: typeof resolveChannelStreamingBlockEnabled(account.config) === "boolean"
|
||||
? !resolveChannelStreamingBlockEnabled(account.config)
|
||||
: undefined,
|
||||
onModelSelected,
|
||||
onPartialReply: useStreaming
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
getChannelStreamingConfigObject,
|
||||
resolveChannelStreamingNativeTransport,
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
|
||||
export type StreamingMode = "off" | "partial" | "block" | "progress";
|
||||
export type SlackLegacyDraftStreamMode = "replace" | "status_final" | "append";
|
||||
|
||||
@@ -56,7 +61,9 @@ export function resolveSlackStreamingMode(
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): StreamingMode {
|
||||
const parsedStreaming = parseStreamingMode(params.streaming);
|
||||
const parsedStreaming = parseStreamingMode(
|
||||
getChannelStreamingConfigObject(params)?.mode ?? params.streaming,
|
||||
);
|
||||
if (parsedStreaming) {
|
||||
return parsedStreaming;
|
||||
}
|
||||
@@ -76,25 +83,12 @@ export function resolveSlackNativeStreaming(
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): boolean {
|
||||
if (typeof params.nativeStreaming === "boolean") {
|
||||
return params.nativeStreaming;
|
||||
const canonical = resolveChannelStreamingNativeTransport(params);
|
||||
if (typeof canonical === "boolean") {
|
||||
return canonical;
|
||||
}
|
||||
if (typeof params.streaming === "boolean") {
|
||||
return params.streaming;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatSlackStreamModeMigrationMessage(
|
||||
pathPrefix: string,
|
||||
resolvedStreaming: string,
|
||||
): string {
|
||||
return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`;
|
||||
}
|
||||
|
||||
export function formatSlackStreamingBooleanMigrationMessage(
|
||||
pathPrefix: string,
|
||||
resolvedNativeStreaming: boolean,
|
||||
): string {
|
||||
return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
removeAckReactionAfterReply,
|
||||
} from "openclaw/plugin-sdk/channel-feedback";
|
||||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
ReplyToMode,
|
||||
@@ -199,9 +200,8 @@ export const dispatchTelegramMessage = async ({
|
||||
parseMode: "HTML" as const,
|
||||
});
|
||||
const accountBlockStreamingEnabled =
|
||||
typeof telegramCfg.blockStreaming === "boolean"
|
||||
? telegramCfg.blockStreaming
|
||||
: cfg.agents?.defaults?.blockStreamingDefault === "on";
|
||||
resolveChannelStreamingBlockEnabled(telegramCfg) ??
|
||||
cfg.agents?.defaults?.blockStreamingDefault === "on";
|
||||
const resolvedReasoningLevel = resolveTelegramReasoningLevel({
|
||||
cfg,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
@@ -389,12 +389,13 @@ export const dispatchTelegramMessage = async ({
|
||||
await lane.stream.flush();
|
||||
};
|
||||
|
||||
const resolvedBlockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg);
|
||||
const disableBlockStreaming = !previewStreamingEnabled
|
||||
? true
|
||||
: forceBlockStreamingForReasoning
|
||||
? false
|
||||
: typeof telegramCfg.blockStreaming === "boolean"
|
||||
? !telegramCfg.blockStreaming
|
||||
: typeof resolvedBlockStreamingEnabled === "boolean"
|
||||
? !resolvedBlockStreamingEnabled
|
||||
: canStreamAnswerDraft
|
||||
? true
|
||||
: undefined;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { pluginCommandMocks, resetPluginCommandMocks } from "./test-support/plug
|
||||
|
||||
let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands;
|
||||
let parseTelegramNativeCommandCallbackData: typeof import("./bot-native-commands.js").parseTelegramNativeCommandCallbackData;
|
||||
let resolveTelegramNativeCommandDisableBlockStreaming: typeof import("./bot-native-commands.js").resolveTelegramNativeCommandDisableBlockStreaming;
|
||||
import {
|
||||
createCommandBot,
|
||||
createNativeCommandTestParams,
|
||||
@@ -22,8 +23,11 @@ import {
|
||||
|
||||
describe("registerTelegramNativeCommands", () => {
|
||||
beforeAll(async () => {
|
||||
({ registerTelegramNativeCommands, parseTelegramNativeCommandCallbackData } =
|
||||
await import("./bot-native-commands.js"));
|
||||
({
|
||||
registerTelegramNativeCommands,
|
||||
parseTelegramNativeCommandCallbackData,
|
||||
resolveTelegramNativeCommandDisableBlockStreaming,
|
||||
} = await import("./bot-native-commands.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -281,6 +285,27 @@ describe("registerTelegramNativeCommands", () => {
|
||||
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
|
||||
});
|
||||
|
||||
it("uses nested streaming.block.enabled for native command block-streaming behavior", () => {
|
||||
expect(
|
||||
resolveTelegramNativeCommandDisableBlockStreaming({
|
||||
streaming: {
|
||||
block: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
} as TelegramAccountConfig),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveTelegramNativeCommandDisableBlockStreaming({
|
||||
streaming: {
|
||||
block: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as TelegramAccountConfig),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses plugin command metadata to send and edit a Telegram progress placeholder", async () => {
|
||||
const { bot, commandHandlers, sendMessage, deleteMessage } = createCommandBot();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Bot, Context } from "grammy";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import {
|
||||
resolveCommandAuthorization,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
@@ -213,6 +214,13 @@ export function parseTelegramNativeCommandCallbackData(data?: string | null): st
|
||||
return commandText.startsWith("/") ? commandText : null;
|
||||
}
|
||||
|
||||
export function resolveTelegramNativeCommandDisableBlockStreaming(
|
||||
telegramCfg: TelegramAccountConfig,
|
||||
): boolean | undefined {
|
||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(telegramCfg);
|
||||
return typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined;
|
||||
}
|
||||
|
||||
export type RegisterTelegramNativeCommandsParams = {
|
||||
bot: Bot;
|
||||
cfg: OpenClawConfig;
|
||||
@@ -900,9 +908,7 @@ export const registerTelegramNativeCommands = ({
|
||||
});
|
||||
|
||||
const disableBlockStreaming =
|
||||
typeof runtimeTelegramCfg.blockStreaming === "boolean"
|
||||
? !runtimeTelegramCfg.blockStreaming
|
||||
: undefined;
|
||||
resolveTelegramNativeCommandDisableBlockStreaming(runtimeTelegramCfg);
|
||||
const deliveryState = {
|
||||
delivered: false,
|
||||
skippedNonSilent: 0,
|
||||
|
||||
@@ -33,6 +33,34 @@ export const telegramChannelConfigUiHints = {
|
||||
label: "Telegram Streaming Mode",
|
||||
help: 'Unified Telegram stream preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram. Legacy boolean/streamMode keys are auto-mapped.',
|
||||
},
|
||||
"streaming.mode": {
|
||||
label: "Telegram Streaming Mode",
|
||||
help: 'Canonical Telegram preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram.',
|
||||
},
|
||||
"streaming.chunkMode": {
|
||||
label: "Telegram Chunk Mode",
|
||||
help: 'Chunking mode for outbound Telegram text delivery: "length" (default) or "newline".',
|
||||
},
|
||||
"streaming.block.enabled": {
|
||||
label: "Telegram Block Streaming Enabled",
|
||||
help: 'Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode="block".',
|
||||
},
|
||||
"streaming.block.coalesce": {
|
||||
label: "Telegram Block Streaming Coalesce",
|
||||
help: "Merge streamed Telegram block replies before sending final delivery.",
|
||||
},
|
||||
"streaming.preview.chunk.minChars": {
|
||||
label: "Telegram Draft Chunk Min Chars",
|
||||
help: 'Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode="block".',
|
||||
},
|
||||
"streaming.preview.chunk.maxChars": {
|
||||
label: "Telegram Draft Chunk Max Chars",
|
||||
help: 'Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode="block".',
|
||||
},
|
||||
"streaming.preview.chunk.breakPreference": {
|
||||
label: "Telegram Draft Chunk Break Preference",
|
||||
help: "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence).",
|
||||
},
|
||||
"retry.attempts": {
|
||||
label: "Telegram Retry Attempts",
|
||||
help: "Max retry attempts for outbound Telegram API calls (default: 3).",
|
||||
|
||||
@@ -11,43 +11,108 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asObjectRecord(owner[key]);
|
||||
if (existing) {
|
||||
return { ...existing };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function normalizeTelegramStreamingAliases(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
const hadLegacyStreamMode = updated.streamMode !== undefined;
|
||||
const beforeStreaming = updated.streaming;
|
||||
const resolved = resolveTelegramPreviewStreamMode(updated);
|
||||
const beforeStreaming = params.entry.streaming;
|
||||
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
|
||||
const hasLegacyFlatFields =
|
||||
params.entry.chunkMode !== undefined ||
|
||||
params.entry.blockStreaming !== undefined ||
|
||||
params.entry.draftChunk !== undefined ||
|
||||
params.entry.blockStreamingCoalesce !== undefined;
|
||||
const resolved = resolveTelegramPreviewStreamMode(params.entry);
|
||||
const shouldNormalize =
|
||||
hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
|
||||
typeof beforeStreaming === "string" ||
|
||||
hasLegacyFlatFields;
|
||||
if (!shouldNormalize) {
|
||||
return { entry: updated, changed: false };
|
||||
return { entry: params.entry, changed: false };
|
||||
}
|
||||
|
||||
let updated = { ...params.entry };
|
||||
let changed = false;
|
||||
if (beforeStreaming !== resolved) {
|
||||
updated = { ...updated, streaming: resolved };
|
||||
const streaming = ensureNestedRecord(updated, "streaming");
|
||||
const block = ensureNestedRecord(streaming, "block");
|
||||
const preview = ensureNestedRecord(streaming, "preview");
|
||||
|
||||
if (
|
||||
(hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
typeof beforeStreaming === "string") &&
|
||||
streaming.mode === undefined
|
||||
) {
|
||||
streaming.mode = resolved;
|
||||
if (hadLegacyStreamMode) {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "string") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (hadLegacyStreamMode) {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
delete updated.streamMode;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
|
||||
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
|
||||
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
|
||||
streaming.chunkMode = updated.chunkMode;
|
||||
delete updated.chunkMode;
|
||||
params.changes.push(
|
||||
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
|
||||
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
|
||||
block.enabled = updated.blockStreaming;
|
||||
delete updated.blockStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.draftChunk !== undefined && preview.chunk === undefined) {
|
||||
preview.chunk = updated.draftChunk;
|
||||
delete updated.draftChunk;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
|
||||
block.coalesce = updated.blockStreamingCoalesce;
|
||||
delete updated.blockStreamingCoalesce;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (Object.keys(preview).length > 0) {
|
||||
streaming.preview = preview;
|
||||
}
|
||||
if (Object.keys(block).length > 0) {
|
||||
streaming.block = block;
|
||||
}
|
||||
updated.streaming = streaming;
|
||||
return { entry: updated, changed };
|
||||
}
|
||||
|
||||
@@ -59,8 +124,11 @@ function hasLegacyTelegramStreamingAliases(value: unknown): boolean {
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" &&
|
||||
entry.streaming !== resolveTelegramPreviewStreamMode(entry))
|
||||
typeof entry.streaming === "string" ||
|
||||
entry.chunkMode !== undefined ||
|
||||
entry.blockStreaming !== undefined ||
|
||||
entry.draftChunk !== undefined ||
|
||||
entry.blockStreamingCoalesce !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,13 +167,13 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "telegram"],
|
||||
message:
|
||||
'channels.telegram.streamMode and boolean channels.telegram.streaming are legacy; use channels.telegram.streaming="off|partial|block".',
|
||||
"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 and boolean channels.telegram.accounts.<id>.streaming are legacy; use channels.telegram.accounts.<id>.streaming="off|partial|block".',
|
||||
"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: hasLegacyTelegramAccountStreamingAliases,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
collectTelegramGroupPolicyWarnings,
|
||||
maybeRepairTelegramAllowFromUsernames,
|
||||
scanTelegramAllowFromUsernameEntries,
|
||||
telegramDoctor,
|
||||
} from "./doctor.js";
|
||||
|
||||
const resolveCommandSecretRefsViaGatewayMock = vi.hoisted(() => vi.fn());
|
||||
@@ -66,6 +67,94 @@ describe("telegram doctor", () => {
|
||||
lookupTelegramChatIdMock.mockReset();
|
||||
});
|
||||
|
||||
it("normalizes legacy telegram streaming aliases into the nested streaming shape", () => {
|
||||
const normalize = telegramDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
streamMode: "block",
|
||||
chunkMode: "newline",
|
||||
blockStreaming: true,
|
||||
draftChunk: {
|
||||
minChars: 120,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
streaming: false,
|
||||
blockStreamingCoalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.telegram?.streaming).toEqual({
|
||||
mode: "block",
|
||||
chunkMode: "newline",
|
||||
block: {
|
||||
enabled: true,
|
||||
},
|
||||
preview: {
|
||||
chunk: {
|
||||
minChars: 120,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.config.channels?.telegram?.accounts?.work?.streaming).toEqual({
|
||||
mode: "off",
|
||||
block: {
|
||||
coalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).",
|
||||
"Moved channels.telegram.chunkMode → channels.telegram.streaming.chunkMode.",
|
||||
"Moved channels.telegram.blockStreaming → channels.telegram.streaming.block.enabled.",
|
||||
"Moved channels.telegram.draftChunk → channels.telegram.streaming.preview.chunk.",
|
||||
"Moved channels.telegram.accounts.work.streaming (boolean) → channels.telegram.accounts.work.streaming.mode (off).",
|
||||
"Moved channels.telegram.accounts.work.blockStreamingCoalesce → channels.telegram.accounts.work.streaming.block.coalesce.",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate streaming.mode change messages when streamMode wins over boolean streaming", () => {
|
||||
const normalize = telegramDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
telegram: {
|
||||
streamMode: "block",
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.telegram?.streaming).toEqual({
|
||||
mode: "block",
|
||||
});
|
||||
expect(
|
||||
result.changes.filter((change) => change.includes("channels.telegram.streaming.mode")),
|
||||
).toEqual(["Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block)."]);
|
||||
});
|
||||
|
||||
it("finds username allowFrom entries across scopes", () => {
|
||||
const hits = scanTelegramAllowFromUsernameEntries({
|
||||
channels: {
|
||||
|
||||
@@ -27,6 +27,14 @@ function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = asObjectRecord(owner[key]);
|
||||
if (existing) {
|
||||
return { ...existing };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function sanitizeForLog(value: string): string {
|
||||
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
||||
}
|
||||
@@ -40,38 +48,95 @@ function normalizeTelegramStreamingAliases(params: {
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
const hadLegacyStreamMode = updated.streamMode !== undefined;
|
||||
const beforeStreaming = updated.streaming;
|
||||
const resolved = resolveTelegramPreviewStreamMode(updated);
|
||||
const beforeStreaming = params.entry.streaming;
|
||||
const hadLegacyStreamMode = params.entry.streamMode !== undefined;
|
||||
const hasLegacyFlatFields =
|
||||
params.entry.chunkMode !== undefined ||
|
||||
params.entry.blockStreaming !== undefined ||
|
||||
params.entry.draftChunk !== undefined ||
|
||||
params.entry.blockStreamingCoalesce !== undefined;
|
||||
const resolved = resolveTelegramPreviewStreamMode(params.entry);
|
||||
const shouldNormalize =
|
||||
hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
|
||||
typeof beforeStreaming === "string" ||
|
||||
hasLegacyFlatFields;
|
||||
if (!shouldNormalize) {
|
||||
return { entry: updated, changed: false };
|
||||
return { entry: params.entry, changed: false };
|
||||
}
|
||||
|
||||
let updated = { ...params.entry };
|
||||
let changed = false;
|
||||
if (beforeStreaming !== resolved) {
|
||||
updated = { ...updated, streaming: resolved };
|
||||
const streaming = ensureNestedRecord(updated, "streaming");
|
||||
const block = ensureNestedRecord(streaming, "block");
|
||||
const preview = ensureNestedRecord(streaming, "preview");
|
||||
|
||||
if (
|
||||
(hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
typeof beforeStreaming === "string") &&
|
||||
streaming.mode === undefined
|
||||
) {
|
||||
streaming.mode = resolved;
|
||||
if (hadLegacyStreamMode) {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
} else if (typeof beforeStreaming === "string") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolved}).`,
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
if (hadLegacyStreamMode) {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
delete updated.streamMode;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
|
||||
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
|
||||
if (updated.chunkMode !== undefined && streaming.chunkMode === undefined) {
|
||||
streaming.chunkMode = updated.chunkMode;
|
||||
delete updated.chunkMode;
|
||||
params.changes.push(
|
||||
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
|
||||
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreaming !== undefined && block.enabled === undefined) {
|
||||
block.enabled = updated.blockStreaming;
|
||||
delete updated.blockStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.draftChunk !== undefined && preview.chunk === undefined) {
|
||||
preview.chunk = updated.draftChunk;
|
||||
delete updated.draftChunk;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (updated.blockStreamingCoalesce !== undefined && block.coalesce === undefined) {
|
||||
block.coalesce = updated.blockStreamingCoalesce;
|
||||
delete updated.blockStreamingCoalesce;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (Object.keys(preview).length > 0) {
|
||||
streaming.preview = preview;
|
||||
}
|
||||
if (Object.keys(block).length > 0) {
|
||||
streaming.block = block;
|
||||
}
|
||||
updated.streaming = streaming;
|
||||
return { entry: updated, changed };
|
||||
}
|
||||
|
||||
@@ -458,8 +523,11 @@ function hasLegacyTelegramStreamingAliases(value: unknown): boolean {
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
(typeof entry.streaming === "string" &&
|
||||
entry.streaming !== resolveTelegramPreviewStreamMode(entry))
|
||||
typeof entry.streaming === "string" ||
|
||||
entry.chunkMode !== undefined ||
|
||||
entry.blockStreaming !== undefined ||
|
||||
entry.draftChunk !== undefined ||
|
||||
entry.blockStreamingCoalesce !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -475,13 +543,13 @@ const TELEGRAM_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "telegram"],
|
||||
message:
|
||||
'channels.telegram.streamMode and boolean channels.telegram.streaming are legacy; use channels.telegram.streaming="off|partial|block".',
|
||||
"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 and boolean channels.telegram.accounts.<id>.streaming are legacy; use channels.telegram.accounts.<id>.streaming="off|partial|block".',
|
||||
"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: hasLegacyTelegramAccountStreamingAliases,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveChannelStreamingPreviewChunk } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
|
||||
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
|
||||
@@ -20,7 +21,11 @@ export function resolveTelegramDraftStreamingChunking(
|
||||
});
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const accountCfg = resolveAccountEntry(cfg?.channels?.telegram?.accounts, normalizedAccountId);
|
||||
const draftCfg = accountCfg?.draftChunk ?? cfg?.channels?.telegram?.draftChunk;
|
||||
const draftCfg =
|
||||
resolveChannelStreamingPreviewChunk(accountCfg) ??
|
||||
resolveChannelStreamingPreviewChunk(cfg?.channels?.telegram) ??
|
||||
accountCfg?.draftChunk ??
|
||||
cfg?.channels?.telegram?.draftChunk;
|
||||
|
||||
const maxRequested = Math.max(
|
||||
1,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getChannelStreamingConfigObject } from "openclaw/plugin-sdk/channel-streaming";
|
||||
|
||||
export type TelegramPreviewStreamMode = "off" | "partial" | "block";
|
||||
|
||||
function normalizeStreamingMode(value: unknown): string | null {
|
||||
@@ -35,7 +37,9 @@ export function resolveTelegramPreviewStreamMode(
|
||||
streaming?: unknown;
|
||||
} = {},
|
||||
): TelegramPreviewStreamMode {
|
||||
const parsedStreaming = parseStreamingMode(params.streaming);
|
||||
const parsedStreaming = parseStreamingMode(
|
||||
getChannelStreamingConfigObject(params)?.mode ?? params.streaming,
|
||||
);
|
||||
if (parsedStreaming) {
|
||||
if (parsedStreaming === "progress") {
|
||||
return "partial";
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
resolveMergedAccountConfig,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-core";
|
||||
import {
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingChunkMode,
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
import type { WhatsAppAccountConfig } from "./runtime-api.js";
|
||||
|
||||
function resolveWhatsAppAccountConfig(
|
||||
@@ -28,5 +32,7 @@ export function resolveMergedWhatsAppAccountConfig(params: {
|
||||
return {
|
||||
accountId,
|
||||
...merged,
|
||||
chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode,
|
||||
blockStreaming: resolveChannelStreamingBlockEnabled(merged) ?? merged.blockStreaming,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +95,10 @@
|
||||
"types": "./dist/plugin-sdk/channel-setup.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-setup.js"
|
||||
},
|
||||
"./plugin-sdk/channel-streaming": {
|
||||
"types": "./dist/plugin-sdk/channel-streaming.d.ts",
|
||||
"default": "./dist/plugin-sdk/channel-streaming.js"
|
||||
},
|
||||
"./plugin-sdk/setup-tools": {
|
||||
"types": "./dist/plugin-sdk/setup-tools.d.ts",
|
||||
"default": "./dist/plugin-sdk/setup-tools.js"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"setup-adapter-runtime",
|
||||
"setup-runtime",
|
||||
"channel-setup",
|
||||
"channel-streaming",
|
||||
"setup-tools",
|
||||
"approval-auth-runtime",
|
||||
"approval-client-runtime",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { findFenceSpanAt, isSafeFenceBreak, parseFenceSpans } from "../markdown/fences.js";
|
||||
import { resolveChannelStreamingChunkMode } from "../plugin-sdk/channel-streaming.js";
|
||||
import { resolveAccountEntry } from "../routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import { chunkTextByBreakResolver } from "../shared/text-chunking.js";
|
||||
@@ -27,7 +28,11 @@ const DEFAULT_CHUNK_MODE: ChunkMode = "length";
|
||||
type ProviderChunkConfig = {
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: ChunkMode;
|
||||
accounts?: Record<string, { textChunkLimit?: number; chunkMode?: ChunkMode }>;
|
||||
streaming?: unknown;
|
||||
accounts?: Record<
|
||||
string,
|
||||
{ textChunkLimit?: number; chunkMode?: ChunkMode; streaming?: unknown }
|
||||
>;
|
||||
};
|
||||
|
||||
function resolveChunkLimitForProvider(
|
||||
@@ -84,11 +89,12 @@ function resolveChunkModeForProvider(
|
||||
const accounts = cfgSection.accounts;
|
||||
if (accounts && typeof accounts === "object") {
|
||||
const direct = resolveAccountEntry(accounts, normalizedAccountId);
|
||||
if (direct?.chunkMode) {
|
||||
return direct.chunkMode;
|
||||
const directMode = resolveChannelStreamingChunkMode(direct);
|
||||
if (directMode) {
|
||||
return directMode;
|
||||
}
|
||||
}
|
||||
return cfgSection.chunkMode;
|
||||
return resolveChannelStreamingChunkMode(cfgSection) ?? cfgSection.chunkMode;
|
||||
}
|
||||
|
||||
export function resolveChunkMode(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
|
||||
import { resolveChannelStreamingBlockCoalesce } from "../../plugin-sdk/channel-streaming.js";
|
||||
import { resolveAccountEntry } from "../../routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
@@ -39,7 +40,11 @@ function resolveProviderChunkContext(
|
||||
|
||||
type ProviderBlockStreamingConfig = {
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
accounts?: Record<string, { blockStreamingCoalesce?: BlockStreamingCoalesceConfig }>;
|
||||
streaming?: unknown;
|
||||
accounts?: Record<
|
||||
string,
|
||||
{ blockStreamingCoalesce?: BlockStreamingCoalesceConfig; streaming?: unknown }
|
||||
>;
|
||||
};
|
||||
|
||||
function resolveProviderBlockStreamingCoalesce(params: {
|
||||
@@ -58,7 +63,12 @@ function resolveProviderBlockStreamingCoalesce(params: {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const typed = providerCfg as ProviderBlockStreamingConfig;
|
||||
const accountCfg = resolveAccountEntry(typed.accounts, normalizedAccountId);
|
||||
return accountCfg?.blockStreamingCoalesce ?? typed.blockStreamingCoalesce;
|
||||
return (
|
||||
resolveChannelStreamingBlockCoalesce(accountCfg) ??
|
||||
resolveChannelStreamingBlockCoalesce(typed) ??
|
||||
accountCfg?.blockStreamingCoalesce ??
|
||||
typed.blockStreamingCoalesce
|
||||
);
|
||||
}
|
||||
|
||||
export type BlockStreamingCoalescing = {
|
||||
|
||||
@@ -633,12 +633,14 @@ describe("doctor config flow", () => {
|
||||
channels: {
|
||||
discord: {
|
||||
streamMode?: string;
|
||||
streaming?: string;
|
||||
streaming?: {
|
||||
mode?: string;
|
||||
};
|
||||
lifecycle?: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(cfg.channels.discord.streaming).toBe("partial");
|
||||
expect(cfg.channels.discord.streaming?.mode).toBe("partial");
|
||||
expect(cfg.channels.discord.streamMode).toBeUndefined();
|
||||
expect(cfg.channels.discord.lifecycle).toEqual({
|
||||
enabled: true,
|
||||
@@ -680,7 +682,7 @@ describe("doctor config flow", () => {
|
||||
([message, title]) =>
|
||||
title === "Legacy config keys detected" &&
|
||||
String(message).includes("channels.telegram:") &&
|
||||
String(message).includes("channels.telegram.streamMode is legacy"),
|
||||
String(message).includes("channels.telegram.streamMode, channels.telegram.streaming"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -688,7 +690,7 @@ describe("doctor config flow", () => {
|
||||
([message, title]) =>
|
||||
title === "Legacy config keys detected" &&
|
||||
String(message).includes("channels.discord:") &&
|
||||
String(message).includes("boolean channels.discord.streaming are legacy"),
|
||||
String(message).includes("channels.discord.streamMode, channels.discord.streaming"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -704,7 +706,7 @@ describe("doctor config flow", () => {
|
||||
([message, title]) =>
|
||||
title === "Legacy config keys detected" &&
|
||||
String(message).includes("channels.slack:") &&
|
||||
String(message).includes("boolean channels.slack.streaming are legacy"),
|
||||
String(message).includes("channels.slack.streamMode, channels.slack.streaming"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
@@ -907,22 +909,11 @@ describe("doctor config flow", () => {
|
||||
const outputs = noteSpy.mock.calls
|
||||
.filter((call) => call[1] === "Doctor warnings" || call[1] === "Doctor changes")
|
||||
.map((call) => String(call[0]));
|
||||
const joinedOutputs = outputs.join("\n");
|
||||
expect(outputs.filter((line) => line.includes("\u001b"))).toEqual([]);
|
||||
expect(outputs.filter((line) => line.includes("\nforged"))).toEqual([]);
|
||||
expect(
|
||||
outputs.some(
|
||||
(line) =>
|
||||
line.includes("channels.slack.accounts.work.allowFrom: aliceforged") &&
|
||||
line.includes("mutable allowlist"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
outputs.some(
|
||||
(line) =>
|
||||
line.includes('channels.slack.accounts.opsopen.allowFrom: set to ["*"]') &&
|
||||
line.includes('required by dmPolicy="open"'),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(joinedOutputs).toContain('channels.slack.accounts.opsopen.allowFrom: set to ["*"]');
|
||||
expect(joinedOutputs).toContain('required by dmPolicy="open"');
|
||||
expect(
|
||||
outputs.some(
|
||||
(line) =>
|
||||
@@ -1651,30 +1642,15 @@ describe("doctor config flow", () => {
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
expect(
|
||||
noteSpy.mock.calls.some(
|
||||
([message, title]) =>
|
||||
title === "Legacy config keys detected" &&
|
||||
String(message).includes("session.threadBindings:") &&
|
||||
String(message).includes("session.threadBindings.idleHours"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
noteSpy.mock.calls.some(
|
||||
([message, title]) =>
|
||||
title === "Legacy config keys detected" &&
|
||||
String(message).includes("channels.discord.threadBindings:") &&
|
||||
String(message).includes("channels.discord.threadBindings.idleHours"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
noteSpy.mock.calls.some(
|
||||
([message, title]) =>
|
||||
title === "Legacy config keys detected" &&
|
||||
String(message).includes("channels.discord.accounts:") &&
|
||||
String(message).includes("channels.discord.accounts.<id>.threadBindings.idleHours"),
|
||||
),
|
||||
).toBe(true);
|
||||
const legacyMessages = noteSpy.mock.calls
|
||||
.filter(([, title]) => title === "Legacy config keys detected")
|
||||
.map(([message]) => String(message))
|
||||
.join("\n");
|
||||
|
||||
expect(legacyMessages).toContain("session.threadBindings.ttlHours");
|
||||
expect(legacyMessages).toContain("session.threadBindings.idleHours");
|
||||
expect(legacyMessages).toContain("channels.<id>.threadBindings.ttlHours");
|
||||
expect(legacyMessages).toContain("channels.<id>.threadBindings.idleHours");
|
||||
expect(
|
||||
noteSpy.mock.calls.some(
|
||||
([message, title]) =>
|
||||
|
||||
@@ -275,7 +275,7 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates Discord streaming boolean alias to streaming enum", () => {
|
||||
it("migrates Discord streaming boolean alias into nested streaming.mode", () => {
|
||||
const res = normalizeCompatibilityConfigValues(
|
||||
asLegacyConfig({
|
||||
channels: {
|
||||
@@ -291,21 +291,25 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBe("partial");
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({
|
||||
mode: "partial",
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.config.channels?.discord?.accounts?.work?.streaming).toBe("off");
|
||||
expect(res.config.channels?.discord?.accounts?.work?.streaming).toEqual({
|
||||
mode: "off",
|
||||
});
|
||||
expect(
|
||||
getLegacyProperty(res.config.channels?.discord?.accounts?.work, "streamMode"),
|
||||
).toBeUndefined();
|
||||
expect(res.changes).toContain(
|
||||
"Normalized channels.discord.streaming boolean → enum (partial).",
|
||||
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (partial).",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Normalized channels.discord.accounts.work.streaming boolean → enum (off).",
|
||||
"Moved channels.discord.accounts.work.streaming (boolean) → channels.discord.accounts.work.streaming.mode (off).",
|
||||
);
|
||||
});
|
||||
|
||||
it("migrates Discord legacy streamMode into streaming enum", () => {
|
||||
it("migrates Discord legacy streamMode into nested streaming.mode", () => {
|
||||
const res = normalizeCompatibilityConfigValues(
|
||||
asLegacyConfig({
|
||||
channels: {
|
||||
@@ -317,15 +321,17 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBe("block");
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({
|
||||
mode: "block",
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.discord.streamMode → channels.discord.streaming (block).",
|
||||
"Normalized channels.discord.streaming boolean → enum (block).",
|
||||
"Moved channels.discord.streamMode → channels.discord.streaming.mode (block).",
|
||||
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (block).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates Telegram streamMode into streaming enum", () => {
|
||||
it("migrates Telegram streamMode into nested streaming.mode", () => {
|
||||
const res = normalizeCompatibilityConfigValues(
|
||||
asLegacyConfig({
|
||||
channels: {
|
||||
@@ -336,14 +342,16 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.telegram?.streaming).toBe("block");
|
||||
expect(res.config.channels?.telegram?.streaming).toEqual({
|
||||
mode: "block",
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.telegram, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.telegram.streamMode → channels.telegram.streaming (block).",
|
||||
"Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates Slack legacy streaming keys to unified config", () => {
|
||||
it("migrates Slack legacy streaming keys into nested streaming config", () => {
|
||||
const res = normalizeCompatibilityConfigValues(
|
||||
asLegacyConfig({
|
||||
channels: {
|
||||
@@ -355,12 +363,15 @@ describe("normalizeCompatibilityConfigValues", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.slack?.streaming).toBe("progress");
|
||||
expect(res.config.channels?.slack?.nativeStreaming).toBe(false);
|
||||
expect(res.config.channels?.slack?.streaming).toEqual({
|
||||
mode: "progress",
|
||||
nativeTransport: false,
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.slack, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.slack.streamMode → channels.slack.streaming (progress).",
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.nativeStreaming (false).",
|
||||
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (progress).",
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.nativeTransport.",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function getLegacyProperty(value: unknown, key: string): unknown {
|
||||
return (value as Record<string, unknown>)[key];
|
||||
}
|
||||
describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
it("normalizes telegram boolean streaming aliases to enum", () => {
|
||||
it("normalizes telegram boolean streaming aliases into nested streaming.mode", () => {
|
||||
const res = normalizeCompatibilityConfigValues(
|
||||
asLegacyConfig({
|
||||
channels: {
|
||||
@@ -24,12 +24,16 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.telegram?.streaming).toBe("off");
|
||||
expect(res.config.channels?.telegram?.streaming).toEqual({
|
||||
mode: "off",
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.telegram, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual(["Normalized channels.telegram.streaming boolean → enum (off)."]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.telegram.streaming (boolean) → channels.telegram.streaming.mode (off).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes discord boolean streaming aliases to enum", () => {
|
||||
it("normalizes discord boolean streaming aliases into nested streaming.mode", () => {
|
||||
const res = normalizeCompatibilityConfigValues(
|
||||
asLegacyConfig({
|
||||
channels: {
|
||||
@@ -40,10 +44,12 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBe("partial");
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({
|
||||
mode: "partial",
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Normalized channels.discord.streaming boolean → enum (partial).",
|
||||
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (partial).",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -58,9 +64,13 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBe("off");
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({
|
||||
mode: "off",
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual(["Normalized channels.discord.streaming boolean → enum (off)."]);
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (off).",
|
||||
]);
|
||||
});
|
||||
|
||||
it("explains why discord preview streaming stays off when legacy config resolves to off", () => {
|
||||
@@ -74,15 +84,17 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.discord?.streaming).toBe("off");
|
||||
expect(res.config.channels?.discord?.streaming).toEqual({
|
||||
mode: "off",
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.discord, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.discord.streamMode → channels.discord.streaming (off).",
|
||||
'channels.discord.streaming remains off by default to avoid Discord preview-edit rate limits; set channels.discord.streaming="partial" to opt in explicitly.',
|
||||
"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.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes slack boolean streaming aliases to enum and native streaming", () => {
|
||||
it("normalizes slack boolean streaming aliases into nested streaming config", () => {
|
||||
const res = normalizeCompatibilityConfigValues(
|
||||
asLegacyConfig({
|
||||
channels: {
|
||||
@@ -93,11 +105,14 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.config.channels?.slack?.streaming).toBe("off");
|
||||
expect(res.config.channels?.slack?.nativeStreaming).toBe(false);
|
||||
expect(res.config.channels?.slack?.streaming).toEqual({
|
||||
mode: "off",
|
||||
nativeTransport: false,
|
||||
});
|
||||
expect(getLegacyProperty(res.config.channels?.slack, "streamMode")).toBeUndefined();
|
||||
expect(res.changes).toEqual([
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.nativeStreaming (false).",
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (off).",
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.nativeTransport.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
validateConfigObjectRawWithPlugins,
|
||||
validateConfigObjectWithPlugins,
|
||||
} from "../../../config/validation.js";
|
||||
import { migrateLegacyConfig } from "./legacy-config-migrate.js";
|
||||
import {
|
||||
applyLegacyDoctorMigrations,
|
||||
migrateLegacyConfig,
|
||||
} from "./legacy-config-migrate.js";
|
||||
|
||||
describe("legacy migrate audio transcription", () => {
|
||||
it("does not rewrite removed routing.transcribeAudio migrations", () => {
|
||||
@@ -238,27 +241,131 @@ describe("legacy migrate sandbox scope aliases", () => {
|
||||
});
|
||||
|
||||
describe("legacy migrate channel streaming aliases", () => {
|
||||
it("migrates telegram and discord streaming aliases", () => {
|
||||
it("migrates preview-channel legacy streaming fields into the nested streaming shape", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
channels: {
|
||||
telegram: {
|
||||
streamMode: "block",
|
||||
chunkMode: "newline",
|
||||
blockStreaming: true,
|
||||
draftChunk: {
|
||||
minChars: 120,
|
||||
},
|
||||
blockStreamingCoalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
streaming: false,
|
||||
chunkMode: "newline",
|
||||
blockStreaming: true,
|
||||
draftChunk: {
|
||||
maxChars: 900,
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
streamMode: "status_final",
|
||||
blockStreaming: true,
|
||||
blockStreamingCoalesce: {
|
||||
minChars: 100,
|
||||
},
|
||||
nativeStreaming: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.telegram.streamMode → channels.telegram.streaming (block).",
|
||||
"Moved channels.telegram.streamMode → channels.telegram.streaming.mode (block).",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.telegram.chunkMode → channels.telegram.streaming.chunkMode.",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.telegram.blockStreaming → channels.telegram.streaming.block.enabled.",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.telegram.draftChunk → channels.telegram.streaming.preview.chunk.",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.telegram.blockStreamingCoalesce → channels.telegram.streaming.block.coalesce.",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (off).",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.discord.draftChunk → channels.discord.streaming.preview.chunk.",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.slack.streamMode → channels.slack.streaming.mode (progress).",
|
||||
);
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.slack.nativeStreaming → channels.slack.streaming.nativeTransport.",
|
||||
);
|
||||
expect(res.changes).toContain("Normalized channels.discord.streaming boolean → enum (off).");
|
||||
expect(res.config?.channels?.telegram).toMatchObject({
|
||||
streaming: "block",
|
||||
streaming: {
|
||||
mode: "block",
|
||||
chunkMode: "newline",
|
||||
block: {
|
||||
enabled: true,
|
||||
coalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
chunk: {
|
||||
minChars: 120,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.config?.channels?.discord).toMatchObject({
|
||||
streaming: "off",
|
||||
streaming: {
|
||||
mode: "off",
|
||||
chunkMode: "newline",
|
||||
block: {
|
||||
enabled: true,
|
||||
},
|
||||
preview: {
|
||||
chunk: {
|
||||
maxChars: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.config?.channels?.slack).toMatchObject({
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
block: {
|
||||
enabled: true,
|
||||
coalesce: {
|
||||
minChars: 100,
|
||||
},
|
||||
},
|
||||
nativeTransport: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves slack streaming=false when deriving nativeTransport during migration", () => {
|
||||
const raw = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = migrateLegacyConfig(raw);
|
||||
const migrated = applyLegacyDoctorMigrations(raw);
|
||||
|
||||
expect(res.changes).toContain(
|
||||
"Moved channels.slack.streaming (boolean) → channels.slack.streaming.mode (off).",
|
||||
);
|
||||
expect((migrated.next as { channels?: { slack?: unknown } }).channels?.slack).toMatchObject({
|
||||
streaming: {
|
||||
mode: "off",
|
||||
nativeTransport: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -819,7 +926,7 @@ describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => {
|
||||
|
||||
it("does not overwrite existing allowedOrigins — returns null (no migration needed)", () => {
|
||||
// When allowedOrigins already exists, the migration is a no-op.
|
||||
// applyLegacyMigrations returns next=null when changes.length===0, so config is null.
|
||||
// applyLegacyDoctorMigrations returns next=null when changes.length===0, so config is null.
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
|
||||
@@ -146,17 +146,6 @@ function resolveSlackNativeStreaming(
|
||||
return true;
|
||||
}
|
||||
|
||||
function formatSlackStreamModeMigrationMessage(pathPrefix: string, resolvedStreaming: string) {
|
||||
return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`;
|
||||
}
|
||||
|
||||
function formatSlackStreamingBooleanMigrationMessage(
|
||||
pathPrefix: string,
|
||||
resolvedNativeStreaming: boolean,
|
||||
) {
|
||||
return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`;
|
||||
}
|
||||
|
||||
function hasLegacyThreadBindingTtl(value: unknown): boolean {
|
||||
const threadBindings = getRecord(value);
|
||||
return Boolean(threadBindings && hasOwnKey(threadBindings, "ttlHours"));
|
||||
@@ -223,7 +212,15 @@ function hasLegacyTelegramStreamingKeys(value: unknown): boolean {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return entry.streamMode !== undefined;
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
typeof entry.streaming === "string" ||
|
||||
hasOwnKey(entry, "chunkMode") ||
|
||||
hasOwnKey(entry, "blockStreaming") ||
|
||||
hasOwnKey(entry, "draftChunk") ||
|
||||
hasOwnKey(entry, "blockStreamingCoalesce")
|
||||
);
|
||||
}
|
||||
|
||||
function hasLegacyDiscordStreamingKeys(value: unknown): boolean {
|
||||
@@ -231,7 +228,15 @@ function hasLegacyDiscordStreamingKeys(value: unknown): boolean {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return entry.streamMode !== undefined || typeof entry.streaming === "boolean";
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
typeof entry.streaming === "string" ||
|
||||
hasOwnKey(entry, "chunkMode") ||
|
||||
hasOwnKey(entry, "blockStreaming") ||
|
||||
hasOwnKey(entry, "draftChunk") ||
|
||||
hasOwnKey(entry, "blockStreamingCoalesce")
|
||||
);
|
||||
}
|
||||
|
||||
function hasLegacySlackStreamingKeys(value: unknown): boolean {
|
||||
@@ -239,7 +244,177 @@ function hasLegacySlackStreamingKeys(value: unknown): boolean {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
return entry.streamMode !== undefined || typeof entry.streaming === "boolean";
|
||||
return (
|
||||
entry.streamMode !== undefined ||
|
||||
typeof entry.streaming === "boolean" ||
|
||||
typeof entry.streaming === "string" ||
|
||||
hasOwnKey(entry, "chunkMode") ||
|
||||
hasOwnKey(entry, "blockStreaming") ||
|
||||
hasOwnKey(entry, "blockStreamingCoalesce") ||
|
||||
hasOwnKey(entry, "nativeStreaming")
|
||||
);
|
||||
}
|
||||
|
||||
function ensureNestedRecord(owner: Record<string, unknown>, key: string): Record<string, unknown> {
|
||||
const existing = getRecord(owner[key]);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created: Record<string, unknown> = {};
|
||||
owner[key] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
function moveLegacyStreamingShapeForPath(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
resolveMode?: (entry: Record<string, unknown>) => string;
|
||||
resolveNativeTransport?: (entry: Record<string, unknown>) => boolean;
|
||||
}): boolean {
|
||||
let changed = false;
|
||||
const legacyStreaming = params.entry.streaming;
|
||||
const legacyStreamingInput = {
|
||||
...params.entry,
|
||||
streaming: legacyStreaming,
|
||||
};
|
||||
const legacyNativeTransportInput = {
|
||||
nativeStreaming: params.entry.nativeStreaming,
|
||||
streaming: legacyStreaming,
|
||||
};
|
||||
const hadLegacyStreamMode = hasOwnKey(params.entry, "streamMode");
|
||||
const hadLegacyStreamingScalar =
|
||||
typeof legacyStreaming === "string" || typeof legacyStreaming === "boolean";
|
||||
|
||||
if (params.resolveMode && (hadLegacyStreamMode || hadLegacyStreamingScalar)) {
|
||||
const streaming = ensureNestedRecord(params.entry, "streaming");
|
||||
if (!hasOwnKey(streaming, "mode")) {
|
||||
const resolvedMode = params.resolveMode(legacyStreamingInput);
|
||||
streaming.mode = resolvedMode;
|
||||
if (hadLegacyStreamMode) {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming.mode (${resolvedMode}).`,
|
||||
);
|
||||
}
|
||||
if (typeof legacyStreaming === "boolean") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.mode (${resolvedMode}).`,
|
||||
);
|
||||
} else if (typeof legacyStreaming === "string") {
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (scalar) → ${params.pathPrefix}.streaming.mode (${resolvedMode}).`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
params.changes.push(
|
||||
`Removed legacy ${params.pathPrefix}.streaming mode aliases (${params.pathPrefix}.streaming.mode already set).`,
|
||||
);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (hadLegacyStreamMode) {
|
||||
delete params.entry.streamMode;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (hadLegacyStreamingScalar) {
|
||||
if (!getRecord(params.entry.streaming)) {
|
||||
params.entry.streaming = {};
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (hasOwnKey(params.entry, "chunkMode")) {
|
||||
const streaming = ensureNestedRecord(params.entry, "streaming");
|
||||
if (!hasOwnKey(streaming, "chunkMode")) {
|
||||
streaming.chunkMode = params.entry.chunkMode;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.chunkMode → ${params.pathPrefix}.streaming.chunkMode.`,
|
||||
);
|
||||
} else {
|
||||
params.changes.push(
|
||||
`Removed ${params.pathPrefix}.chunkMode (${params.pathPrefix}.streaming.chunkMode already set).`,
|
||||
);
|
||||
}
|
||||
delete params.entry.chunkMode;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (hasOwnKey(params.entry, "blockStreaming")) {
|
||||
const block = ensureNestedRecord(ensureNestedRecord(params.entry, "streaming"), "block");
|
||||
if (!hasOwnKey(block, "enabled")) {
|
||||
block.enabled = params.entry.blockStreaming;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreaming → ${params.pathPrefix}.streaming.block.enabled.`,
|
||||
);
|
||||
} else {
|
||||
params.changes.push(
|
||||
`Removed ${params.pathPrefix}.blockStreaming (${params.pathPrefix}.streaming.block.enabled already set).`,
|
||||
);
|
||||
}
|
||||
delete params.entry.blockStreaming;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (hasOwnKey(params.entry, "draftChunk")) {
|
||||
const preview = ensureNestedRecord(ensureNestedRecord(params.entry, "streaming"), "preview");
|
||||
if (!hasOwnKey(preview, "chunk")) {
|
||||
preview.chunk = params.entry.draftChunk;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.draftChunk → ${params.pathPrefix}.streaming.preview.chunk.`,
|
||||
);
|
||||
} else {
|
||||
params.changes.push(
|
||||
`Removed ${params.pathPrefix}.draftChunk (${params.pathPrefix}.streaming.preview.chunk already set).`,
|
||||
);
|
||||
}
|
||||
delete params.entry.draftChunk;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (hasOwnKey(params.entry, "blockStreamingCoalesce")) {
|
||||
const block = ensureNestedRecord(ensureNestedRecord(params.entry, "streaming"), "block");
|
||||
if (!hasOwnKey(block, "coalesce")) {
|
||||
block.coalesce = params.entry.blockStreamingCoalesce;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.blockStreamingCoalesce → ${params.pathPrefix}.streaming.block.coalesce.`,
|
||||
);
|
||||
} else {
|
||||
params.changes.push(
|
||||
`Removed ${params.pathPrefix}.blockStreamingCoalesce (${params.pathPrefix}.streaming.block.coalesce already set).`,
|
||||
);
|
||||
}
|
||||
delete params.entry.blockStreamingCoalesce;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (params.resolveNativeTransport && hasOwnKey(params.entry, "nativeStreaming")) {
|
||||
const streaming = ensureNestedRecord(params.entry, "streaming");
|
||||
if (!hasOwnKey(streaming, "nativeTransport")) {
|
||||
streaming.nativeTransport = params.resolveNativeTransport(legacyNativeTransportInput);
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.nativeStreaming → ${params.pathPrefix}.streaming.nativeTransport.`,
|
||||
);
|
||||
} else {
|
||||
params.changes.push(
|
||||
`Removed ${params.pathPrefix}.nativeStreaming (${params.pathPrefix}.streaming.nativeTransport already set).`,
|
||||
);
|
||||
}
|
||||
delete params.entry.nativeStreaming;
|
||||
changed = true;
|
||||
} else if (params.resolveNativeTransport && typeof legacyStreaming === "boolean") {
|
||||
const streaming = ensureNestedRecord(params.entry, "streaming");
|
||||
if (!hasOwnKey(streaming, "nativeTransport")) {
|
||||
streaming.nativeTransport = params.resolveNativeTransport(legacyNativeTransportInput);
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.streaming.nativeTransport.`,
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
|
||||
@@ -343,37 +518,37 @@ const CHANNEL_STREAMING_RULES: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "telegram"],
|
||||
message:
|
||||
'channels.telegram.streamMode is legacy; use channels.telegram.streaming instead. Run "openclaw doctor --fix".',
|
||||
'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} instead. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyTelegramStreamingKeys(value),
|
||||
},
|
||||
{
|
||||
path: ["channels", "telegram", "accounts"],
|
||||
message:
|
||||
'channels.telegram.accounts.<id>.streamMode is legacy; use channels.telegram.accounts.<id>.streaming instead. Run "openclaw doctor --fix".',
|
||||
'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} instead. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyTelegramStreamingKeys),
|
||||
},
|
||||
{
|
||||
path: ["channels", "discord"],
|
||||
message:
|
||||
'channels.discord.streamMode and boolean channels.discord.streaming are legacy; use channels.discord.streaming with enum values instead. Run "openclaw doctor --fix".',
|
||||
'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} instead. Run "openclaw doctor --fix".',
|
||||
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. 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} instead. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyKeysInAccounts(value, hasLegacyDiscordStreamingKeys),
|
||||
},
|
||||
{
|
||||
path: ["channels", "slack"],
|
||||
message:
|
||||
'channels.slack.streamMode and boolean channels.slack.streaming are legacy; use channels.slack.streaming with enum values instead. Run "openclaw doctor --fix".',
|
||||
'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} instead. Run "openclaw doctor --fix".',
|
||||
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. 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} instead. Run "openclaw doctor --fix".',
|
||||
match: (value) => hasLegacyKeysInAccounts(value, hasLegacySlackStreamingKeys),
|
||||
},
|
||||
];
|
||||
@@ -501,60 +676,33 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
}) => {
|
||||
const migrateCommonStreamingMode = (
|
||||
resolveMode: (entry: Record<string, unknown>) => string,
|
||||
) => {
|
||||
const hasLegacyStreamMode = params.entry.streamMode !== undefined;
|
||||
const legacyStreaming = params.entry.streaming;
|
||||
if (!hasLegacyStreamMode && typeof legacyStreaming !== "boolean") {
|
||||
return false;
|
||||
}
|
||||
const resolved = resolveMode(params.entry);
|
||||
params.entry.streaming = resolved;
|
||||
if (hasLegacyStreamMode) {
|
||||
delete params.entry.streamMode;
|
||||
changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (typeof legacyStreaming === "boolean") {
|
||||
changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const hasLegacyStreamMode = params.entry.streamMode !== undefined;
|
||||
const legacyStreaming = params.entry.streaming;
|
||||
const legacyNativeStreaming = params.entry.nativeStreaming;
|
||||
|
||||
if (params.provider === "telegram") {
|
||||
migrateCommonStreamingMode(resolveTelegramPreviewStreamMode);
|
||||
moveLegacyStreamingShapeForPath({
|
||||
entry: params.entry,
|
||||
pathPrefix: params.pathPrefix,
|
||||
changes,
|
||||
resolveMode: resolveTelegramPreviewStreamMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.provider === "discord") {
|
||||
migrateCommonStreamingMode(resolveDiscordPreviewStreamMode);
|
||||
moveLegacyStreamingShapeForPath({
|
||||
entry: params.entry,
|
||||
pathPrefix: params.pathPrefix,
|
||||
changes,
|
||||
resolveMode: resolveDiscordPreviewStreamMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasLegacyStreamMode && typeof legacyStreaming !== "boolean") {
|
||||
return;
|
||||
}
|
||||
const resolvedStreaming = resolveSlackStreamingMode(params.entry);
|
||||
const resolvedNativeStreaming = resolveSlackNativeStreaming(params.entry);
|
||||
params.entry.streaming = resolvedStreaming;
|
||||
params.entry.nativeStreaming = resolvedNativeStreaming;
|
||||
if (hasLegacyStreamMode) {
|
||||
delete params.entry.streamMode;
|
||||
changes.push(formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming));
|
||||
}
|
||||
if (typeof legacyStreaming === "boolean") {
|
||||
changes.push(
|
||||
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
|
||||
);
|
||||
} else if (typeof legacyNativeStreaming !== "boolean" && hasLegacyStreamMode) {
|
||||
changes.push(`Set ${params.pathPrefix}.nativeStreaming → ${resolvedNativeStreaming}.`);
|
||||
}
|
||||
moveLegacyStreamingShapeForPath({
|
||||
entry: params.entry,
|
||||
pathPrefix: params.pathPrefix,
|
||||
changes,
|
||||
resolveMode: resolveSlackStreamingMode,
|
||||
resolveNativeTransport: resolveSlackNativeStreaming,
|
||||
});
|
||||
};
|
||||
|
||||
const migrateProvider = (provider: "telegram" | "discord" | "slack") => {
|
||||
|
||||
@@ -767,66 +767,84 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
blockStreaming: {
|
||||
type: "boolean",
|
||||
},
|
||||
blockStreamingCoalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
streaming: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
draftChunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
preview: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
block: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
coalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -1913,66 +1931,84 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
blockStreaming: {
|
||||
type: "boolean",
|
||||
},
|
||||
blockStreamingCoalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
streaming: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
draftChunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
preview: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
block: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
coalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -2919,15 +2955,31 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
label: "Discord Streaming Mode",
|
||||
help: 'Unified Discord stream preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord. Legacy boolean/streamMode keys are auto-mapped.',
|
||||
},
|
||||
"draftChunk.minChars": {
|
||||
"streaming.mode": {
|
||||
label: "Discord Streaming Mode",
|
||||
help: 'Canonical Discord preview mode: "off" | "partial" | "block" | "progress". "progress" maps to "partial" on Discord.',
|
||||
},
|
||||
"streaming.chunkMode": {
|
||||
label: "Discord Chunk Mode",
|
||||
help: 'Chunking mode for outbound Discord text delivery: "length" (default) or "newline".',
|
||||
},
|
||||
"streaming.block.enabled": {
|
||||
label: "Discord Block Streaming Enabled",
|
||||
help: 'Enable chunked block-style Discord preview delivery when channels.discord.streaming.mode="block".',
|
||||
},
|
||||
"streaming.block.coalesce": {
|
||||
label: "Discord Block Streaming Coalesce",
|
||||
help: "Merge streamed Discord block replies before final delivery.",
|
||||
},
|
||||
"streaming.preview.chunk.minChars": {
|
||||
label: "Discord Draft Chunk Min Chars",
|
||||
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming="block" (default: 200).',
|
||||
help: 'Minimum chars before emitting a Discord stream preview update when channels.discord.streaming.mode="block" (default: 200).',
|
||||
},
|
||||
"draftChunk.maxChars": {
|
||||
"streaming.preview.chunk.maxChars": {
|
||||
label: "Discord Draft Chunk Max Chars",
|
||||
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming="block" (default: 800; clamped to channels.discord.textChunkLimit).',
|
||||
help: 'Target max size for a Discord stream preview chunk when channels.discord.streaming.mode="block" (default: 800; clamped to channels.discord.textChunkLimit).',
|
||||
},
|
||||
"draftChunk.breakPreference": {
|
||||
"streaming.preview.chunk.breakPreference": {
|
||||
label: "Discord Draft Chunk Break Preference",
|
||||
help: "Preferred breakpoints for Discord draft chunks (paragraph | newline | sentence). Default: paragraph.",
|
||||
},
|
||||
@@ -10581,41 +10633,91 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
blockStreaming: {
|
||||
type: "boolean",
|
||||
},
|
||||
blockStreamingCoalesce: {
|
||||
streaming: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
preview: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
block: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
coalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
nativeTransport: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
streaming: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
nativeStreaming: {
|
||||
type: "boolean",
|
||||
},
|
||||
mediaMaxMb: {
|
||||
type: "number",
|
||||
exclusiveMinimum: 0,
|
||||
@@ -11437,41 +11539,91 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
blockStreaming: {
|
||||
type: "boolean",
|
||||
},
|
||||
blockStreamingCoalesce: {
|
||||
streaming: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
preview: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
block: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
coalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
nativeTransport: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
streaming: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
nativeStreaming: {
|
||||
type: "boolean",
|
||||
},
|
||||
mediaMaxMb: {
|
||||
type: "number",
|
||||
exclusiveMinimum: 0,
|
||||
@@ -11946,9 +12098,25 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
label: "Slack Streaming Mode",
|
||||
help: 'Unified Slack stream preview mode: "off" | "partial" | "block" | "progress". Legacy boolean/streamMode keys are auto-mapped.',
|
||||
},
|
||||
nativeStreaming: {
|
||||
"streaming.mode": {
|
||||
label: "Slack Streaming Mode",
|
||||
help: 'Canonical Slack preview mode: "off" | "partial" | "block" | "progress".',
|
||||
},
|
||||
"streaming.chunkMode": {
|
||||
label: "Slack Chunk Mode",
|
||||
help: 'Chunking mode for outbound Slack text delivery: "length" (default) or "newline".',
|
||||
},
|
||||
"streaming.block.enabled": {
|
||||
label: "Slack Block Streaming Enabled",
|
||||
help: 'Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode="block".',
|
||||
},
|
||||
"streaming.block.coalesce": {
|
||||
label: "Slack Block Streaming Coalesce",
|
||||
help: "Merge streamed Slack block replies before final delivery.",
|
||||
},
|
||||
"streaming.nativeTransport": {
|
||||
label: "Slack Native Streaming",
|
||||
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming is partial (default: true).",
|
||||
help: "Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true).",
|
||||
},
|
||||
"thread.historyScope": {
|
||||
label: "Slack Thread History Scope",
|
||||
@@ -12647,66 +12815,84 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
streaming: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
blockStreaming: {
|
||||
type: "boolean",
|
||||
},
|
||||
draftChunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
preview: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
block: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
coalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
blockStreamingCoalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -13662,66 +13848,84 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
streaming: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
blockStreaming: {
|
||||
type: "boolean",
|
||||
},
|
||||
draftChunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
mode: {
|
||||
type: "string",
|
||||
enum: ["off", "partial", "block", "progress"],
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
chunkMode: {
|
||||
type: "string",
|
||||
enum: ["length", "newline"],
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
preview: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chunk: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
breakPreference: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
const: "paragraph",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "newline",
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
block: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
},
|
||||
{
|
||||
type: "string",
|
||||
const: "sentence",
|
||||
coalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
blockStreamingCoalesce: {
|
||||
type: "object",
|
||||
properties: {
|
||||
minChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
maxChars: {
|
||||
type: "integer",
|
||||
exclusiveMinimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
idleMs: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
maximum: 9007199254740991,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -14059,6 +14263,34 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
||||
label: "Telegram Streaming Mode",
|
||||
help: 'Unified Telegram stream preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram. Legacy boolean/streamMode keys are auto-mapped.',
|
||||
},
|
||||
"streaming.mode": {
|
||||
label: "Telegram Streaming Mode",
|
||||
help: 'Canonical Telegram preview mode: "off" | "partial" | "block" | "progress" (default: "partial"). "progress" maps to "partial" on Telegram.',
|
||||
},
|
||||
"streaming.chunkMode": {
|
||||
label: "Telegram Chunk Mode",
|
||||
help: 'Chunking mode for outbound Telegram text delivery: "length" (default) or "newline".',
|
||||
},
|
||||
"streaming.block.enabled": {
|
||||
label: "Telegram Block Streaming Enabled",
|
||||
help: 'Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode="block".',
|
||||
},
|
||||
"streaming.block.coalesce": {
|
||||
label: "Telegram Block Streaming Coalesce",
|
||||
help: "Merge streamed Telegram block replies before sending final delivery.",
|
||||
},
|
||||
"streaming.preview.chunk.minChars": {
|
||||
label: "Telegram Draft Chunk Min Chars",
|
||||
help: 'Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode="block".',
|
||||
},
|
||||
"streaming.preview.chunk.maxChars": {
|
||||
label: "Telegram Draft Chunk Max Chars",
|
||||
help: 'Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode="block".',
|
||||
},
|
||||
"streaming.preview.chunk.breakPreference": {
|
||||
label: "Telegram Draft Chunk Break Preference",
|
||||
help: "Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence).",
|
||||
},
|
||||
"retry.attempts": {
|
||||
label: "Telegram Retry Attempts",
|
||||
help: "Max retry attempts for outbound Telegram API calls (default: 3).",
|
||||
|
||||
@@ -425,3 +425,672 @@ describe("config paths", () => {
|
||||
expect(getConfigValueAtPath(root, parsed.path)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("config strict validation", () => {
|
||||
it("rejects unknown fields", async () => {
|
||||
const res = validateConfigObject({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
customUnknownField: { nested: "value" },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts documented agents.list[].params overrides", () => {
|
||||
const res = validateConfigObject({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
params: {
|
||||
cacheRetention: "none",
|
||||
temperature: 0.4,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.config.agents?.list?.[0]?.params).toEqual({
|
||||
cacheRetention: "none",
|
||||
temperature: 0.4,
|
||||
maxTokens: 8192,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts top-level memorySearch via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
memorySearch: {
|
||||
provider: "local",
|
||||
fallback: "none",
|
||||
query: { maxResults: 7 },
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "memorySearch")).toBe(true);
|
||||
expect(snap.sourceConfig.agents?.defaults?.memorySearch).toMatchObject({
|
||||
provider: "local",
|
||||
fallback: "none",
|
||||
query: { maxResults: 7 },
|
||||
});
|
||||
expect((snap.sourceConfig as { memorySearch?: unknown }).memorySearch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts top-level heartbeat agent settings via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
heartbeat: {
|
||||
every: "30m",
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
|
||||
expect(snap.sourceConfig.agents?.defaults?.heartbeat).toMatchObject({
|
||||
every: "30m",
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
});
|
||||
expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts top-level heartbeat visibility via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
heartbeat: {
|
||||
showOk: true,
|
||||
showAlerts: false,
|
||||
useIndicator: true,
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
|
||||
expect(snap.sourceConfig.channels?.defaults?.heartbeat).toMatchObject({
|
||||
showOk: true,
|
||||
showAlerts: false,
|
||||
useIndicator: true,
|
||||
});
|
||||
expect((snap.sourceConfig as { heartbeat?: unknown }).heartbeat).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy messages.tts provider keys via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
messages: {
|
||||
tts: {
|
||||
provider: "elevenlabs",
|
||||
elevenlabs: {
|
||||
apiKey: "test-key",
|
||||
voiceId: "voice-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "messages.tts")).toBe(true);
|
||||
expect(snap.sourceConfig.messages?.tts?.providers?.elevenlabs).toEqual({
|
||||
apiKey: "test-key",
|
||||
voiceId: "voice-1",
|
||||
});
|
||||
expect(
|
||||
(snap.sourceConfig.messages?.tts as Record<string, unknown> | undefined)?.elevenlabs,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy talk flat fields via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
talk: {
|
||||
voiceId: "voice-1",
|
||||
modelId: "eleven_v3",
|
||||
apiKey: "test-key",
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "talk")).toBe(true);
|
||||
expect(snap.sourceConfig.talk?.providers?.elevenlabs).toEqual({
|
||||
voiceId: "voice-1",
|
||||
modelId: "eleven_v3",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
expect(
|
||||
(snap.sourceConfig.talk as Record<string, unknown> | undefined)?.voiceId,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(snap.sourceConfig.talk as Record<string, unknown> | undefined)?.modelId,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(snap.sourceConfig.talk as Record<string, unknown> | undefined)?.apiKey,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy sandbox perSession via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
perSession: true,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "pi",
|
||||
sandbox: {
|
||||
perSession: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "agents.defaults.sandbox")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "agents.list")).toBe(true);
|
||||
expect(snap.sourceConfig.agents?.defaults?.sandbox).toEqual({
|
||||
scope: "session",
|
||||
});
|
||||
expect(snap.sourceConfig.agents?.list?.[0]?.sandbox).toEqual({
|
||||
scope: "shared",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy x_search auth via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
tools: {
|
||||
web: {
|
||||
x_search: {
|
||||
apiKey: "test-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "tools.web.x_search.apiKey")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(snap.sourceConfig.plugins?.entries?.xai?.config?.webSearch).toMatchObject({
|
||||
apiKey: "test-key",
|
||||
});
|
||||
expect(
|
||||
(snap.sourceConfig.tools?.web?.x_search as Record<string, unknown> | undefined)?.apiKey,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy thread binding ttlHours via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
session: {
|
||||
threadBindings: {
|
||||
ttlHours: 24,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
threadBindings: {
|
||||
ttlHours: 12,
|
||||
},
|
||||
accounts: {
|
||||
alpha: {
|
||||
threadBindings: {
|
||||
ttlHours: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "session.threadBindings")).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "channels")).toBe(true);
|
||||
expect(snap.sourceConfig.session?.threadBindings).toMatchObject({
|
||||
idleHours: 24,
|
||||
});
|
||||
expect(snap.sourceConfig.channels?.discord?.threadBindings).toMatchObject({
|
||||
idleHours: 12,
|
||||
});
|
||||
expect(snap.sourceConfig.channels?.discord?.accounts?.alpha?.threadBindings).toMatchObject({
|
||||
idleHours: 6,
|
||||
});
|
||||
expect(
|
||||
(snap.sourceConfig.session?.threadBindings as Record<string, unknown> | undefined)
|
||||
?.ttlHours,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(snap.sourceConfig.channels?.discord?.threadBindings as Record<string, unknown> | undefined)
|
||||
?.ttlHours,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
snap.sourceConfig.channels?.discord?.accounts?.alpha?.threadBindings as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.ttlHours,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy channel streaming aliases via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
channels: {
|
||||
telegram: {
|
||||
streamMode: "block",
|
||||
chunkMode: "newline",
|
||||
blockStreaming: true,
|
||||
draftChunk: {
|
||||
minChars: 120,
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
streaming: false,
|
||||
blockStreamingCoalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
streamMode: "block",
|
||||
draftChunk: {
|
||||
maxChars: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
googlechat: {
|
||||
streamMode: "append",
|
||||
accounts: {
|
||||
work: {
|
||||
streamMode: "replace",
|
||||
},
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
streaming: true,
|
||||
nativeStreaming: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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.googlechat")).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat.accounts")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack")).toBe(true);
|
||||
expect(snap.sourceConfig.channels?.telegram).toMatchObject({
|
||||
streaming: {
|
||||
mode: "block",
|
||||
chunkMode: "newline",
|
||||
block: {
|
||||
enabled: true,
|
||||
},
|
||||
preview: {
|
||||
chunk: {
|
||||
minChars: 120,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(snap.sourceConfig.channels?.telegram as Record<string, unknown> | undefined)?.streamMode,
|
||||
).toBeUndefined();
|
||||
expect(snap.sourceConfig.channels?.discord).toMatchObject({
|
||||
streaming: {
|
||||
mode: "off",
|
||||
block: {
|
||||
coalesce: {
|
||||
idleMs: 250,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(snap.sourceConfig.channels?.discord?.accounts?.work).toMatchObject({
|
||||
streaming: {
|
||||
mode: "block",
|
||||
preview: {
|
||||
chunk: {
|
||||
maxChars: 900,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(snap.sourceConfig.channels?.googlechat as Record<string, unknown> | undefined)?.streamMode,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
snap.sourceConfig.channels?.googlechat?.accounts?.work as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.streamMode,
|
||||
).toBeUndefined();
|
||||
expect(snap.sourceConfig.channels?.slack).toMatchObject({
|
||||
streaming: {
|
||||
mode: "partial",
|
||||
nativeTransport: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy nested channel allow aliases via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
channels: {
|
||||
slack: {
|
||||
channels: {
|
||||
ops: {
|
||||
allow: false,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
channels: {
|
||||
general: {
|
||||
allow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
googlechat: {
|
||||
groups: {
|
||||
"spaces/aaa": {
|
||||
allow: false,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
groups: {
|
||||
"spaces/bbb": {
|
||||
allow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
guilds: {
|
||||
"100": {
|
||||
channels: {
|
||||
general: {
|
||||
allow: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
guilds: {
|
||||
"200": {
|
||||
channels: {
|
||||
help: {
|
||||
allow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack")).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "channels.slack.accounts")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat")).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "channels.googlechat.accounts")).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.sourceConfig.channels?.slack?.channels?.ops).toMatchObject({
|
||||
enabled: false,
|
||||
});
|
||||
expect(snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"]).toMatchObject({
|
||||
enabled: false,
|
||||
});
|
||||
expect(snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general).toMatchObject(
|
||||
{
|
||||
enabled: false,
|
||||
},
|
||||
);
|
||||
expect(
|
||||
(snap.sourceConfig.channels?.slack?.channels?.ops as Record<string, unknown> | undefined)
|
||||
?.allow,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
snap.sourceConfig.channels?.googlechat?.groups?.["spaces/aaa"] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.allow,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
snap.sourceConfig.channels?.discord?.guilds?.["100"]?.channels?.general as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.allow,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts telegram groupMentionsOnly via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupMentionsOnly: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(
|
||||
snap.legacyIssues.some((issue) => issue.path === "channels.telegram.groupMentionsOnly"),
|
||||
).toBe(true);
|
||||
expect(snap.sourceConfig.channels?.telegram?.groups?.["*"]).toMatchObject({
|
||||
requireMention: true,
|
||||
});
|
||||
expect(
|
||||
(snap.sourceConfig.channels?.telegram as Record<string, unknown> | undefined)
|
||||
?.groupMentionsOnly,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy plugins.entries.*.config.tts provider keys via auto-migration", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": {
|
||||
config: {
|
||||
tts: {
|
||||
provider: "openai",
|
||||
openai: {
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "plugins.entries")).toBe(true);
|
||||
const voiceCallTts = (
|
||||
snap.sourceConfig.plugins?.entries as
|
||||
| Record<
|
||||
string,
|
||||
{
|
||||
config?: {
|
||||
tts?: {
|
||||
providers?: Record<string, unknown>;
|
||||
openai?: unknown;
|
||||
};
|
||||
};
|
||||
}
|
||||
>
|
||||
| undefined
|
||||
)?.["voice-call"]?.config?.tts;
|
||||
expect(voiceCallTts?.providers?.openai).toEqual({
|
||||
model: "gpt-4o-mini-tts",
|
||||
voice: "alloy",
|
||||
});
|
||||
expect(voiceCallTts?.openai).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy discord voice tts provider keys via auto-migration and reports legacyIssues", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
channels: {
|
||||
discord: {
|
||||
voice: {
|
||||
tts: {
|
||||
provider: "elevenlabs",
|
||||
elevenlabs: {
|
||||
voiceId: "voice-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
main: {
|
||||
voice: {
|
||||
tts: {
|
||||
edge: {
|
||||
voice: "en-US-AvaNeural",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.voice.tts")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "channels.discord.accounts")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(snap.sourceConfig.channels?.discord?.voice?.tts?.providers?.elevenlabs).toEqual({
|
||||
voiceId: "voice-1",
|
||||
});
|
||||
expect(
|
||||
snap.sourceConfig.channels?.discord?.accounts?.main?.voice?.tts?.providers?.microsoft,
|
||||
).toEqual({
|
||||
voice: "en-US-AvaNeural",
|
||||
});
|
||||
expect(
|
||||
(snap.sourceConfig.channels?.discord?.voice?.tts as Record<string, unknown> | undefined)
|
||||
?.elevenlabs,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
(
|
||||
snap.sourceConfig.channels?.discord?.accounts?.main?.voice?.tts as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.edge,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat resolved-only gateway.bind aliases as source-literal legacy or invalid", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
gateway: { bind: "${OPENCLAW_BIND}" },
|
||||
});
|
||||
|
||||
const prev = process.env.OPENCLAW_BIND;
|
||||
process.env.OPENCLAW_BIND = "0.0.0.0";
|
||||
try {
|
||||
const snap = await readConfigFileSnapshot();
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues).toHaveLength(0);
|
||||
expect(snap.issues).toHaveLength(0);
|
||||
} finally {
|
||||
if (prev === undefined) {
|
||||
delete process.env.OPENCLAW_BIND;
|
||||
} else {
|
||||
process.env.OPENCLAW_BIND = prev;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("still marks literal gateway.bind host aliases as legacy", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await writeOpenClawConfig(home, {
|
||||
gateway: { bind: "0.0.0.0" },
|
||||
});
|
||||
|
||||
const snap = await readConfigFileSnapshot();
|
||||
expect(snap.valid).toBe(true);
|
||||
expect(snap.legacyIssues.some((issue) => issue.path === "gateway.bind")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,6 +169,89 @@ describe("legacy config detection", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
{
|
||||
name: "top-level off",
|
||||
input: { channels: { telegram: { streamMode: "off" } } },
|
||||
assert: (config: NonNullable<OpenClawConfig>) => {
|
||||
expect(config.channels?.telegram?.streaming?.mode).toBe("off");
|
||||
expect(
|
||||
(config.channels?.telegram as Record<string, unknown> | undefined)?.streamMode,
|
||||
).toBeUndefined();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "top-level block",
|
||||
input: { channels: { telegram: { streamMode: "block" } } },
|
||||
assert: (config: NonNullable<OpenClawConfig>) => {
|
||||
expect(config.channels?.telegram?.streaming?.mode).toBe("block");
|
||||
expect(
|
||||
(config.channels?.telegram as Record<string, unknown> | undefined)?.streamMode,
|
||||
).toBeUndefined();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "per-account off",
|
||||
input: {
|
||||
channels: {
|
||||
telegram: {
|
||||
accounts: {
|
||||
ops: {
|
||||
streamMode: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: (config: NonNullable<OpenClawConfig>) => {
|
||||
expect(config.channels?.telegram?.accounts?.ops?.streaming?.mode).toBe("off");
|
||||
expect(
|
||||
(config.channels?.telegram?.accounts?.ops as Record<string, unknown> | undefined)
|
||||
?.streamMode,
|
||||
).toBeUndefined();
|
||||
},
|
||||
},
|
||||
] as const)(
|
||||
"normalizes telegram legacy streamMode alias during migration: $name",
|
||||
({ input, assert, name }) => {
|
||||
const res = migrateLegacyConfig(input);
|
||||
expect(res.config, name).not.toBeNull();
|
||||
if (res.config) {
|
||||
assert(res.config);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "boolean streaming=true",
|
||||
input: { channels: { discord: { streaming: true } } },
|
||||
expectedChanges: [
|
||||
"Moved channels.discord.streaming (boolean) → channels.discord.streaming.mode (partial).",
|
||||
],
|
||||
expectedStreaming: "partial",
|
||||
},
|
||||
{
|
||||
name: "streamMode with streaming boolean",
|
||||
input: { channels: { discord: { streaming: false, streamMode: "block" } } },
|
||||
expectedChanges: ["Moved channels.discord.streamMode → channels.discord.streaming.mode (block)."],
|
||||
expectedStreaming: "block",
|
||||
},
|
||||
] as const)(
|
||||
"normalizes discord streaming fields during legacy migration: $name",
|
||||
({ input, expectedChanges, expectedStreaming, name }) => {
|
||||
const res = migrateLegacyConfig(input);
|
||||
for (const expectedChange of expectedChanges) {
|
||||
expect(res.changes, name).toContain(expectedChange);
|
||||
}
|
||||
expect(res.config?.channels?.discord?.streaming?.mode, name).toBe(expectedStreaming);
|
||||
expect(
|
||||
(res.config?.channels?.discord as Record<string, unknown> | undefined)?.streamMode,
|
||||
name,
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "streaming=true",
|
||||
@@ -193,11 +276,76 @@ describe("legacy config detection", () => {
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path, name).toBe("channels.discord");
|
||||
expect(res.issues[0]?.message, name).toContain(
|
||||
"channels.discord.streamMode and boolean channels.discord.streaming are legacy",
|
||||
"channels.discord.streamMode, channels.discord.streaming (scalar), chunkMode, blockStreaming, draftChunk, and blockStreamingCoalesce are legacy",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "discord account streaming boolean",
|
||||
input: {
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
work: {
|
||||
streaming: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: (config: NonNullable<OpenClawConfig>) => {
|
||||
expect(config.channels?.discord?.accounts?.work?.streaming?.mode).toBe("partial");
|
||||
expect(
|
||||
(config.channels?.discord?.accounts?.work as Record<string, unknown> | undefined)
|
||||
?.streamMode,
|
||||
).toBeUndefined();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "slack streamMode alias",
|
||||
input: {
|
||||
channels: {
|
||||
slack: {
|
||||
streamMode: "status_final",
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: (config: NonNullable<OpenClawConfig>) => {
|
||||
expect(config.channels?.slack?.streaming?.mode).toBe("progress");
|
||||
expect(
|
||||
(config.channels?.slack as Record<string, unknown> | undefined)?.streamMode,
|
||||
).toBeUndefined();
|
||||
expect(config.channels?.slack?.streaming?.nativeTransport).toBe(true);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "slack streaming boolean legacy",
|
||||
input: {
|
||||
channels: {
|
||||
slack: {
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: (config: NonNullable<OpenClawConfig>) => {
|
||||
expect(config.channels?.slack?.streaming?.mode).toBe("off");
|
||||
expect(config.channels?.slack?.streaming?.nativeTransport).toBe(false);
|
||||
},
|
||||
},
|
||||
] as const)(
|
||||
"normalizes account-level discord/slack streaming alias during migration: $name",
|
||||
({ input, assert, name }) => {
|
||||
const res = migrateLegacyConfig(input);
|
||||
expect(res.config, name).not.toBeNull();
|
||||
if (res.config) {
|
||||
assert(res.config);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts historyLimit overrides per provider and account", async () => {
|
||||
const res = validateConfigObject({
|
||||
messages: { groupChat: { historyLimit: 12 } },
|
||||
|
||||
@@ -8,6 +8,8 @@ export type ReplyToMode = "off" | "first" | "all" | "batched";
|
||||
export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
|
||||
export type ContextVisibilityMode = "all" | "allowlist" | "allowlist_quote";
|
||||
export type TextChunkMode = "length" | "newline";
|
||||
export type StreamingMode = "off" | "partial" | "block" | "progress";
|
||||
|
||||
export type OutboundRetryConfig = {
|
||||
/** Max retry attempts for outbound requests (default: 3). */
|
||||
@@ -32,6 +34,50 @@ export type BlockStreamingChunkConfig = {
|
||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||
};
|
||||
|
||||
export type ChannelStreamingPreviewConfig = {
|
||||
/** Chunking thresholds for preview-draft updates while streaming. */
|
||||
chunk?: BlockStreamingChunkConfig;
|
||||
};
|
||||
|
||||
export type ChannelStreamingBlockConfig = {
|
||||
/** Enable chunked block-reply delivery for channels that support it. */
|
||||
enabled?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
coalesce?: BlockStreamingCoalesceConfig;
|
||||
};
|
||||
|
||||
export type ChannelStreamingConfig = {
|
||||
/**
|
||||
* Preview streaming mode:
|
||||
* - "off": disable preview updates
|
||||
* - "partial": update one preview in place
|
||||
* - "block": emit larger chunked preview updates
|
||||
* - "progress": progress/status preview mode for channels that support it
|
||||
*/
|
||||
mode?: StreamingMode;
|
||||
/** Chunking mode for outbound text delivery. */
|
||||
chunkMode?: TextChunkMode;
|
||||
/**
|
||||
* Channel-specific native transport streaming toggle.
|
||||
* Used today by Slack's native stream API.
|
||||
*/
|
||||
nativeTransport?: boolean;
|
||||
preview?: ChannelStreamingPreviewConfig;
|
||||
block?: ChannelStreamingBlockConfig;
|
||||
};
|
||||
|
||||
export type ChannelDeliveryStreamingConfig = Pick<ChannelStreamingConfig, "chunkMode" | "block">;
|
||||
|
||||
export type ChannelPreviewStreamingConfig = Pick<
|
||||
ChannelStreamingConfig,
|
||||
"mode" | "chunkMode" | "preview" | "block"
|
||||
>;
|
||||
|
||||
export type SlackChannelStreamingConfig = Pick<
|
||||
ChannelStreamingConfig,
|
||||
"mode" | "chunkMode" | "preview" | "block" | "nativeTransport"
|
||||
>;
|
||||
|
||||
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";
|
||||
|
||||
export type MarkdownConfig = {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
BlockStreamingChunkConfig,
|
||||
BlockStreamingCoalesceConfig,
|
||||
ChannelPreviewStreamingConfig,
|
||||
ContextVisibilityMode,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
@@ -253,22 +252,8 @@ export type DiscordAccountConfig = {
|
||||
contextVisibility?: ContextVisibilityMode;
|
||||
/** Outbound text chunk size (chars). Default: 2000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/**
|
||||
* Live stream preview mode:
|
||||
* - "off": disable preview updates
|
||||
* - "partial": edit a single preview message
|
||||
* - "block": stream in chunked preview updates
|
||||
* - "progress": alias that maps to "partial" on Discord
|
||||
*/
|
||||
streaming?: DiscordStreamMode;
|
||||
/** Chunking config for Discord stream previews in `streaming: "block"`. */
|
||||
draftChunk?: BlockStreamingChunkConfig;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */
|
||||
streaming?: ChannelPreviewStreamingConfig;
|
||||
/**
|
||||
* Soft max line count per Discord message.
|
||||
* Discord clients can clip/collapse very tall messages; splitting by lines
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
ContextVisibilityMode,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
MarkdownConfig,
|
||||
ReplyToMode,
|
||||
SlackChannelStreamingConfig,
|
||||
} from "./types.base.js";
|
||||
import type {
|
||||
ChannelHealthMonitorConfig,
|
||||
@@ -149,24 +149,8 @@ export type SlackAccountConfig = {
|
||||
/** Per-DM config overrides keyed by user ID. */
|
||||
dms?: Record<string, DmConfig>;
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/**
|
||||
* Stream preview mode:
|
||||
* - "off": disable live preview streaming
|
||||
* - "partial": replace preview text with the latest partial output (default)
|
||||
* - "block": append chunked preview updates
|
||||
* - "progress": show progress status, then send final text
|
||||
*/
|
||||
streaming?: SlackStreamingMode;
|
||||
/**
|
||||
* Slack native text streaming toggle (`chat.startStream` / `chat.appendStream` / `chat.stopStream`).
|
||||
* Used when `streaming` is `partial`. Default: true.
|
||||
*/
|
||||
nativeStreaming?: boolean;
|
||||
/** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */
|
||||
streaming?: SlackChannelStreamingConfig;
|
||||
mediaMaxMb?: number;
|
||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||
reactionNotifications?: SlackReactionNotificationMode;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
BlockStreamingChunkConfig,
|
||||
BlockStreamingCoalesceConfig,
|
||||
ChannelPreviewStreamingConfig,
|
||||
ContextVisibilityMode,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
@@ -148,22 +147,8 @@ export type TelegramAccountConfig = {
|
||||
dms?: Record<string, DmConfig>;
|
||||
/** Outbound text chunk size (chars). Default: 4000. */
|
||||
textChunkLimit?: number;
|
||||
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
|
||||
chunkMode?: "length" | "newline";
|
||||
/**
|
||||
* Stream preview mode:
|
||||
* - "off": disable preview updates
|
||||
* - "partial": edit a single preview message
|
||||
* - "block": stream in larger chunked updates
|
||||
* - "progress": alias that maps to "partial" on Telegram
|
||||
*/
|
||||
streaming?: TelegramStreamingMode;
|
||||
/** Disable block streaming for this account. */
|
||||
blockStreaming?: boolean;
|
||||
/** Draft block-stream chunking thresholds for Telegram preview edits. */
|
||||
draftChunk?: BlockStreamingChunkConfig;
|
||||
/** Merge streamed block replies before sending. */
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
/** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */
|
||||
streaming?: ChannelPreviewStreamingConfig;
|
||||
mediaMaxMb?: number;
|
||||
/** Telegram API client timeout in seconds (grammY ApiClientOptions). */
|
||||
timeoutSeconds?: number;
|
||||
|
||||
@@ -70,6 +70,30 @@ const TelegramCapabilitiesSchema = z.union([
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
const TextChunkModeSchema = z.enum(["length", "newline"]);
|
||||
const UnifiedStreamingModeSchema = z.enum(["off", "partial", "block", "progress"]);
|
||||
const ChannelStreamingBlockSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
coalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
const ChannelStreamingPreviewSchema = z
|
||||
.object({
|
||||
chunk: BlockStreamingChunkSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
const ChannelPreviewStreamingConfigSchema = z
|
||||
.object({
|
||||
mode: UnifiedStreamingModeSchema.optional(),
|
||||
chunkMode: TextChunkModeSchema.optional(),
|
||||
preview: ChannelStreamingPreviewSchema.optional(),
|
||||
block: ChannelStreamingBlockSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
const SlackStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
|
||||
nativeTransport: z.boolean().optional(),
|
||||
}).strict();
|
||||
const SlackCapabilitiesSchema = z.union([
|
||||
z.array(z.string()),
|
||||
z
|
||||
@@ -205,11 +229,7 @@ export const TelegramAccountSchemaBase = z
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
streaming: z.enum(["off", "partial", "block", "progress"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
draftChunk: BlockStreamingChunkSchema.optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
streaming: ChannelPreviewStreamingConfigSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
timeoutSeconds: z.number().int().positive().optional(),
|
||||
retry: RetryConfigSchema,
|
||||
@@ -499,11 +519,7 @@ export const DiscordAccountSchema = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
streaming: z.enum(["off", "partial", "block", "progress"]).optional(),
|
||||
draftChunk: BlockStreamingChunkSchema.optional(),
|
||||
streaming: ChannelPreviewStreamingConfigSchema.optional(),
|
||||
maxLinesPerMessage: z.number().int().positive().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
retry: RetryConfigSchema,
|
||||
@@ -894,11 +910,7 @@ export const SlackAccountSchema = z
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
streaming: z.enum(["off", "partial", "block", "progress"]).optional(),
|
||||
nativeStreaming: z.boolean().optional(),
|
||||
streaming: SlackStreamingConfigSchema.optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
reactionNotifications: z.enum(["off", "own", "all", "allowlist"]).optional(),
|
||||
reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
|
||||
72
src/plugin-sdk/channel-streaming.test.ts
Normal file
72
src/plugin-sdk/channel-streaming.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getChannelStreamingConfigObject,
|
||||
resolveChannelStreamingBlockCoalesce,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingChunkMode,
|
||||
resolveChannelStreamingNativeTransport,
|
||||
resolveChannelStreamingPreviewChunk,
|
||||
} from "./channel-streaming.js";
|
||||
|
||||
describe("channel-streaming", () => {
|
||||
it("reads canonical nested streaming config first", () => {
|
||||
const entry = {
|
||||
streaming: {
|
||||
chunkMode: "newline",
|
||||
nativeTransport: true,
|
||||
block: {
|
||||
enabled: true,
|
||||
coalesce: { minChars: 40, maxChars: 80, idleMs: 250 },
|
||||
},
|
||||
preview: {
|
||||
chunk: { minChars: 10, maxChars: 20, breakPreference: "sentence" },
|
||||
},
|
||||
},
|
||||
chunkMode: "length",
|
||||
blockStreaming: false,
|
||||
nativeStreaming: false,
|
||||
blockStreamingCoalesce: { minChars: 5, maxChars: 15, idleMs: 100 },
|
||||
draftChunk: { minChars: 2, maxChars: 4, breakPreference: "paragraph" },
|
||||
} as const;
|
||||
|
||||
expect(getChannelStreamingConfigObject(entry)).toEqual(entry.streaming);
|
||||
expect(resolveChannelStreamingChunkMode(entry)).toBe("newline");
|
||||
expect(resolveChannelStreamingNativeTransport(entry)).toBe(true);
|
||||
expect(resolveChannelStreamingBlockEnabled(entry)).toBe(true);
|
||||
expect(resolveChannelStreamingBlockCoalesce(entry)).toEqual({
|
||||
minChars: 40,
|
||||
maxChars: 80,
|
||||
idleMs: 250,
|
||||
});
|
||||
expect(resolveChannelStreamingPreviewChunk(entry)).toEqual({
|
||||
minChars: 10,
|
||||
maxChars: 20,
|
||||
breakPreference: "sentence",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to legacy flat fields when the canonical object is absent", () => {
|
||||
const entry = {
|
||||
chunkMode: "newline",
|
||||
blockStreaming: true,
|
||||
nativeStreaming: true,
|
||||
blockStreamingCoalesce: { minChars: 120, maxChars: 240, idleMs: 500 },
|
||||
draftChunk: { minChars: 8, maxChars: 16, breakPreference: "newline" },
|
||||
} as const;
|
||||
|
||||
expect(getChannelStreamingConfigObject(entry)).toBeUndefined();
|
||||
expect(resolveChannelStreamingChunkMode(entry)).toBe("newline");
|
||||
expect(resolveChannelStreamingNativeTransport(entry)).toBe(true);
|
||||
expect(resolveChannelStreamingBlockEnabled(entry)).toBe(true);
|
||||
expect(resolveChannelStreamingBlockCoalesce(entry)).toEqual({
|
||||
minChars: 120,
|
||||
maxChars: 240,
|
||||
idleMs: 500,
|
||||
});
|
||||
expect(resolveChannelStreamingPreviewChunk(entry)).toEqual({
|
||||
minChars: 8,
|
||||
maxChars: 16,
|
||||
breakPreference: "newline",
|
||||
});
|
||||
});
|
||||
});
|
||||
101
src/plugin-sdk/channel-streaming.ts
Normal file
101
src/plugin-sdk/channel-streaming.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
BlockStreamingChunkConfig,
|
||||
BlockStreamingCoalesceConfig,
|
||||
ChannelDeliveryStreamingConfig,
|
||||
ChannelPreviewStreamingConfig,
|
||||
ChannelStreamingConfig,
|
||||
SlackChannelStreamingConfig,
|
||||
TextChunkMode,
|
||||
} from "../config/types.base.js";
|
||||
|
||||
export type {
|
||||
ChannelDeliveryStreamingConfig,
|
||||
ChannelPreviewStreamingConfig,
|
||||
ChannelStreamingBlockConfig,
|
||||
ChannelStreamingConfig,
|
||||
ChannelStreamingPreviewConfig,
|
||||
SlackChannelStreamingConfig,
|
||||
StreamingMode,
|
||||
TextChunkMode,
|
||||
} from "../config/types.base.js";
|
||||
|
||||
type StreamingCompatEntry = {
|
||||
streaming?: unknown;
|
||||
chunkMode?: unknown;
|
||||
blockStreaming?: unknown;
|
||||
draftChunk?: unknown;
|
||||
blockStreamingCoalesce?: unknown;
|
||||
nativeStreaming?: unknown;
|
||||
};
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asTextChunkMode(value: unknown): TextChunkMode | undefined {
|
||||
return value === "length" || value === "newline" ? value : undefined;
|
||||
}
|
||||
|
||||
function asBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function asBlockStreamingCoalesceConfig(value: unknown): BlockStreamingCoalesceConfig | undefined {
|
||||
return asObjectRecord(value) as BlockStreamingCoalesceConfig | undefined;
|
||||
}
|
||||
|
||||
function asBlockStreamingChunkConfig(value: unknown): BlockStreamingChunkConfig | undefined {
|
||||
return asObjectRecord(value) as BlockStreamingChunkConfig | undefined;
|
||||
}
|
||||
|
||||
export function getChannelStreamingConfigObject(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): ChannelStreamingConfig | undefined {
|
||||
const streaming = asObjectRecord(entry?.streaming);
|
||||
return streaming ? (streaming as ChannelStreamingConfig) : undefined;
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingChunkMode(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): TextChunkMode | undefined {
|
||||
return (
|
||||
asTextChunkMode(getChannelStreamingConfigObject(entry)?.chunkMode) ??
|
||||
asTextChunkMode(entry?.chunkMode)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingBlockEnabled(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): boolean | undefined {
|
||||
const config = getChannelStreamingConfigObject(entry);
|
||||
return asBoolean(config?.block?.enabled) ?? asBoolean(entry?.blockStreaming);
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingBlockCoalesce(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): BlockStreamingCoalesceConfig | undefined {
|
||||
const config = getChannelStreamingConfigObject(entry);
|
||||
return (
|
||||
asBlockStreamingCoalesceConfig(config?.block?.coalesce) ??
|
||||
asBlockStreamingCoalesceConfig(entry?.blockStreamingCoalesce)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingPreviewChunk(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): BlockStreamingChunkConfig | undefined {
|
||||
const config = getChannelStreamingConfigObject(entry);
|
||||
return (
|
||||
asBlockStreamingChunkConfig(config?.preview?.chunk) ??
|
||||
asBlockStreamingChunkConfig(entry?.draftChunk)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveChannelStreamingNativeTransport(
|
||||
entry: StreamingCompatEntry | null | undefined,
|
||||
): boolean | undefined {
|
||||
const config = getChannelStreamingConfigObject(entry);
|
||||
return asBoolean(config?.nativeTransport) ?? asBoolean(entry?.nativeStreaming);
|
||||
}
|
||||
@@ -68,8 +68,20 @@ describe("config footprint guardrails", () => {
|
||||
"hooks.internal.handlers",
|
||||
"channels.telegram.groupMentionsOnly",
|
||||
"channels.telegram.streamMode",
|
||||
"channels.telegram.chunkMode",
|
||||
"channels.telegram.blockStreaming",
|
||||
"channels.telegram.draftChunk",
|
||||
"channels.telegram.blockStreamingCoalesce",
|
||||
"channels.slack.streamMode",
|
||||
"channels.slack.chunkMode",
|
||||
"channels.slack.blockStreaming",
|
||||
"channels.slack.blockStreamingCoalesce",
|
||||
"channels.slack.nativeStreaming",
|
||||
"channels.discord.streamMode",
|
||||
"channels.discord.chunkMode",
|
||||
"channels.discord.blockStreaming",
|
||||
"channels.discord.draftChunk",
|
||||
"channels.discord.blockStreamingCoalesce",
|
||||
"channels.googlechat.streamMode",
|
||||
"channels.slack.channels.*.allow",
|
||||
"channels.slack.accounts.*.channels.*.allow",
|
||||
@@ -100,6 +112,16 @@ describe("config footprint guardrails", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps canonical nested streaming paths in the public core channel schema", () => {
|
||||
const source = readSource("src/config/zod-schema.providers-core.ts");
|
||||
|
||||
expect(source).toContain("streaming: ChannelPreviewStreamingConfigSchema.optional(),");
|
||||
expect(source).toContain("streaming: SlackStreamingConfigSchema.optional(),");
|
||||
expect(source).not.toContain('streamMode: z.enum(["replace", "status_final", "append"])');
|
||||
expect(source).not.toContain("draftChunk:");
|
||||
expect(source).not.toContain("nativeStreaming:");
|
||||
});
|
||||
|
||||
it("keeps shared setup input canonical-first", () => {
|
||||
const source = readSource("src/channels/plugins/types.core.ts");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user