refactor(config): retire flat streaming keys from the last six channel schemas via doctor migration (#105709)

* refactor(config): retire flat streaming keys from the last six channel schemas

signal, irc, nextcloud-talk, whatsapp, googlechat, and mattermost now accept
only the nested streaming.{chunkMode,block.enabled,block.coalesce} shape
(mattermost also drops scalar/boolean streaming); flat spellings migrate via
each channel's defineChannelAliasMigration doctor contract with root seeding
for their wholesale-replace account merges.

* feat(channels): warn once per key when the deprecated flat streaming fallback is used

Bundled schemas now reject the flat delivery keys, so the streaming.ts
fallback only serves external SDK plugin configs; emit a once-per-process
per-key subsystem warning and pin the removal plan to the next release train.

* chore(config): regenerate bundled channel config metadata for nested-only streaming

* docs: describe nested-only channel streaming config and the SDK flat-key deprecation window

* fix(whatsapp): seed migrated named-account streaming from the accounts.default layer

WhatsApp resolution layers accounts.default shared config between root and
named accounts, so doctor-materialized account streaming objects now inherit
default-account settings over root ones; mattermost schema test moves to the
nested-only shape with explicit rejection coverage; scope flat-key deprecation
notes to the pending Matrix/Feishu migrations.

* fix(whatsapp): resolve the default account case-insensitively when seeding migrated streaming

* chore(plugin-sdk): repin API baseline for nested-only channel streaming types
This commit is contained in:
Peter Steinberger
2026-07-12 15:27:28 -07:00
committed by GitHub
parent 2f25e9cba3
commit 4a05a087a1
49 changed files with 1012 additions and 247 deletions
@@ -1,2 +1,2 @@
0eb8e2e8ebfe48bbf269d6e0100e38b8c85c387651be7a3bc3d67f549a13768f plugin-sdk-api-baseline.json
f6e5dc7474a7cb54bdcde544f135656ac8e501679e18129607f4f42fec7cd791 plugin-sdk-api-baseline.jsonl
75edbe496d57c19d3ea53e51908c6c7aa37ed8a5c7311234c7ce17eea0775db8 plugin-sdk-api-baseline.json
f21d330f28d93248bd6a7d96780452d6a969f5d1fcca1b9246ceb5a68e589b9f plugin-sdk-api-baseline.jsonl
+3 -3
View File
@@ -294,13 +294,13 @@ Notes:
Mattermost streams thinking, tool activity, and partial reply text into a **draft preview post** that finalizes in place when the final answer is safe to send. In `partial` mode the preview updates on the same post id instead of spamming the channel with per-chunk messages. In `block` mode the preview rotates between completed text and tool-activity blocks, so earlier blocks stay visible as their own posts instead of being overwritten by the next one. Media/error finals cancel pending preview edits and use normal delivery instead of flushing a throwaway preview post.
Preview streaming is **on by default** in `partial` mode. Configure via `channels.mattermost.streaming` (a mode string, boolean, or an object like `{ mode: "progress" }`):
Preview streaming is **on by default** in `partial` mode. Configure via `channels.mattermost.streaming.mode` (legacy scalar/boolean `streaming` values are migrated by `openclaw doctor --fix`):
```json5
{
channels: {
mattermost: {
streaming: "partial", // off | partial | block | progress
streaming: { mode: "partial" }, // off | partial | block | progress
},
},
}
@@ -311,7 +311,7 @@ Preview streaming is **on by default** in `partial` mode. Configure via `channel
- `partial` (default): one preview post that is edited as the reply grows, then finalized with the complete answer.
- `block` rotates the preview between completed text and tool-activity blocks, so each block stays visible as its own post instead of being overwritten in place. Parallel and consecutive tool updates share the current tool-activity post.
- `progress` shows a status preview while generating and only posts the final answer at completion.
- `off` disables preview streaming. With `blockStreaming: true`, completed assistant blocks are still delivered as normal block replies (separate posts) rather than a single coalesced final post.
- `off` disables preview streaming. With `streaming.block.enabled: true`, completed assistant blocks are still delivered as normal block replies (separate posts) rather than a single coalesced final post.
</Accordion>
<Accordion title="Streaming behavior notes">
+3 -3
View File
@@ -156,9 +156,9 @@ Provider options:
- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables).
- `channels.nextcloud-talk.dms`: per-DM overrides keyed by user ID (`historyLimit`).
- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size in chars (default: 4000).
- `channels.nextcloud-talk.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel.
- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning.
- `channels.nextcloud-talk.streaming.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.nextcloud-talk.streaming.block.enabled`: enable or disable block streaming for this channel.
- `channels.nextcloud-talk.streaming.block.coalesce`: block streaming coalesce tuning.
- `channels.nextcloud-talk.responsePrefix`: outbound reply prefix.
- `channels.nextcloud-talk.markdown.tables`: markdown table rendering mode (`off | bullets | code | block`).
- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB).
+3 -3
View File
@@ -256,7 +256,7 @@ Groups:
## Media + limits
- Outbound text is chunked to `channels.signal.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.signal.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
- Optional newline chunking: set `channels.signal.streaming.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
- Attachments are supported (base64 fetched from `signal-cli`).
- Voice-note attachments use the `signal-cli` filename as a MIME fallback when `contentType` is missing, so audio transcription can still classify AAC voice memos.
- Default media cap: `channels.signal.mediaMaxMb` (default 8).
@@ -440,12 +440,12 @@ Provider options:
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
- `channels.signal.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.signal.dms["<phone_or_uuid>"].historyLimit`.
- `channels.signal.textChunkLimit`: outbound chunk size in characters (default 4000).
- `channels.signal.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.signal.streaming.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.signal.mediaMaxMb`: inbound/outbound media cap in MB (default 8).
- `channels.signal.reactionLevel`: `off | ack | minimal | extensive` (default `minimal`). See [Reactions](#reactions-message-tool).
- `channels.signal.reactionNotifications`: `off | own | all | allowlist` (default `own`) - when the agent is notified of incoming reactions from others.
- `channels.signal.reactionAllowlist`: senders whose reactions notify the agent when `reactionNotifications: "allowlist"`.
- `channels.signal.blockStreaming`, `channels.signal.blockStreamingCoalesce`: block-mode streaming controls shared across channels. See [Streaming](/concepts/streaming).
- `channels.signal.streaming.block.enabled`, `channels.signal.streaming.block.coalesce`: block-mode streaming controls shared across channels. See [Streaming](/concepts/streaming).
Related global options:
+2 -2
View File
@@ -391,7 +391,7 @@ When the linked self number is also present in `allowFrom`, self-chat safeguards
<AccordionGroup>
<Accordion title="Text chunking">
- default chunk limit: `channels.whatsapp.textChunkLimit = 4000`
- `channels.whatsapp.chunkMode = "length" | "newline"`; `newline` prefers paragraph boundaries (blank lines), then falls back to length-safe chunking
- `channels.whatsapp.streaming.chunkMode = "length" | "newline"`; `newline` prefers paragraph boundaries (blank lines), then falls back to length-safe chunking
</Accordion>
@@ -677,7 +677,7 @@ Primary reference: [Configuration reference - WhatsApp](/gateway/config-channels
| Area | Fields |
| ---------------- | -------------------------------------------------------------------------------------------------------------- |
| Access | `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups` |
| Delivery | `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` |
| Delivery | `textChunkLimit`, `streaming.chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`, `reactionLevel` |
| Multi-account | `accounts.<id>.enabled`, `accounts.<id>.authDir`, and other per-account overrides |
| Operations | `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`, `web.whatsapp.*` |
| Session behavior | `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms.<id>.historyLimit` |
+1 -1
View File
@@ -125,7 +125,7 @@ Block streaming sends partial replies as the model produces text blocks; chunkin
- `agents.defaults.blockStreamingChunk` (`minChars|maxChars|breakPreference`)
- `agents.defaults.blockStreamingCoalesce` (idle-based batching)
- `agents.defaults.humanDelay` (human-like pause between block replies)
- Channel overrides: `*.streaming.block.enabled` and `*.streaming.block.coalesce` on nested-streaming channels (Telegram, Discord, Slack, iMessage, Microsoft Teams); flat `*.blockStreaming` / `*.blockStreamingCoalesce` on channels without a nested streaming config. Block streaming is off unless explicitly enabled, on every channel including Telegram.
- Channel overrides: `*.streaming.block.enabled` and `*.streaming.block.coalesce` on bundled channels (Matrix and Feishu still use the flat `*.blockStreaming` / `*.blockStreamingCoalesce` spellings); stale flat keys elsewhere are migrated by `openclaw doctor --fix`. Block streaming is off unless explicitly enabled, on every channel including Telegram.
Details: [Streaming + chunking](/concepts/streaming).
+20 -18
View File
@@ -41,22 +41,23 @@ Model output
| `blockStreamingBreak` | `"text_end"` / `"message_end"` | - |
| `blockStreamingChunk` | `{ minChars, maxChars, breakPreference? }` | - |
| `blockStreamingCoalesce` | `{ minChars?, maxChars?, idleMs? }` (merge streamed blocks before send) | - |
| `*.blockStreaming` (channel override) | `true` / `false`, forces block streaming per channel (and per account) | - |
| `*.streaming.block.enabled` (channel override) | `true` / `false`, forces block streaming per channel (and per account) | - |
| `*.textChunkLimit` (e.g. `channels.whatsapp.textChunkLimit`) | number, hard cap | 4000 |
| `*.chunkMode` | `"length"` / `"newline"` | `"length"` |
| `*.streaming.chunkMode` | `"length"` / `"newline"` | `"length"` |
| `channels.discord.maxLinesPerMessage` | number, soft line cap that splits tall replies to avoid UI clipping | 17 |
`chunkMode: "newline"` splits on blank lines (paragraph boundaries), not every
newline, before falling back to length chunking once the text exceeds the
limit.
`streaming.chunkMode: "newline"` splits on blank lines (paragraph boundaries),
not every newline, before falling back to length chunking once the text
exceeds the limit.
Channels with a nested `streaming` config (Telegram, Discord, Slack, iMessage,
Microsoft Teams) spell these overrides as
`channels.<id>.streaming.{chunkMode,block.enabled,block.coalesce}`; the flat
`*.chunkMode` / `*.blockStreaming` / `*.blockStreamingCoalesce` spellings apply
to channels without one (for example Signal, IRC, Google Chat, WhatsApp,
Mattermost). Stale flat keys on nested-streaming channels are migrated by
`openclaw doctor --fix` and are not read at runtime.
Bundled channels spell these overrides as
`channels.<id>.streaming.{chunkMode,block.enabled,block.coalesce}`. The flat
`*.chunkMode` / `*.blockStreaming` / `*.blockStreamingCoalesce` spellings are
legacy everywhere except Matrix and Feishu (their nested migration is still
pending): `openclaw doctor --fix` migrates them into the nested shape, and
migrated channel schemas reject them. External SDK plugin configs that still
use the flat spellings keep working through a deprecated fallback (with a
runtime warning) until the next release train.
**Boundary semantics** for `blockStreamingBreak`:
@@ -104,7 +105,7 @@ progressive output.
(final flush always sends remaining text).
- Joiner is derived from `blockStreamingChunk.breakPreference`: `paragraph` ->
`\n\n`, `newline` -> `\n`, `sentence` -> space.
- Channel overrides are available via `*.blockStreamingCoalesce` (including
- Channel overrides are available via `*.streaming.block.coalesce` (including
per-account configs).
- Discord, Signal, and Slack default coalesce to `{ minChars: 1500, idleMs: 1000 }`
unless overridden.
@@ -126,15 +127,16 @@ replies**, not final replies or tool summaries.
## "Stream chunks or everything"
- **Stream chunks:** `blockStreamingDefault: "on"` + `blockStreamingBreak: "text_end"`
(emit as you go). Non-Telegram channels also need `*.blockStreaming: true`.
(emit as you go). Non-Telegram channels also need
`*.streaming.block.enabled: true`.
- **Stream everything at end:** `blockStreamingBreak: "message_end"` (flush
once, possibly multiple chunks if very long).
- **No block streaming:** `blockStreamingDefault: "off"` (only final reply).
Block streaming is **off unless** `*.blockStreaming` is explicitly set to
`true`. Channels can stream a live preview (`channels.<channel>.streaming`)
without block replies. The `blockStreaming*` defaults live under
`agents.defaults`, not the config root.
Block streaming is **off unless** `*.streaming.block.enabled` is explicitly
set to `true`. Channels can stream a live preview
(`channels.<channel>.streaming.mode`) without block replies. The
`blockStreaming*` defaults live under `agents.defaults`, not the config root.
## Preview streaming modes
+3 -3
View File
@@ -129,7 +129,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
dmPolicy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["+15555550123", "+447700900123"],
textChunkLimit: 4000,
chunkMode: "length", // length | newline
streaming: { chunkMode: "length" }, // length | newline
mediaMaxMb: 50,
sendReadReceipts: true, // blue ticks (false in self-chat mode)
groups: {
@@ -202,7 +202,7 @@ WhatsApp runs through the gateway's web channel (Baileys Web). It starts automat
historyLimit: 50,
replyToMode: "first", // off | first | all | batched
linkPreview: true,
streaming: "partial", // off | partial | block | progress (default: partial)
streaming: { mode: "partial" }, // off | partial | block | progress (default: partial)
actions: { reactions: true, sendMessage: true },
reactionNotifications: "own", // off | own | all
mediaMaxMb: 100,
@@ -576,7 +576,7 @@ Check [npmjs.com/package/@openclaw/mattermost](https://www.npmjs.com/package/@op
callbackUrl: "https://gateway.example.com/api/channels/mattermost/command",
},
textChunkLimit: 4000,
chunkMode: "length",
streaming: { chunkMode: "length" },
},
},
}
@@ -1,6 +1,6 @@
// Googlechat tests cover doctor contract plugin behavior.
import { describe, expect, it } from "vitest";
import { normalizeCompatibilityConfig } from "./doctor-contract.js";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
describe("googlechat doctor contract", () => {
it("removes legacy streamMode keys", () => {
@@ -73,4 +73,41 @@ describe("googlechat doctor contract", () => {
enabled: true,
});
});
it("matches flat streaming aliases in legacy rules but not the nested shape", () => {
const rootRule = legacyConfigRules.find(
(rule) => rule.path.join(".") === "channels.googlechat" && rule.message.includes("chunkMode"),
);
expect(rootRule?.match?.({ blockStreaming: true }, {})).toBe(true);
expect(rootRule?.match?.({ streaming: { block: { enabled: true } } }, {})).toBe(false);
});
it("moves flat delivery aliases at root and account level with root seeding", () => {
const result = normalizeCompatibilityConfig({
cfg: {
channels: {
googlechat: {
streamMode: "append",
chunkMode: "newline",
accounts: {
work: { blockStreaming: true },
},
},
},
} as never,
});
const googlechat = result.config.channels?.googlechat as unknown as Record<string, unknown>;
expect(googlechat.streamMode).toBeUndefined();
expect(googlechat.streaming).toEqual({ chunkMode: "newline" });
expect(googlechat.chunkMode).toBeUndefined();
const work = (googlechat.accounts as Record<string, Record<string, unknown>>).work;
// Google Chat's account merge replaces root streaming wholesale, so the
// migrated account object carries the inherited root chunk mode.
expect(work?.streaming).toEqual({ chunkMode: "newline", block: { enabled: true } });
expect(work?.blockStreaming).toBeUndefined();
const second = normalizeCompatibilityConfig({ cfg: result.config });
expect(second.changes).toEqual([]);
});
});
+27 -6
View File
@@ -4,10 +4,22 @@ import type {
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { asObjectRecord } from "openclaw/plugin-sdk/runtime-doctor";
import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
type GoogleChatChannelsConfig = NonNullable<OpenClawConfig["channels"]>;
// Google Chat's nested streaming schema is delivery-only ({chunkMode, block});
// it has no preview mode (legacy streamMode is removed outright above), so
// only the delivery flat aliases migrate. Account merge replaces the root
// streaming object wholesale (resolveMergedAccountConfig without a streaming
// deep-merge), so migration seeds materialized account objects with the
// inherited root settings.
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "googlechat",
streaming: { defaultMode: "partial", deliveryOnly: true },
accountStreamingReplacesRoot: true,
});
function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
return asObjectRecord(value)?.streamMode !== undefined;
}
@@ -113,13 +125,10 @@ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
'channels.googlechat.accounts.<id>.groups.<id>.allow is legacy; use channels.googlechat.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
match: (value) => hasLegacyAccountAliases(value, hasLegacyGoogleChatGroupAllowAlias),
},
...streamingAliasMigration.legacyConfigRules,
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
function normalizeRetiredGoogleChatKeys(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord(
(cfg.channels as Record<string, unknown> | undefined)?.googlechat,
);
@@ -179,3 +188,15 @@ export function normalizeCompatibilityConfig({
changes,
};
}
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const retired = normalizeRetiredGoogleChatKeys(cfg);
return streamingAliasMigration.normalizeChannelConfig({
cfg: retired.config,
changes: retired.changes,
});
}
@@ -0,0 +1,61 @@
// Irc tests cover doctor contract api plugin behavior.
import { expectDefined } from "@openclaw/normalization-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
function ircConfig(entry: Record<string, unknown>): OpenClawConfig {
return { channels: { irc: entry } } as never;
}
describe("irc streaming legacy config rules", () => {
const rootRule = legacyConfigRules.find((rule) => rule.path.join(".") === "channels.irc");
const accountRule = legacyConfigRules.find(
(rule) => rule.path.join(".") === "channels.irc.accounts",
);
it("matches flat delivery aliases at root and account level", () => {
expect(rootRule?.match?.({ blockStreaming: false }, {})).toBe(true);
expect(rootRule?.match?.({ streaming: { block: { enabled: false } } }, {})).toBe(false);
expect(accountRule?.match?.({ libera: { chunkMode: "newline" } }, {})).toBe(true);
expect(accountRule?.match?.({ libera: { streaming: { chunkMode: "newline" } } }, {})).toBe(
false,
);
});
});
describe("irc normalizeCompatibilityConfig streaming aliases", () => {
it("moves flat delivery aliases and seeds materialized account objects from root", () => {
const result = normalizeCompatibilityConfig({
cfg: ircConfig({
blockStreaming: true,
accounts: {
libera: { chunkMode: "newline" },
},
}),
});
const irc = result.config.channels?.irc as unknown as Record<string, unknown>;
expect(irc.streaming).toEqual({ block: { enabled: true } });
expect(irc.blockStreaming).toBeUndefined();
const libera = expectDefined(
(irc.accounts as Record<string, Record<string, unknown>>).libera,
"libera irc account",
);
// IRC's account merge replaces the root streaming object wholesale, so the
// migrated account object must carry the inherited root block settings.
expect(libera.streaming).toEqual({ chunkMode: "newline", block: { enabled: true } });
expect(libera.chunkMode).toBeUndefined();
});
it("is idempotent: a second run reports no changes", () => {
const first = normalizeCompatibilityConfig({
cfg: ircConfig({ chunkMode: "length", blockStreamingCoalesce: { minChars: 10 } }),
});
expect(first.changes.length).toBeGreaterThan(0);
const second = normalizeCompatibilityConfig({ cfg: first.config });
expect(second.changes).toEqual([]);
expect(second.config).toBe(first.config);
});
});
+29
View File
@@ -0,0 +1,29 @@
// Irc API module exposes the plugin doctor contract.
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
// IRC's nested streaming schema is delivery-only ({chunkMode, block}); it has
// no preview mode, so only the delivery flat aliases are legal legacy input.
// Account merge replaces the root streaming object wholesale
// (resolveMergedAccountConfig without a streaming deep-merge), so migration
// seeds materialized account objects with the inherited root settings.
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "irc",
streaming: { defaultMode: "partial", deliveryOnly: true },
accountStreamingReplacesRoot: true,
});
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] =
streamingAliasMigration.legacyConfigRules;
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
return streamingAliasMigration.normalizeChannelConfig({ cfg });
}
+3 -3
View File
@@ -5,6 +5,7 @@ import {
createChannelIngressResolver,
defineStableChannelIngressIdentity,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
@@ -394,6 +395,7 @@ export async function handleIrcInbound(params: {
});
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
@@ -449,9 +451,7 @@ export async function handleIrcInbound(params: {
replyOptions: {
skillFilter: groupMatch.groupConfig?.skills,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,
typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined,
},
record: {
onRecordError: (err) => {
+2 -4
View File
@@ -1,6 +1,6 @@
// Irc type declarations define plugin contracts.
import type { ChannelDeliveryStreamingConfig } from "openclaw/plugin-sdk/channel-outbound";
import type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
@@ -60,9 +60,7 @@ export type IrcAccountConfig = {
dmHistoryLimit?: number;
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
streaming?: ChannelDeliveryStreamingConfig;
responsePrefix?: string;
mediaMaxMb?: number;
};
@@ -100,19 +100,15 @@ const MattermostStreamingBlockSchema = z
coalesce: BlockStreamingCoalesceSchema.optional(),
})
.strict();
const MattermostStreamingSchema = z.union([
MattermostStreamingModeSchema,
z.boolean(),
z
.object({
mode: MattermostStreamingModeSchema.optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
preview: MattermostStreamingPreviewSchema.optional(),
progress: MattermostStreamingProgressSchema.optional(),
block: MattermostStreamingBlockSchema.optional(),
})
.strict(),
]);
const MattermostStreamingSchema = z
.object({
mode: MattermostStreamingModeSchema.optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
preview: MattermostStreamingPreviewSchema.optional(),
progress: MattermostStreamingProgressSchema.optional(),
block: MattermostStreamingBlockSchema.optional(),
})
.strict();
const MattermostReplyToModeSchema = z.enum(["off", "first", "all", "batched"]);
const MattermostReplyToModeByChatTypeSchema = z
@@ -141,10 +137,7 @@ const MattermostAccountSchemaBase = z
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
streaming: MattermostStreamingSchema.optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
replyToMode: MattermostReplyToModeSchema.optional(),
replyToModeByChatType: MattermostReplyToModeByChatTypeSchema.optional(),
responsePrefix: z.string().optional(),
@@ -68,13 +68,22 @@ describe("MattermostConfigSchema", () => {
},
accounts: {
quiet: {
streaming: "off",
streaming: { mode: "off" },
},
},
});
expect(result.success).toBe(true);
});
it("rejects retired scalar streaming and flat delivery keys", () => {
// Scalar/boolean streaming and the flat delivery keys are doctor-migrated
// legacy input; the runtime schema accepts the nested shape only.
expect(MattermostConfigSchema.safeParse({ streaming: "partial" }).success).toBe(false);
expect(MattermostConfigSchema.safeParse({ streaming: false }).success).toBe(false);
expect(MattermostConfigSchema.safeParse({ blockStreaming: true }).success).toBe(false);
expect(MattermostConfigSchema.safeParse({ chunkMode: "newline" }).success).toBe(false);
});
it("accepts groups with requireMention", () => {
const result = MattermostConfigSchema.safeParse({
groups: {
@@ -0,0 +1,72 @@
// Mattermost tests cover doctor contract plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
function mattermostConfig(entry: Record<string, unknown>): OpenClawConfig {
return { channels: { mattermost: entry } } as never;
}
describe("mattermost streaming legacy config rules", () => {
const rootRule = legacyConfigRules.find(
(rule) => rule.path.join(".") === "channels.mattermost" && rule.message.includes("chunkMode"),
);
it("matches scalar streaming and flat delivery aliases but not the nested shape", () => {
expect(rootRule?.match?.({ streaming: "block" }, {})).toBe(true);
expect(rootRule?.match?.({ streaming: false }, {})).toBe(true);
expect(rootRule?.match?.({ blockStreaming: true }, {})).toBe(true);
expect(rootRule?.match?.({ streaming: { mode: "block" } }, {})).toBe(false);
});
});
describe("mattermost normalizeCompatibilityConfig streaming aliases", () => {
it("moves scalar streaming into streaming.mode alongside flat delivery keys", () => {
const result = normalizeCompatibilityConfig({
cfg: mattermostConfig({
streaming: "progress",
chunkMode: "newline",
blockStreamingCoalesce: { idleMs: 100 },
}),
});
const mattermost = result.config.channels?.mattermost as unknown as Record<string, unknown>;
expect(mattermost.streaming).toEqual({
mode: "progress",
chunkMode: "newline",
block: { coalesce: { idleMs: 100 } },
});
expect(mattermost.chunkMode).toBeUndefined();
expect(mattermost.blockStreamingCoalesce).toBeUndefined();
});
it("migrates boolean streaming off and seeds materialized account objects from root", () => {
const result = normalizeCompatibilityConfig({
cfg: mattermostConfig({
streaming: false,
accounts: {
work: { blockStreaming: true },
},
}),
});
const mattermost = result.config.channels?.mattermost as unknown as Record<string, unknown>;
expect(mattermost.streaming).toEqual({ mode: "off" });
const work = (mattermost.accounts as Record<string, Record<string, unknown>>).work;
// Mattermost's account merge replaces root streaming wholesale, so the
// migrated account object carries the inherited root mode.
expect(work?.streaming).toEqual({ mode: "off", block: { enabled: true } });
expect(work?.blockStreaming).toBeUndefined();
});
it("is idempotent: a second run reports no changes", () => {
const first = normalizeCompatibilityConfig({
cfg: mattermostConfig({ streaming: "partial", blockStreaming: true }),
});
expect(first.changes.length).toBeGreaterThan(0);
const second = normalizeCompatibilityConfig({ cfg: first.config });
expect(second.changes).toEqual([]);
expect(second.config).toBe(first.config);
});
});
+31 -3
View File
@@ -1,10 +1,38 @@
// Mattermost plugin module implements doctor contract behavior.
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
const contract = createLegacyPrivateNetworkDoctorContract({
const networkContract = createLegacyPrivateNetworkDoctorContract({
channelKey: "mattermost",
});
export const legacyConfigRules = contract.legacyConfigRules;
// Mattermost has a preview stream mode; runtime resolves it with a "partial"
// default (resolveChannelPreviewStreamMode(merged, "partial") in accounts.ts),
// so scalar/boolean `streaming` values migrate through the mode path. Account
// merge replaces the root streaming object wholesale (resolveMergedAccountConfig
// without a streaming deep-merge), so migration seeds materialized account
// objects with the inherited root settings.
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "mattermost",
streaming: { defaultMode: "partial" },
accountStreamingReplacesRoot: true,
});
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
export const legacyConfigRules = [
...networkContract.legacyConfigRules,
...streamingAliasMigration.legacyConfigRules,
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const network = networkContract.normalizeCompatibilityConfig({ cfg });
return streamingAliasMigration.normalizeChannelConfig({
cfg: network.config,
changes: network.changes,
});
}
@@ -11,7 +11,9 @@ import {
resolveChannelStreamingChunkMode,
resolveChannelPreviewStreamMode,
type StreamingMode,
type TextChunkMode,
} from "openclaw/plugin-sdk/channel-outbound";
import type { BlockStreamingCoalesceConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
import type {
@@ -39,10 +41,10 @@ export type ResolvedMattermostAccount = {
oncharPrefixes?: string[];
requireMention?: boolean;
textChunkLimit?: number;
chunkMode?: MattermostAccountConfig["chunkMode"];
chunkMode?: TextChunkMode;
streamingMode: StreamingMode;
blockStreaming?: boolean;
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
};
const mattermostAccountHelpers = createAccountListHelpers("mattermost", {
+4 -13
View File
@@ -1,9 +1,6 @@
// Mattermost type declarations define plugin contracts.
import type {
ChannelPreviewStreamingConfig,
StreamingMode,
} from "openclaw/plugin-sdk/channel-outbound";
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./runtime-api.js";
import type { ChannelPreviewStreamingConfig } from "openclaw/plugin-sdk/channel-outbound";
import type { DmPolicy, GroupPolicy } from "./runtime-api.js";
import type { SecretInput } from "./secret-input.js";
export type MattermostReplyToMode = "off" | "first" | "all" | "batched";
@@ -54,14 +51,8 @@ export type MattermostAccountConfig = {
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Preview streaming mode/config. */
streaming?: StreamingMode | boolean | ChannelPreviewStreamingConfig;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Preview streaming config (nested-only; scalar modes migrate via doctor). */
streaming?: ChannelPreviewStreamingConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/**
@@ -0,0 +1,55 @@
// Nextcloud Talk tests cover doctor contract plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
function talkConfig(entry: Record<string, unknown>): OpenClawConfig {
return { channels: { "nextcloud-talk": entry } } as never;
}
describe("nextcloud-talk streaming legacy config rules", () => {
const rootRule = legacyConfigRules.find(
(rule) =>
rule.path.join(".") === "channels.nextcloud-talk" && rule.message.includes("chunkMode"),
);
it("matches flat delivery aliases but not the nested shape", () => {
expect(rootRule?.match?.({ chunkMode: "newline" }, {})).toBe(true);
expect(rootRule?.match?.({ streaming: { chunkMode: "newline" } }, {})).toBe(false);
});
});
describe("nextcloud-talk normalizeCompatibilityConfig streaming aliases", () => {
it("moves flat delivery aliases at root and account level with root seeding", () => {
const result = normalizeCompatibilityConfig({
cfg: talkConfig({
chunkMode: "newline",
accounts: {
home: { blockStreaming: true },
},
}),
});
const talk = result.config.channels?.["nextcloud-talk"] as unknown as Record<string, unknown>;
expect(talk.streaming).toEqual({ chunkMode: "newline" });
expect(talk.chunkMode).toBeUndefined();
const home = (talk.accounts as Record<string, Record<string, unknown>>).home;
// Account merge replaces root streaming wholesale, so the migrated account
// object carries the inherited root chunk mode.
expect(home?.streaming).toEqual({ chunkMode: "newline", block: { enabled: true } });
expect(home?.blockStreaming).toBeUndefined();
});
it("still runs the legacy private-network migration and stays idempotent", () => {
const first = normalizeCompatibilityConfig({
cfg: talkConfig({ allowPrivateNetwork: true, blockStreaming: false }),
});
const talk = first.config.channels?.["nextcloud-talk"] as unknown as Record<string, unknown>;
expect(talk.allowPrivateNetwork).toBeUndefined();
expect(talk.network).toEqual({ dangerouslyAllowPrivateNetwork: true });
expect(talk.streaming).toEqual({ block: { enabled: false } });
const second = normalizeCompatibilityConfig({ cfg: first.config });
expect(second.changes).toEqual([]);
});
});
@@ -1,10 +1,37 @@
// Nextcloud Talk plugin module implements doctor contract behavior.
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
const contract = createLegacyPrivateNetworkDoctorContract({
const networkContract = createLegacyPrivateNetworkDoctorContract({
channelKey: "nextcloud-talk",
});
export const legacyConfigRules = contract.legacyConfigRules;
// Nextcloud Talk's nested streaming schema is delivery-only ({chunkMode,
// block}); it has no preview mode, so only the delivery flat aliases are
// legal legacy input. Account merge replaces the root streaming object
// wholesale (resolveMergedAccountConfig without a streaming deep-merge), so
// migration seeds materialized account objects with inherited root settings.
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "nextcloud-talk",
streaming: { defaultMode: "partial", deliveryOnly: true },
accountStreamingReplacesRoot: true,
});
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
export const legacyConfigRules = [
...networkContract.legacyConfigRules,
...streamingAliasMigration.legacyConfigRules,
];
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
const network = networkContract.normalizeCompatibilityConfig({ cfg });
return streamingAliasMigration.normalizeChannelConfig({
cfg: network.config,
changes: network.changes,
});
}
+3 -3
View File
@@ -3,6 +3,7 @@ import {
channelIngressRoutes,
resolveStableChannelMessageIngress,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import {
normalizeOptionalString,
@@ -325,6 +326,7 @@ export async function handleNextcloudTalkInbound(params: {
});
const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
@@ -387,9 +389,7 @@ export async function handleNextcloudTalkInbound(params: {
replyOptions: {
skillFilter: roomConfig?.skills,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,
typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined,
},
record: {
onRecordError: (err) => {
+6 -13
View File
@@ -1,12 +1,9 @@
// Nextcloud Talk type declarations define plugin contracts.
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
import type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
SecretInput,
} from "../runtime-api.js";
ChannelDeliveryStreamingConfig,
MessageReceipt,
} from "openclaw/plugin-sdk/channel-outbound";
import type { DmConfig, DmPolicy, GroupPolicy, SecretInput } from "../runtime-api.js";
export type NextcloudTalkRoomConfig = {
requireMention?: boolean;
@@ -70,12 +67,8 @@ export type NextcloudTalkAccountConfig = {
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";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Delivery streaming config: chunk mode plus block streaming controls. */
streaming?: ChannelDeliveryStreamingConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Media upload max size in MB. */
@@ -0,0 +1,78 @@
// Signal tests cover doctor contract api plugin behavior.
import { expectDefined } from "@openclaw/normalization-core";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
function signalConfig(entry: Record<string, unknown>): OpenClawConfig {
return { channels: { signal: entry } } as never;
}
describe("signal streaming legacy config rules", () => {
const rootRule = legacyConfigRules.find((rule) => rule.path.join(".") === "channels.signal");
const accountRule = legacyConfigRules.find(
(rule) => rule.path.join(".") === "channels.signal.accounts",
);
it("matches flat delivery aliases at root and account level", () => {
expect(rootRule?.match?.({ chunkMode: "newline" }, {})).toBe(true);
expect(rootRule?.match?.({ blockStreaming: true }, {})).toBe(true);
expect(rootRule?.match?.({ streaming: { chunkMode: "newline" } }, {})).toBe(false);
expect(accountRule?.match?.({ personal: { blockStreamingCoalesce: { idleMs: 5 } } }, {})).toBe(
true,
);
expect(
accountRule?.match?.({ personal: { streaming: { block: { enabled: true } } } }, {}),
).toBe(false);
});
});
describe("signal normalizeCompatibilityConfig streaming aliases", () => {
it("moves flat delivery aliases and seeds materialized account objects from root", () => {
const result = normalizeCompatibilityConfig({
cfg: signalConfig({
chunkMode: "newline",
blockStreaming: true,
accounts: {
personal: {
blockStreamingCoalesce: { idleMs: 250 },
},
},
}),
});
const signal = result.config.channels?.signal as unknown as Record<string, unknown>;
expect(signal.streaming).toEqual({ chunkMode: "newline", block: { enabled: true } });
expect(signal.chunkMode).toBeUndefined();
expect(signal.blockStreaming).toBeUndefined();
const personal = expectDefined(
(signal.accounts as Record<string, Record<string, unknown>>).personal,
"personal signal account",
);
// Signal's account merge replaces the root streaming object wholesale, so
// the account object migration materializes must carry the inherited root
// settings or `doctor --fix` would silently drop them for this account.
expect(personal.streaming).toEqual({
chunkMode: "newline",
block: { enabled: true, coalesce: { idleMs: 250 } },
});
expect(personal.blockStreamingCoalesce).toBeUndefined();
expect(result.changes).toContain(
"Copied channels.signal.streaming into channels.signal.accounts.personal.streaming to keep inherited settings while migrating flat streaming keys.",
);
});
it("is idempotent: a second run reports no changes", () => {
const first = normalizeCompatibilityConfig({
cfg: signalConfig({
chunkMode: "newline",
accounts: { personal: { blockStreaming: false } },
}),
});
expect(first.changes.length).toBeGreaterThan(0);
const second = normalizeCompatibilityConfig({ cfg: first.config });
expect(second.changes).toEqual([]);
expect(second.config).toBe(first.config);
});
});
+29
View File
@@ -0,0 +1,29 @@
// Signal API module exposes the plugin doctor contract.
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
// Signal's nested streaming schema is delivery-only ({chunkMode, block}); it
// has no preview mode, so only the delivery flat aliases are legal legacy
// input. Account merge replaces the root streaming object wholesale
// (resolveMergedAccountConfig without a streaming deep-merge), so migration
// seeds materialized account objects with the inherited root settings.
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "signal",
streaming: { defaultMode: "partial", deliveryOnly: true },
accountStreamingReplacesRoot: true,
});
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] =
streamingAliasMigration.legacyConfigRules;
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
return streamingAliasMigration.normalizeChannelConfig({ cfg });
}
+2 -1
View File
@@ -1,6 +1,7 @@
// Signal plugin module implements monitor behavior.
import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plugin-sdk/approval-handler-adapter-runtime";
import type { ChannelRuntimeSurface } from "openclaw/plugin-sdk/channel-contract";
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
import { registerChannelRuntimeContext } from "openclaw/plugin-sdk/channel-runtime-context";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type {
@@ -702,7 +703,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
account,
accountUuid: accountInfo.config.accountUuid,
accountId: accountInfo.accountId,
blockStreaming: accountInfo.config.blockStreaming,
blockStreaming: resolveChannelStreamingBlockEnabled(accountInfo.config),
historyLimit,
groupHistories,
textLimit,
+1 -8
View File
@@ -1,9 +1,2 @@
// Whatsapp API module exposes the plugin public contract.
import type { ChannelDoctorLegacyConfigRule } from "openclaw/plugin-sdk/channel-contract";
export { normalizeCompatibilityConfig } from "./src/doctor-contract.js";
// WhatsApp currently exposes doctor compatibility fixes without extra legacy
// rule scans. Keep that empty answer on a lightweight contract surface so
// config validation stays off the broad contract-api import path.
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [];
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
+1 -7
View File
@@ -65,11 +65,5 @@ export function resolveMergedWhatsAppAccountConfig(params: {
accountId === DEFAULT_ACCOUNT_ID
? base
: resolveMergedNamedWhatsAppAccountConfig({ cfg: params.cfg, accountId });
return {
accountId,
...merged,
// WhatsApp has no nested streaming schema; the flat keys are canonical.
chunkMode: merged.chunkMode,
blockStreaming: merged.blockStreaming,
};
return { accountId, ...merged };
}
+2 -4
View File
@@ -38,9 +38,8 @@ export type ResolvedWhatsAppAccount = {
dmPolicy?: DmPolicy;
historyLimit?: number;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
streaming?: WhatsAppAccountConfig["streaming"];
mediaMaxMb?: number;
blockStreaming?: boolean;
ackReaction?: WhatsAppAccountConfig["ackReaction"];
reactionLevel?: WhatsAppAccountConfig["reactionLevel"];
groups?: WhatsAppAccountConfig["groups"];
@@ -146,9 +145,8 @@ export function resolveWhatsAppAccount(params: {
mentionPatterns: merged.mentionPatterns,
historyLimit: merged.historyLimit,
textChunkLimit: merged.textChunkLimit,
chunkMode: merged.chunkMode,
streaming: merged.streaming,
mediaMaxMb: merged.mediaMaxMb,
blockStreaming: merged.blockStreaming,
ackReaction: merged.ackReaction,
reactionLevel: merged.reactionLevel,
groups: merged.groups,
@@ -94,9 +94,11 @@ function resolveWebMonitorConfigSnapshot(params: {
groupAllowFrom: account.groupAllowFrom,
groupPolicy: account.groupPolicy,
textChunkLimit: account.textChunkLimit,
chunkMode: account.chunkMode,
// Account merge replaces `streaming` wholesale, so pinning the
// account-resolved object here keeps downstream root-level resolver
// reads (chunk mode, block enable/coalesce) on this account's config.
streaming: account.streaming,
mediaMaxMb: account.mediaMaxMb,
blockStreaming: account.blockStreaming,
groups: account.groups,
},
},
@@ -312,7 +312,7 @@ function unacceptedDeliveryResult() {
async function dispatchBufferedReply(overrides: Partial<BufferedReplyParams> = {}) {
const params: BufferedReplyParams = {
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
cfg: { channels: { whatsapp: { streaming: { block: { enabled: true } } } } } as never,
connectionId: "conn",
context: { Body: "hi" },
deliverReply: async () => acceptedDeliveryResult(),
@@ -1108,21 +1108,21 @@ describe("whatsapp inbound dispatch", () => {
expect(rememberSentText).not.toHaveBeenCalled();
});
it("maps WhatsApp blockStreaming=true to disableBlockStreaming=false", async () => {
it("maps WhatsApp streaming.block.enabled=true to disableBlockStreaming=false", async () => {
await dispatchBufferedReply();
expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(false);
});
it("maps WhatsApp blockStreaming=false to disableBlockStreaming=true", async () => {
it("maps WhatsApp streaming.block.enabled=false to disableBlockStreaming=true", async () => {
await dispatchBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: false } } } as never,
cfg: { channels: { whatsapp: { streaming: { block: { enabled: false } } } } } as never,
});
expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(true);
});
it("leaves disableBlockStreaming undefined when WhatsApp blockStreaming is unset", async () => {
it("leaves disableBlockStreaming undefined when WhatsApp block streaming is unset", async () => {
await dispatchBufferedReply({
cfg: { channels: { whatsapp: {} } } as never,
});
@@ -1155,7 +1155,7 @@ describe("whatsapp inbound dispatch", () => {
it("delivers authorized WhatsApp group text slash command replies visibly", async () => {
await dispatchBufferedReply({
cfg: {
channels: { whatsapp: { blockStreaming: true } },
channels: { whatsapp: { streaming: { block: { enabled: true } } } },
messages: { groupChat: { visibleReplies: "message_tool" } },
} as never,
context: {
@@ -1180,7 +1180,7 @@ describe("whatsapp inbound dispatch", () => {
it("honors automatic visible replies for WhatsApp groups", async () => {
await dispatchBufferedReply({
cfg: {
channels: { whatsapp: { blockStreaming: true } },
channels: { whatsapp: { streaming: { block: { enabled: true } } } },
messages: { groupChat: { visibleReplies: "automatic" } },
} as never,
context: { Body: "hi", ChatType: "group" },
@@ -1327,7 +1327,7 @@ describe("whatsapp inbound dispatch", () => {
await expect(
dispatchWhatsAppBufferedReply({
cfg: { channels: { whatsapp: { blockStreaming: true } } } as never,
cfg: { channels: { whatsapp: { streaming: { block: { enabled: true } } } } } as never,
connectionId: "conn",
context: { Body: "hi" },
deliverReply,
@@ -9,7 +9,10 @@ import {
toInboundMediaFacts,
} from "openclaw/plugin-sdk/channel-inbound";
import { hasVisibleInboundReplyDispatch } from "openclaw/plugin-sdk/channel-inbound";
import { deliverInboundReplyWithMessageSendContext } from "openclaw/plugin-sdk/channel-outbound";
import {
deliverInboundReplyWithMessageSendContext,
resolveChannelStreamingBlockEnabled,
} from "openclaw/plugin-sdk/channel-outbound";
import { buildInboundHistoryFromEntries } from "openclaw/plugin-sdk/reply-history";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -169,10 +172,10 @@ function resolveWhatsAppDurableReplyToId(params: {
}
function resolveWhatsAppDisableBlockStreaming(cfg: ReturnType<LoadConfigFn>): boolean | undefined {
if (typeof cfg.channels?.whatsapp?.blockStreaming !== "boolean") {
return undefined;
}
return !cfg.channels.whatsapp.blockStreaming;
// The monitor snapshot pins the account-resolved streaming object onto the
// root channel entry, so this root-level read is already account-scoped.
const enabled = resolveChannelStreamingBlockEnabled(cfg.channels?.whatsapp);
return typeof enabled === "boolean" ? !enabled : undefined;
}
function resolveWhatsAppDeliverablePayload(
@@ -0,0 +1,109 @@
// Whatsapp tests cover doctor contract plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
function whatsappConfig(entry: Record<string, unknown>): OpenClawConfig {
return { channels: { whatsapp: entry } } as never;
}
describe("whatsapp streaming legacy config rules", () => {
const rootRule = legacyConfigRules.find((rule) => rule.path.join(".") === "channels.whatsapp");
it("matches flat delivery aliases but not the nested shape", () => {
expect(rootRule?.match?.({ blockStreaming: true }, {})).toBe(true);
expect(rootRule?.match?.({ streaming: { block: { enabled: true } } }, {})).toBe(false);
});
});
describe("whatsapp normalizeCompatibilityConfig streaming aliases", () => {
it("moves flat delivery aliases at root and account level with root seeding", () => {
const result = normalizeCompatibilityConfig({
cfg: whatsappConfig({
chunkMode: "newline",
blockStreaming: false,
accounts: {
personal: { blockStreamingCoalesce: { minChars: 20 } },
},
}),
});
const whatsapp = result.config.channels?.whatsapp as unknown as Record<string, unknown>;
expect(whatsapp.streaming).toEqual({ chunkMode: "newline", block: { enabled: false } });
expect(whatsapp.chunkMode).toBeUndefined();
expect(whatsapp.blockStreaming).toBeUndefined();
const personal = (whatsapp.accounts as Record<string, Record<string, unknown>>).personal;
// WhatsApp's account merge replaces root streaming wholesale, so the
// migrated account object carries the inherited root delivery settings.
expect(personal?.streaming).toEqual({
chunkMode: "newline",
block: { enabled: false, coalesce: { minChars: 20 } },
});
expect(personal?.blockStreamingCoalesce).toBeUndefined();
});
it("seeds named accounts from accounts.default over root (layered inheritance)", () => {
const result = normalizeCompatibilityConfig({
cfg: whatsappConfig({
chunkMode: "length",
accounts: {
default: { blockStreaming: true },
work: { chunkMode: "newline" },
},
}),
});
const whatsapp = result.config.channels?.whatsapp as unknown as Record<string, unknown>;
const accounts = whatsapp.accounts as Record<string, Record<string, unknown>>;
expect(whatsapp.streaming).toEqual({ chunkMode: "length" });
expect(accounts.default?.streaming).toEqual({
chunkMode: "length",
block: { enabled: true },
});
// The old flat keys resolved per key across named > accounts.default >
// root, so the materialized work object must inherit the default
// account's block setting, not just the root chunk mode.
expect(accounts.work?.streaming).toEqual({
chunkMode: "newline",
block: { enabled: true },
});
const second = normalizeCompatibilityConfig({ cfg: result.config });
expect(second.changes).toEqual([]);
});
it("resolves the default account case-insensitively when seeding named accounts", () => {
// resolveAccountEntry matches account keys case-insensitively, so
// `accounts.Default` is the runtime default account too.
const result = normalizeCompatibilityConfig({
cfg: whatsappConfig({
accounts: {
Default: { blockStreaming: true },
work: { chunkMode: "newline" },
},
}),
});
const whatsapp = result.config.channels?.whatsapp as unknown as Record<string, unknown>;
const accounts = whatsapp.accounts as Record<string, Record<string, unknown>>;
expect(accounts.work?.streaming).toEqual({
chunkMode: "newline",
block: { enabled: true },
});
});
it("keeps the legacy ackReaction migration and stays idempotent", () => {
const first = normalizeCompatibilityConfig({
cfg: {
messages: { ackReaction: "👀" },
channels: { whatsapp: { blockStreaming: true } },
} as never,
});
const whatsapp = first.config.channels?.whatsapp as unknown as Record<string, unknown>;
expect(whatsapp.ackReaction).toEqual({ emoji: "👀", direct: false, group: "mentions" });
expect(whatsapp.streaming).toEqual({ block: { enabled: true } });
const second = normalizeCompatibilityConfig({ cfg: first.config });
expect(second.changes).toEqual([]);
});
});
+143 -3
View File
@@ -1,12 +1,152 @@
// Whatsapp plugin module implements doctor contract behavior.
import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract";
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { normalizeCompatibilityConfig as normalizeCompatibilityConfigImpl } from "./doctor.js";
import { asObjectRecord, defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor";
import { normalizeCompatibilityConfig as normalizeAckReactionConfig } from "./doctor.js";
// WhatsApp's nested streaming schema is delivery-only ({chunkMode, block});
// it has no preview mode, so only the delivery flat aliases are legal legacy
// input. Seeding is handled below instead of via accountStreamingReplacesRoot
// because WhatsApp resolution layers accounts.default shared config between
// the channel root and named accounts (account-config.ts), so a materialized
// named-account object must inherit default-account settings over root ones.
const streamingAliasMigration = defineChannelAliasMigration({
channelId: "whatsapp",
streaming: { defaultMode: "partial", deliveryOnly: true },
});
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] =
streamingAliasMigration.legacyConfigRules;
/** Deep-fills fields missing from target with copies of source values. */
function fillMissingStreamingFields(
target: Record<string, unknown>,
source: Record<string, unknown>,
): { value: Record<string, unknown>; filled: boolean } {
let filled = false;
const value = { ...target };
for (const [key, sourceValue] of Object.entries(source)) {
if (sourceValue === undefined) {
continue;
}
const existing = value[key];
if (existing === undefined) {
value[key] = structuredClone(sourceValue);
filled = true;
continue;
}
const existingRecord = asObjectRecord(existing);
const sourceRecord = asObjectRecord(sourceValue);
if (!existingRecord || !sourceRecord) {
continue;
}
const merged = fillMissingStreamingFields(existingRecord, sourceRecord);
if (merged.filled) {
value[key] = merged.value;
filled = true;
}
}
return { value, filled };
}
// The runtime merge replaces `streaming` wholesale per layer (named account >
// accounts.default > root), while the retired flat keys resolved per key
// across those layers. Account objects that migration materializes must carry
// the settings the account previously inherited, or `doctor --fix` silently
// changes effective delivery behavior for that account.
function seedMigratedAccountStreaming(params: {
cfg: OpenClawConfig;
accountsWithoutStreamingBefore: ReadonlySet<string>;
changes: string[];
}): OpenClawConfig {
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const entry = asObjectRecord(channels?.whatsapp);
const accounts = asObjectRecord(entry?.accounts);
if (!entry || !accounts) {
return params.cfg;
}
const rootStreaming = asObjectRecord(entry.streaming);
// Account lookup treats keys case-insensitively (resolveAccountEntry), so
// `accounts.Default` is the default account too.
const defaultKey = Object.hasOwn(accounts, "default")
? "default"
: Object.keys(accounts).find((key) => key.trim().toLowerCase() === "default");
let accountsChanged = false;
const nextAccounts = { ...accounts };
// Seed the default account first: its final object is the inheritance
// source for named accounts (default replaces root wholesale when set).
const seedOrder = Object.keys(accounts).toSorted((left, right) =>
left === defaultKey ? -1 : right === defaultKey ? 1 : left.localeCompare(right),
);
for (const accountId of seedOrder) {
const account = asObjectRecord(nextAccounts[accountId]);
const created = asObjectRecord(account?.streaming);
if (!account || !created || !params.accountsWithoutStreamingBefore.has(accountId)) {
continue;
}
const defaultStreaming = defaultKey
? asObjectRecord(asObjectRecord(nextAccounts[defaultKey])?.streaming)
: null;
const inheritedSource =
accountId === defaultKey ? rootStreaming : (defaultStreaming ?? rootStreaming);
if (!inheritedSource) {
continue;
}
const seeded = fillMissingStreamingFields(created, inheritedSource);
if (!seeded.filled) {
continue;
}
nextAccounts[accountId] = { ...account, streaming: seeded.value };
accountsChanged = true;
const sourcePath =
accountId === defaultKey
? "channels.whatsapp.streaming"
: "effective channels.whatsapp streaming defaults";
params.changes.push(
`Copied ${sourcePath} into channels.whatsapp.accounts.${accountId}.streaming to keep inherited settings while migrating flat streaming keys.`,
);
}
if (!accountsChanged) {
return params.cfg;
}
return {
...params.cfg,
channels: {
...channels,
whatsapp: { ...entry, accounts: nextAccounts },
},
} as OpenClawConfig;
}
export function normalizeCompatibilityConfig({
cfg,
}: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
return normalizeCompatibilityConfigImpl({ cfg });
const ackReaction = normalizeAckReactionConfig({ cfg });
const accountsBefore = asObjectRecord(
asObjectRecord((ackReaction.config.channels as Record<string, unknown> | undefined)?.whatsapp)
?.accounts,
);
const accountsWithoutStreamingBefore = new Set(
Object.entries(accountsBefore ?? {})
.filter(([, account]) => asObjectRecord(account)?.streaming === undefined)
.map(([accountId]) => accountId),
);
const aliases = streamingAliasMigration.normalizeChannelConfig({
cfg: ackReaction.config,
changes: ackReaction.changes,
});
return {
config: seedMigratedAccountStreaming({
cfg: aliases.config,
accountsWithoutStreamingBefore,
changes: aliases.changes,
}),
changes: aliases.changes,
};
}
+15 -8
View File
@@ -628,16 +628,17 @@ describe("chunkMarkdownTextWithMode", () => {
});
describe("resolveChunkMode", () => {
// Flat chunkMode stays canonical for channels without a nested streaming
// schema (signal, irc, googlechat, whatsapp); nested-only channels are
// covered by the imessage rows below.
const providerCfg = { channels: { signal: { chunkMode: "newline" as const } } };
// All bundled channels are nested-only now; the flat chunkMode row below
// covers the deprecated SDK-plugin fallback that streaming.ts still reads.
const providerCfg = {
channels: { signal: { streaming: { chunkMode: "newline" as const } } },
};
const accountCfg = {
channels: {
signal: {
chunkMode: "length" as const,
streaming: { chunkMode: "length" as const },
accounts: {
primary: { chunkMode: "newline" as const },
primary: { streaming: { chunkMode: "newline" as const } },
},
},
},
@@ -677,8 +678,8 @@ describe("resolveChunkMode", () => {
accountId: undefined,
expected: "length",
},
// Mattermost's schema accepts both shapes; the nested streaming config
// wins over the flat key when both are present.
// Deprecated SDK fallback: nested config wins over a stale flat key, and
// a flat-only entry still resolves until the SDK deprecation window ends.
{
cfg: {
channels: {
@@ -692,6 +693,12 @@ describe("resolveChunkMode", () => {
accountId: undefined,
expected: "newline",
},
{
cfg: { channels: { "sdk-plugin": { chunkMode: "newline" as const } } },
provider: "sdk-plugin",
accountId: undefined,
expected: "newline",
},
] as const)(
"resolves default/provider/account/internal chunk mode for $provider $accountId",
({ cfg, provider, accountId, expected }) => {
+4 -3
View File
@@ -122,9 +122,10 @@ describe("resolveEffectiveBlockStreamingConfig", () => {
).toMatchObject({ minChars: 25, maxChars: 80, idleMs: 2 });
});
it("merges flat account block coalescing over channel nested config for flat-canonical channels", () => {
// Mattermost's plugin schema still accepts flat blockStreamingCoalesce as
// canonical config, so the account-level flat read must keep working.
it("merges flat account block coalescing over channel nested config for SDK-plugin configs", () => {
// No bundled schema accepts flat blockStreamingCoalesce anymore, but the
// resolver keeps the account-level flat read for external SDK plugin
// configs until the deprecation window closes.
const cfg = {
channels: {
mattermost: {
@@ -0,0 +1,59 @@
// Covers the once-per-process deprecation warning for flat streaming key fallbacks.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resetFlatStreamingKeyDeprecationWarningsForTest } from "./streaming-flat-key-deprecation.js";
import {
resolveChannelStreamingBlockCoalesce,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingChunkMode,
resolveChannelStreamingPreviewChunk,
} from "./streaming.js";
const loggerMocks = vi.hoisted(() => ({
warn: vi.fn(),
}));
vi.mock("../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => loggerMocks),
}));
describe("flat streaming key deprecation warning", () => {
beforeEach(() => {
resetFlatStreamingKeyDeprecationWarningsForTest();
loggerMocks.warn.mockClear();
});
afterEach(() => {
resetFlatStreamingKeyDeprecationWarningsForTest();
});
it("warns once per key when the flat fallback is actually used", () => {
expect(resolveChannelStreamingChunkMode({ chunkMode: "newline" })).toBe("newline");
expect(resolveChannelStreamingChunkMode({ chunkMode: "newline" })).toBe("newline");
expect(loggerMocks.warn).toHaveBeenCalledTimes(1);
expect(loggerMocks.warn.mock.calls[0]?.[0]).toContain('"chunkMode"');
expect(loggerMocks.warn.mock.calls[0]?.[0]).toContain("streaming.chunkMode");
expect(resolveChannelStreamingBlockEnabled({ blockStreaming: true })).toBe(true);
expect(resolveChannelStreamingBlockCoalesce({ blockStreamingCoalesce: { idleMs: 5 } })).toEqual(
{ idleMs: 5 },
);
expect(resolveChannelStreamingPreviewChunk({ draftChunk: { minChars: 10 } })).toEqual({
minChars: 10,
});
expect(loggerMocks.warn).toHaveBeenCalledTimes(4);
});
it("stays silent when nested config wins or no flat key is set", () => {
expect(
resolveChannelStreamingChunkMode({
streaming: { chunkMode: "length" },
chunkMode: "newline",
}),
).toBe("length");
expect(resolveChannelStreamingBlockEnabled({ streaming: { block: { enabled: false } } })).toBe(
false,
);
expect(resolveChannelStreamingBlockCoalesce({})).toBeUndefined();
expect(loggerMocks.warn).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,23 @@
// Warn-once state for the deprecated flat streaming key fallback in streaming.ts.
// Lives outside streaming.ts so the test-only reset stays off the public SDK
// surface (openclaw/plugin-sdk/channel-outbound re-exports all of streaming.ts).
import { createSubsystemLogger } from "../logging/subsystem.js";
const log = createSubsystemLogger("channels/streaming");
const warnedFlatStreamingKeys = new Set<string>();
/** @internal Test-only reset for the flat streaming key deprecation warning cache. */
export function resetFlatStreamingKeyDeprecationWarningsForTest(): void {
warnedFlatStreamingKeys.clear();
}
/** Warns once per process per flat key when a resolver used the flat fallback. */
export function warnFlatStreamingKeyFallback(flatKey: string, nestedPath: string): void {
if (warnedFlatStreamingKeys.has(flatKey)) {
return;
}
warnedFlatStreamingKeys.add(flatKey);
log.warn(
`Flat channel streaming key "${flatKey}" is deprecated; move it to streaming.${nestedPath}. The flat fallback is removed after the next release train.`,
);
}
+54 -30
View File
@@ -14,6 +14,7 @@ import type {
TextChunkMode,
} from "../config/types.base.js";
import { asBoolean } from "../utils/boolean.js";
import { warnFlatStreamingKeyFallback } from "./streaming-flat-key-deprecation.js";
export type {
ChannelDeliveryStreamingConfig,
@@ -30,8 +31,8 @@ export type { SlackChannelStreamingConfig } from "../config/types.slack.js";
export type StreamingCompatEntry = {
/**
* Canonical nested streaming config. Some channel schemas (for example
* Mattermost) also accept a scalar mode string or boolean here.
* Canonical nested streaming config. External SDK plugin configs may still
* carry a scalar mode string or boolean here; bundled schemas reject those.
*/
streaming?: unknown;
chunkMode?: unknown;
@@ -40,15 +41,15 @@ export type StreamingCompatEntry = {
draftChunk?: unknown;
};
// Nested streaming config wins. The flat delivery keys (chunkMode,
// blockStreaming, blockStreamingCoalesce, draftChunk) are still canonical for
// channels without a nested streaming schema (Mattermost, WhatsApp, Google
// Chat, IRC, Signal) and for external SDK plugins, so these public resolvers
// keep reading them; dropping the fallback here silently disables configured
// chunking/block delivery for those consumers. Remove the flat reads only
// after the remaining bundled channels migrate to nested schemas + doctor
// rules and the SDK deprecation window closes. Mode-family aliases
// (streamMode, scalar/boolean streaming) are doctor-only and stay unread here.
// Nested streaming config wins. Every bundled channel except Matrix and
// Feishu now uses a nested-only streaming schema with doctor migrating the
// flat spellings, so in-tree the flat delivery keys (chunkMode,
// blockStreaming, blockStreamingCoalesce, draftChunk) are legacy config. The
// fallback reads below serve external SDK plugin configs plus the pending
// Matrix/Feishu migrations and emit a once-per-key deprecation warning;
// remove them (and the flat StreamingCompatEntry fields) once those channels
// migrate and the next release train closes the SDK deprecation window.
// Mode-family aliases (streamMode) are doctor-only and stay unread here.
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
@@ -777,40 +778,63 @@ export function getChannelStreamingConfigObject(
return streaming ? (streaming as ChannelStreamingConfig) : undefined;
}
function resolveWithFlatFallback<T>(params: {
nested: T | undefined;
flat: T | undefined;
flatKey: string;
nestedPath: string;
}): T | undefined {
if (params.nested !== undefined) {
return params.nested;
}
if (params.flat !== undefined) {
warnFlatStreamingKeyFallback(params.flatKey, params.nestedPath);
}
return params.flat;
}
export function resolveChannelStreamingChunkMode(
entry: StreamingCompatEntry | null | undefined,
): TextChunkMode | undefined {
return (
asTextChunkMode(getChannelStreamingConfigObject(entry)?.chunkMode) ??
asTextChunkMode(entry?.chunkMode)
);
return resolveWithFlatFallback({
nested: asTextChunkMode(getChannelStreamingConfigObject(entry)?.chunkMode),
flat: asTextChunkMode(entry?.chunkMode),
flatKey: "chunkMode",
nestedPath: "chunkMode",
});
}
export function resolveChannelStreamingBlockEnabled(
entry: StreamingCompatEntry | null | undefined,
): boolean | undefined {
return (
asBoolean(getChannelStreamingConfigObject(entry)?.block?.enabled) ??
asBoolean(entry?.blockStreaming)
);
return resolveWithFlatFallback({
nested: asBoolean(getChannelStreamingConfigObject(entry)?.block?.enabled),
flat: asBoolean(entry?.blockStreaming),
flatKey: "blockStreaming",
nestedPath: "block.enabled",
});
}
export function resolveChannelStreamingBlockCoalesce(
entry: StreamingCompatEntry | null | undefined,
): BlockStreamingCoalesceConfig | undefined {
return (
asBlockStreamingCoalesceConfig(getChannelStreamingConfigObject(entry)?.block?.coalesce) ??
asBlockStreamingCoalesceConfig(entry?.blockStreamingCoalesce)
);
return resolveWithFlatFallback({
nested: asBlockStreamingCoalesceConfig(getChannelStreamingConfigObject(entry)?.block?.coalesce),
flat: asBlockStreamingCoalesceConfig(entry?.blockStreamingCoalesce),
flatKey: "blockStreamingCoalesce",
nestedPath: "block.coalesce",
});
}
export function resolveChannelStreamingPreviewChunk(
entry: StreamingCompatEntry | null | undefined,
): BlockStreamingChunkConfig | undefined {
return (
asBlockStreamingChunkConfig(getChannelStreamingConfigObject(entry)?.preview?.chunk) ??
asBlockStreamingChunkConfig(entry?.draftChunk)
);
return resolveWithFlatFallback({
nested: asBlockStreamingChunkConfig(getChannelStreamingConfigObject(entry)?.preview?.chunk),
flat: asBlockStreamingChunkConfig(entry?.draftChunk),
flatKey: "draftChunk",
nestedPath: "preview.chunk",
});
}
export function resolveChannelStreamingPreviewToolProgress(
@@ -896,9 +920,9 @@ export function resolveChannelPreviewStreamMode(
entry: StreamingCompatEntry | null | undefined,
defaultMode: "off" | "partial",
): StreamingMode {
// Scalar `streaming` (mode string or boolean) stays supported here: channel
// schemas that never adopted the nested-only shape (for example Mattermost)
// still accept it as canonical config, not as a legacy alias.
// Scalar `streaming` (mode string or boolean) is rejected by every bundled
// channel schema and doctor-migrated to streaming.mode; the read here stays
// only for external SDK plugin configs that predate the nested shape.
const parsedStreaming = parsePreviewStreamingMode(
getChannelStreamingConfigObject(entry)?.mode ?? entry?.streaming,
);
@@ -200,7 +200,7 @@ const DOCTOR_DEPRECATION_COMPAT_RECORDS = [
docsPath: "/channels/channel-routing",
tests: ["src/commands/doctor/shared/channel-legacy-config-migrate.test.ts"],
notes:
"Runtime no longer reads the flat aliases (src/channels/streaming.ts is nested-only); doctor keeps this migration to move shipped configs during upgrade. Channels without a nested streaming schema (signal, irc, googlechat, whatsapp) still treat flat keys as canonical.",
"Bundled channel schemas are nested-only except Matrix and Feishu (migration pending); doctor keeps this migration to move shipped configs during upgrade. src/channels/streaming.ts retains a warn-once flat-key fallback for those channels and external SDK plugin configs until the next release train.",
}),
deprecatedCompatRecord({
code: "doctor-webchat-channel-config",
File diff suppressed because one or more lines are too long
+3 -6
View File
@@ -1,6 +1,6 @@
// Defines common channel messaging configuration types.
import type {
BlockStreamingCoalesceConfig,
ChannelDeliveryStreamingConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
@@ -48,11 +48,8 @@ export type CommonChannelMessagingConfig = {
dms?: Record<string, DmConfig>;
/** Outbound text chunk size (chars). */
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;
/** Delivery streaming config: chunk mode plus block streaming controls. */
streaming?: ChannelDeliveryStreamingConfig;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Channel health monitor overrides for this channel/account. */
+3 -6
View File
@@ -1,6 +1,6 @@
// Defines Google Chat channel configuration types.
import type {
BlockStreamingCoalesceConfig,
ChannelDeliveryStreamingConfig,
DmPolicy,
GroupPolicy,
ReplyToMode,
@@ -96,11 +96,8 @@ export type GoogleChatAccountConfig = {
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";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Delivery streaming config: chunk mode plus block streaming controls. */
streaming?: ChannelDeliveryStreamingConfig;
mediaMaxMb?: number;
/** Control reply threading when reply tags are present (off|first|all|batched). */
replyToMode?: ReplyToMode;
+3 -7
View File
@@ -1,7 +1,7 @@
// Defines WhatsApp channel configuration types.
import type { ReactionLevel } from "../utils/reaction-level.js";
import type {
BlockStreamingCoalesceConfig,
ChannelDeliveryStreamingConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
@@ -85,14 +85,10 @@ type WhatsAppSharedConfig = {
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";
/** Delivery streaming config: chunk mode plus block streaming controls. */
streaming?: ChannelDeliveryStreamingConfig;
/** Maximum media file size in MB. Default: 50. */
mediaMaxMb?: number;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
groups?: Record<string, WhatsAppGroupConfig>;
/** Per-direct-chat prompt overrides keyed by user ID or `*` wildcard. */
direct?: Record<string, WhatsAppDirectConfig>;
+18 -3
View File
@@ -676,15 +676,30 @@ export const BlockStreamingCoalesceSchema = z
})
.strict();
export const TextChunkModeSchema = z.enum(["length", "newline"]);
export const ChannelStreamingBlockSchema = z
.object({
enabled: z.boolean().optional(),
coalesce: BlockStreamingCoalesceSchema.optional(),
})
.strict();
/** Delivery-only nested streaming config for channels without preview modes. */
export const ChannelDeliveryStreamingConfigSchema = z
.object({
chunkMode: TextChunkModeSchema.optional(),
block: ChannelStreamingBlockSchema.optional(),
})
.strict();
export const ReplyRuntimeConfigSchemaShape = {
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
contextVisibility: ContextVisibilityModeSchema.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: ChannelDeliveryStreamingConfigSchema.optional(),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().positive().optional(),
};
+5 -20
View File
@@ -17,7 +17,8 @@ import {
} from "./zod-schema.channels.js";
import {
BlockStreamingChunkSchema,
BlockStreamingCoalesceSchema,
ChannelDeliveryStreamingConfigSchema,
ChannelStreamingBlockSchema,
ContextVisibilityModeSchema,
DmConfigSchema,
DmPolicySchema,
@@ -31,6 +32,7 @@ import {
SecretInputSchema,
ReplyToModeSchema,
RetryConfigSchema,
TextChunkModeSchema,
TtsConfigSchema,
requireAllowlistAllowFrom,
requireOpenAllowFrom,
@@ -75,14 +77,7 @@ 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(),
@@ -107,12 +102,6 @@ const DiscordStreamingProgressSchema = ChannelStreamingProgressSchema;
const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
nativeTaskCards: z.boolean().optional(),
}).strict();
const ChannelDeliveryStreamingConfigSchema = z
.object({
chunkMode: TextChunkModeSchema.optional(),
block: ChannelStreamingBlockSchema.optional(),
})
.strict();
const ChannelPreviewStreamingConfigSchema = z
.object({
@@ -1214,9 +1203,7 @@ export const SignalAccountSchemaBase = 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: ChannelDeliveryStreamingConfigSchema.optional(),
mediaMaxMb: z.number().int().positive().optional(),
replyToMode: ReplyToModeSchema.optional(),
replyToModeByChatType: DirectGroupReplyToModeByChatTypeSchema.optional(),
@@ -1338,9 +1325,7 @@ export const IrcAccountSchemaBase = 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: ChannelDeliveryStreamingConfigSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
healthMonitor: ChannelHealthMonitorSchema,
@@ -3,7 +3,7 @@ import { z } from "zod";
import { ChannelBotLoopProtectionSchema } from "./zod-schema.channels-config.js";
import { ChannelHealthMonitorSchema } from "./zod-schema.channels.js";
import {
BlockStreamingCoalesceSchema,
ChannelDeliveryStreamingConfigSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
@@ -81,9 +81,7 @@ export const GoogleChatAccountSchema = 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: ChannelDeliveryStreamingConfigSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
replyToMode: ReplyToModeSchema.optional(),
actions: z
+2 -4
View File
@@ -8,7 +8,7 @@ import {
ChannelHeartbeatVisibilitySchema,
} from "./zod-schema.channels.js";
import {
BlockStreamingCoalesceSchema,
ChannelDeliveryStreamingConfigSchema,
ContextVisibilityModeSchema,
DmConfigSchema,
DmPolicySchema,
@@ -94,9 +94,7 @@ function buildWhatsAppCommonShape(params: { useDefaults: boolean }) {
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: ChannelDeliveryStreamingConfigSchema.optional(),
groups: WhatsAppGroupsSchema,
direct: WhatsAppDirectSchema,
ackReaction: WhatsAppAckReactionSchema,