From 35f6071d8d5841b185db070826263c9c64f22aaf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 14:35:30 -0700 Subject: [PATCH] fix(mattermost): accept streaming config --- CHANGELOG.md | 1 + docs/.generated/config-baseline.sha256 | 4 +- .../mattermost/src/config-schema-core.ts | 35 ++++ .../mattermost/src/config-schema.test.ts | 19 ++ .../src/mattermost/accounts.test.ts | 20 ++ .../mattermost/src/mattermost/accounts.ts | 4 + .../src/mattermost/monitor.authz.test.ts | 1 + .../mattermost/src/mattermost/monitor.ts | 37 +++- .../mattermost/slash-http.send-config.test.ts | 1 + .../src/mattermost/slash-http.test.ts | 1 + .../src/mattermost/slash-state.test.ts | 1 + extensions/mattermost/src/types.ts | 6 + ...ndled-channel-config-metadata.generated.ts | 190 ++++++++++++++++++ 13 files changed, 309 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a95c7b05e13..3cbab5ffd7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Google Meet: grant Meet media permissions through the Playwright browser context when CDP grants do not affect the attached Chrome page, and report in-call microphone/speaker permission problems instead of marking realtime speech ready. - Control UI/WebChat: collapse duplicate in-flight internal text sends onto the active Gateway run so rapid repeat submits do not start fresh `agent:main:main` dispatches. Fixes #75737. Thanks @dsdsddd1 and @BunsDev. +- Mattermost: accept the documented `channels.mattermost.streaming` config and honor `streaming: "off"` by disabling draft preview posts. Thanks @vincentkoc. - Channels/streaming: expose `streaming.progress.label`, `labels`, `maxLines`, and `toolProgress` in bundled channel config metadata so progress draft settings appear in config, docs, and control surfaces. Thanks @vincentkoc. - Channels/streaming: normalize whitespace and case for `streaming.progress.label: "auto"` so progress draft labels keep using the built-in label pool instead of rendering a literal `auto` title. Thanks @vincentkoc. - Gateway/install: prefer supported system Node over nvm/fnm/volta/asdf/mise when regenerating managed gateway services, so `gateway install --force` no longer recreates service definitions that doctor immediately flags as version-manager-backed. Fixes #76339. Thanks @brokemac79. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 4bee7427bd9..b5e8112ab93 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -056760c0a86627641d8e2993cc0cc987820dc4289c40c67dc8c2c1e8970c1849 config-baseline.json +5603f93164f1bed3b39714b813c7597e188321fff07cfbb6980d7198a69da162 config-baseline.json 5b5ebd95939d75496597d9858a375e27544812d0f79dc3b4bf87c794ada2ba08 config-baseline.core.json -7b207901b595ad527026b1f357f63a5cd33123a72eeb66bdac24a8f2e8bb1ac8 config-baseline.channel.json +c83a29196d34b4aff4849f63ae8850298441c367811d928e1ab2efe787eae520 config-baseline.channel.json 055fae0d0067a751dc10125af7421da45633f73519c94c982d02b0c4eb2bdf67 config-baseline.plugin.json diff --git a/extensions/mattermost/src/config-schema-core.ts b/extensions/mattermost/src/config-schema-core.ts index 665c1cd488c..ecc8fb9c60e 100644 --- a/extensions/mattermost/src/config-schema-core.ts +++ b/extensions/mattermost/src/config-schema-core.ts @@ -78,6 +78,40 @@ const MattermostNetworkSchema = z .strict() .optional(); +const MattermostStreamingModeSchema = z.enum(["off", "partial", "block", "progress"]); +const MattermostStreamingProgressSchema = z + .object({ + label: z.union([z.string(), z.literal(false)]).optional(), + labels: z.array(z.string()).optional(), + maxLines: z.number().int().positive().optional(), + toolProgress: z.boolean().optional(), + }) + .strict(); +const MattermostStreamingPreviewSchema = z + .object({ + toolProgress: z.boolean().optional(), + }) + .strict(); +const MattermostStreamingBlockSchema = z + .object({ + enabled: z.boolean().optional(), + 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 MattermostAccountSchemaBase = z .object({ name: z.string().optional(), @@ -97,6 +131,7 @@ const MattermostAccountSchemaBase = z 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: z.enum(["off", "first", "all", "batched"]).optional(), diff --git a/extensions/mattermost/src/config-schema.test.ts b/extensions/mattermost/src/config-schema.test.ts index 5e62b4c44fd..a2f9c1aba55 100644 --- a/extensions/mattermost/src/config-schema.test.ts +++ b/extensions/mattermost/src/config-schema.test.ts @@ -29,6 +29,25 @@ describe("MattermostConfigSchema", () => { expect(result.success).toBe(true); }); + it("accepts documented streaming modes and progress config", () => { + const result = MattermostConfigSchema.safeParse({ + streaming: { + mode: "progress", + progress: { + label: "Shelling", + maxLines: 4, + toolProgress: false, + }, + }, + accounts: { + quiet: { + streaming: "off", + }, + }, + }); + expect(result.success).toBe(true); + }); + it("accepts groups with requireMention", () => { const result = MattermostConfigSchema.safeParse({ groups: { diff --git a/extensions/mattermost/src/mattermost/accounts.test.ts b/extensions/mattermost/src/mattermost/accounts.test.ts index 7bcf77a20b4..1430b1642ac 100644 --- a/extensions/mattermost/src/mattermost/accounts.test.ts +++ b/extensions/mattermost/src/mattermost/accounts.test.ts @@ -135,4 +135,24 @@ describe("resolveMattermostReplyToMode", () => { callbackPath: "/hooks/work", }); }); + + it("resolves documented streaming mode from account config", () => { + const account = resolveMattermostAccount({ + cfg: { + channels: { + mattermost: { + streaming: "partial", + accounts: { + work: { + streaming: "off", + }, + }, + }, + }, + }, + accountId: "work", + }); + + expect(account.streamingMode).toBe("off"); + }); }); diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index ee3303ef80c..3a73124d49d 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -5,6 +5,8 @@ import { resolveChannelStreamingBlockCoalesce, resolveChannelStreamingBlockEnabled, resolveChannelStreamingChunkMode, + resolveChannelPreviewStreamMode, + type StreamingMode, } from "openclaw/plugin-sdk/channel-streaming"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js"; @@ -34,6 +36,7 @@ export type ResolvedMattermostAccount = { requireMention?: boolean; textChunkLimit?: number; chunkMode?: MattermostAccountConfig["chunkMode"]; + streamingMode: StreamingMode; blockStreaming?: boolean; blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"]; }; @@ -120,6 +123,7 @@ export function resolveMattermostAccount(params: { requireMention, textChunkLimit: merged.textChunkLimit, chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode, + streamingMode: resolveChannelPreviewStreamMode(merged, "partial"), blockStreaming: resolveChannelStreamingBlockEnabled(merged) ?? merged.blockStreaming, blockStreamingCoalesce: resolveChannelStreamingBlockCoalesce(merged) ?? merged.blockStreamingCoalesce, diff --git a/extensions/mattermost/src/mattermost/monitor.authz.test.ts b/extensions/mattermost/src/mattermost/monitor.authz.test.ts index addbccd10c9..0c2aa9f6d2a 100644 --- a/extensions/mattermost/src/mattermost/monitor.authz.test.ts +++ b/extensions/mattermost/src/mattermost/monitor.authz.test.ts @@ -13,6 +13,7 @@ const accountFixture: ResolvedMattermostAccount = { baseUrl: "https://chat.example.com", botTokenSource: "config", baseUrlSource: "config", + streamingMode: "partial", config: {}, }; diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 67afc2e520a..729129c2065 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -281,6 +281,20 @@ type MattermostDraftPreviewState = { finalizedViaPreviewPost: boolean; }; +function createDisabledMattermostDraftStream(): ReturnType { + const noopAsync = async () => {}; + return { + update: () => {}, + flush: noopAsync, + postId: () => undefined, + clear: noopAsync, + discardPending: noopAsync, + seal: noopAsync, + stop: noopAsync, + forceNewMessage: () => {}, + }; +} + type MattermostDraftPreviewDeliverParams = { payload: ReplyPayload; info: { kind: "tool" | "block" | "final" }; @@ -1619,14 +1633,17 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }, }, }); - const draftStream = createMattermostDraftStream({ - client, - channelId, - rootId: effectiveReplyToId, - throttleMs: 1200, - log: logVerboseMessage, - warn: logVerboseMessage, - }); + const draftPreviewEnabled = account.streamingMode !== "off"; + const draftStream = draftPreviewEnabled + ? createMattermostDraftStream({ + client, + channelId, + rootId: effectiveReplyToId, + throttleMs: 1200, + log: logVerboseMessage, + warn: logVerboseMessage, + }) + : createDisabledMattermostDraftStream(); let lastPartialText = ""; const previewState: MattermostDraftPreviewState = { finalizedViaPreviewPost: false, @@ -1815,7 +1832,9 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} disableBlockStreaming: true, onModelSelected, onPartialReply: (payload) => { - updateDraftFromPartial(payload.text); + if (account.streamingMode !== "progress") { + updateDraftFromPartial(payload.text); + } }, onAssistantMessageStart: () => { lastPartialText = ""; diff --git a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts index 8497a6dd32b..fbfdc9f2d39 100644 --- a/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.send-config.test.ts @@ -205,6 +205,7 @@ const accountFixture: ResolvedMattermostAccount = { baseUrl: "https://chat.example.com", botTokenSource: "config", baseUrlSource: "config", + streamingMode: "partial", config: {}, }; diff --git a/extensions/mattermost/src/mattermost/slash-http.test.ts b/extensions/mattermost/src/mattermost/slash-http.test.ts index 6da0ca88703..641af52234f 100644 --- a/extensions/mattermost/src/mattermost/slash-http.test.ts +++ b/extensions/mattermost/src/mattermost/slash-http.test.ts @@ -68,6 +68,7 @@ const accountFixture: ResolvedMattermostAccount = { baseUrl: "https://chat.example.com", botTokenSource: "config", baseUrlSource: "config", + streamingMode: "partial", config: {}, }; diff --git a/extensions/mattermost/src/mattermost/slash-state.test.ts b/extensions/mattermost/src/mattermost/slash-state.test.ts index e40f303c68f..26d4432d966 100644 --- a/extensions/mattermost/src/mattermost/slash-state.test.ts +++ b/extensions/mattermost/src/mattermost/slash-state.test.ts @@ -15,6 +15,7 @@ function createResolvedMattermostAccount(accountId: string): ResolvedMattermostA enabled: true, botTokenSource: "config", baseUrlSource: "config", + streamingMode: "partial", config: {}, }; } diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts index 78db9bc47d7..20c30646e05 100644 --- a/extensions/mattermost/src/types.ts +++ b/extensions/mattermost/src/types.ts @@ -1,3 +1,7 @@ +import type { + ChannelPreviewStreamingConfig, + StreamingMode, +} from "openclaw/plugin-sdk/channel-streaming"; import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./runtime-api.js"; import type { SecretInput } from "./secret-input.js"; @@ -51,6 +55,8 @@ export type MattermostAccountConfig = { 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. */ diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 6430ec6fa45..60ce3e106a3 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -8198,6 +8198,101 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "string", enum: ["length", "newline"], }, + streaming: { + anyOf: [ + { + type: "string", + enum: ["off", "partial", "block", "progress"], + }, + { + type: "boolean", + }, + { + type: "object", + properties: { + mode: { + type: "string", + enum: ["off", "partial", "block", "progress"], + }, + chunkMode: { + type: "string", + enum: ["length", "newline"], + }, + preview: { + type: "object", + properties: { + toolProgress: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + progress: { + type: "object", + properties: { + label: { + anyOf: [ + { + type: "string", + }, + { + type: "boolean", + const: false, + }, + ], + }, + labels: { + type: "array", + items: { + type: "string", + }, + }, + maxLines: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + }, + toolProgress: { + type: "boolean", + }, + }, + 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, + }, + }, + additionalProperties: false, + }, + ], + }, blockStreaming: { type: "boolean", }, @@ -8500,6 +8595,101 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ type: "string", enum: ["length", "newline"], }, + streaming: { + anyOf: [ + { + type: "string", + enum: ["off", "partial", "block", "progress"], + }, + { + type: "boolean", + }, + { + type: "object", + properties: { + mode: { + type: "string", + enum: ["off", "partial", "block", "progress"], + }, + chunkMode: { + type: "string", + enum: ["length", "newline"], + }, + preview: { + type: "object", + properties: { + toolProgress: { + type: "boolean", + }, + }, + additionalProperties: false, + }, + progress: { + type: "object", + properties: { + label: { + anyOf: [ + { + type: "string", + }, + { + type: "boolean", + const: false, + }, + ], + }, + labels: { + type: "array", + items: { + type: "string", + }, + }, + maxLines: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + }, + toolProgress: { + type: "boolean", + }, + }, + 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, + }, + }, + additionalProperties: false, + }, + ], + }, blockStreaming: { type: "boolean", },