feat(discord): show commentary in progress drafts (#85200)

Adds opt-in Discord progress-draft commentary for assistant preambles while keeping commentary hidden by default and final delivery unchanged.
Keeps commentary config Discord-specific, strips directive tags/NO_REPLY, and clears stale commentary rows without stopping the active draft stream.
Thanks @bryanpearson.

Co-authored-by: bryanpearson <bryanmpearson@gmail.com>
This commit is contained in:
clawsweeper[bot]
2026-05-29 04:21:06 +01:00
committed by GitHub
parent 5c7f960125
commit 4df1fcf7b3
13 changed files with 450 additions and 23 deletions

View File

@@ -696,6 +696,7 @@ Default slash command settings:
maxLines: 8,
maxLineChars: 120,
toolProgress: true,
commentary: false,
},
},
},
@@ -708,6 +709,7 @@ Default slash command settings:
- Media, error, and explicit-reply finals cancel pending preview edits.
- `streaming.preview.toolProgress` (default `true`) controls whether tool/progress updates reuse the preview message.
- Tool/progress rows render as compact emoji + title + detail when available, for example `🛠️ Bash: run tests` or `🔎 Web Search: for "query"`.
- `streaming.progress.commentary` (default `false`) opts into assistant commentary/preamble text in the temporary progress draft. Commentary is cleaned before display, stays transient, and does not change final answer delivery.
- `streaming.progress.maxLineChars` controls the per-line progress preview budget. Prose is shortened on word boundaries; command and path details keep useful suffixes.
- `streaming.preview.commandText` / `streaming.progress.commandText` controls command/exec detail in compact progress lines: `raw` (default) or `status` (tool label only).

View File

@@ -89,6 +89,10 @@ export const discordChannelConfigUiHints = {
label: "Discord Progress Tool Lines",
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
},
"streaming.progress.commentary": {
label: "Discord Progress Commentary",
help: "Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged.",
},
"streaming.progress.commandText": {
label: "Discord Progress Command Text",
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',

View File

@@ -57,6 +57,35 @@ describe("createDiscordDraftStream", () => {
expect(stream.messageId()).toBe("m1");
});
it("deletes the current preview without stopping later draft updates", async () => {
const rest = {
post: vi.fn().mockResolvedValueOnce({ id: "m1" }).mockResolvedValueOnce({ id: "m2" }),
patch: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
};
const stream = createDiscordDraftStream({
rest: rest as never,
channelId: "c1",
throttleMs: 250,
});
stream.update("temporary commentary");
await stream.flush();
await stream.deleteCurrentMessage();
stream.update("tool progress");
await stream.flush();
expect(rest.delete).toHaveBeenCalledWith(Routes.channelMessage("c1", "m1"));
expect(rest.post).toHaveBeenNthCalledWith(2, Routes.channelMessages("c1"), {
body: {
content: "tool progress",
allowed_mentions: { parse: [] },
},
});
expect(rest.patch).not.toHaveBeenCalled();
expect(stream.messageId()).toBe("m2");
});
it("suppresses mentions in preview creates and edits", async () => {
const rest = {
post: vi.fn(async () => ({ id: "m1" })),

View File

@@ -18,6 +18,7 @@ type DiscordDraftStream = {
flush: () => Promise<void>;
messageId: () => string | undefined;
clear: () => Promise<void>;
deleteCurrentMessage: () => Promise<void>;
discardPending: () => Promise<void>;
seal: () => Promise<void>;
stop: () => Promise<void>;
@@ -146,6 +147,22 @@ export function createDiscordDraftStream(params: {
lastSentText = "";
loop.resetPending();
};
const deleteCurrentMessage = async () => {
loop.resetPending();
await loop.waitForInFlight();
const messageId = streamMessageId;
streamMessageId = undefined;
lastSentText = "";
loop.resetThrottleWindow();
if (!isValidStreamMessageId(messageId)) {
return;
}
try {
await deleteStreamMessage(messageId);
} catch (err) {
params.warn?.(`discord stream preview cleanup failed: ${formatErrorMessage(err)}`);
}
};
params.log?.(`discord stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
@@ -154,6 +171,7 @@ export function createDiscordDraftStream(params: {
flush: loop.flush,
messageId: () => streamMessageId,
clear,
deleteCurrentMessage,
discardPending,
seal,
stop,

View File

@@ -8,6 +8,7 @@ import {
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingProgressCommentary,
resolveChannelStreamingPreviewToolProgress,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
} from "openclaw/plugin-sdk/channel-outbound";
@@ -81,6 +82,8 @@ export function createDiscordDraftPreviewController(params: {
let finalReplyDelivered = false;
const previewToolProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
const commentaryProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingProgressCommentary(params.discordConfig);
const suppressDefaultToolProgressMessages =
Boolean(draftStream) &&
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.discordConfig, {
@@ -119,6 +122,34 @@ export function createDiscordDraftPreviewController(params: {
onStart: () => renderProgressDraft({ flush: true }),
});
const clearProgressDraftLine = async (lineId: string) => {
const nextLines = previewToolProgressLines.filter(
(line) => typeof line !== "object" || line.id?.trim() !== lineId,
);
if (nextLines.length === previewToolProgressLines.length) {
return;
}
previewToolProgressLines = nextLines;
if (!progressDraftGate.hasStarted) {
return;
}
const previewText = formatChannelProgressDraftText({
entry: params.discordConfig,
lines: previewToolProgressLines,
seed: progressSeed,
});
if (previewText) {
await renderProgressDraft();
return;
}
lastPartialText = "";
draftText = "";
hasStreamedMessage = false;
if (draftStream?.messageId()) {
await draftStream.deleteCurrentMessage();
}
};
const resetProgressState = () => {
lastPartialText = "";
draftText = "";
@@ -140,6 +171,7 @@ export function createDiscordDraftPreviewController(params: {
return {
draftStream,
previewToolProgressEnabled,
commentaryProgressEnabled,
suppressDefaultToolProgressMessages,
get isProgressMode() {
return discordStreamMode === "progress";
@@ -268,6 +300,38 @@ export function createDiscordDraftPreviewController(params: {
await renderProgressDraft();
}
},
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
if (!draftStream || discordStreamMode !== "progress" || !commentaryProgressEnabled) {
return;
}
if (finalReplyStarted || finalReplyDelivered) {
return;
}
const itemId = options?.itemId?.trim();
if (!text && !itemId) {
return;
}
const normalized = normalizeCommentaryProgressText(text ?? "");
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
if (!normalized) {
if (lineId) {
await clearProgressDraftLine(lineId);
}
return;
}
const line: ChannelProgressDraftLine = {
id: lineId,
kind: "item",
text: normalized,
label: "Commentary",
prefix: false,
};
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
});
await progressDraftGate.startNow();
await renderProgressDraft();
},
resolvePreviewFinalText(text?: string) {
if (typeof text !== "string") {
return undefined;
@@ -405,6 +469,24 @@ function normalizeReasoningProgressLine(text: string): string {
.trim();
}
function normalizeCommentaryProgressText(text: string): string {
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
return "";
}
return cleaned
.split(/\r?\n/u)
.map((line) => line.replace(/\s+/g, " ").trim())
.filter(Boolean)
.map((line) => `_${line}_`)
.join("\n");
}
function isSilentCommentaryProgressText(text: string): boolean {
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
return /^NO_REPLY$/iu.test(normalized);
}
function mergeReasoningProgressText(
current: string,
incoming: string,

View File

@@ -27,6 +27,9 @@ function createMockDraftStream() {
clear: vi.fn(async () => {
messageId = undefined;
}),
deleteCurrentMessage: vi.fn(async () => {
messageId = undefined;
}),
discardPending: vi.fn(async () => {}),
seal: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
@@ -127,6 +130,7 @@ type DispatchInboundParams = {
detailMode?: "explain" | "raw";
}) => Promise<void> | void;
onItemEvent?: (payload: {
itemId?: string;
kind?: string;
progressText?: string;
summary?: string;
@@ -2494,6 +2498,196 @@ describe("processDiscordMessage draft streaming", () => {
).toBe(true);
});
it.each([
["unset", undefined],
["false", false],
])("hides Discord commentary progress when commentary is %s", async (_label, commentary) => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Checking private context before replying.",
});
await params?.replyOptions?.onItemEvent?.({
itemId: "tool-1",
kind: "tool",
name: "exec",
progressText: "curl weather api",
});
return createNoQueuedDispatchResult();
});
const progress =
commentary === undefined
? {
label: false,
toolProgress: true,
}
: {
label: false,
toolProgress: true,
commentary,
};
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress,
},
},
});
await runProcessDiscordMessage(ctx);
const updates = draftStream.update.mock.calls.map((call) => call[0]).join("\n");
expect(updates).toContain("Exec");
expect(updates).toContain("curl weather api");
expect(updates).not.toContain("Checking private context");
});
it("shows opt-in Discord commentary progress independently from tool progress", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Checking the current weather source before summarizing.",
});
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Checking the current weather source before summarizing clearly.",
});
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-2",
kind: "preamble",
progressText: "[[reply_to_current]] Checking route impacts.",
});
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-2",
kind: "preamble",
progressText: "NO_REPLY",
});
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-3",
kind: "preamble",
progressText: "**NO_REPLY",
});
await params?.replyOptions?.onItemEvent?.({
itemId: "tool-1",
kind: "tool",
name: "exec",
progressText: "curl weather api",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: false,
toolProgress: false,
commentary: true,
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenLastCalledWith(
"_Checking the current weather source before summarizing clearly._",
);
const updates = draftStream.update.mock.calls.map((call) => call[0]).join("\n");
expect(updates).not.toContain("Exec");
expect(updates).not.toContain("curl weather api");
expect(updates).not.toContain("reply_to_current");
expect(updates).not.toContain("NO_REPLY");
});
it("keeps Discord progress drafts usable after the last commentary line becomes silent", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Temporary note.",
});
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "NO_REPLY",
});
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: false,
commentary: true,
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.deleteCurrentMessage).toHaveBeenCalledTimes(1);
expect(draftStream.clear).not.toHaveBeenCalled();
expect(draftStream.update).toHaveBeenLastCalledWith("🛠️ Exec");
});
it("does not update Discord commentary progress after final answer delivery starts", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Checking source data.",
});
void params?.dispatcher.sendFinalReply({ text: "done" });
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-2",
kind: "preamble",
progressText: "Late commentary should not edit the draft.",
});
await params?.dispatcher.waitForIdle();
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: false,
commentary: true,
},
},
},
});
await runProcessDiscordMessage(ctx);
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates).toEqual(["_Checking source data._"]);
expectPreviewEditContent("done");
expect(deliverDiscordReply).not.toHaveBeenCalled();
});
it("does not start Discord progress drafts for text-only accepted turns", async () => {
const draftStream = createMockDraftStreamForTest();

View File

@@ -980,6 +980,14 @@ export async function processDiscordMessage(
);
},
onItemEvent: async (payload) => {
if (payload.kind === "preamble") {
if (draftPreview.commentaryProgressEnabled && payload.progressText) {
await draftPreview.pushCommentaryProgress(payload.progressText, {
itemId: payload.itemId,
});
}
return;
}
await draftPreview.pushToolProgress(
buildChannelProgressDraftLineForEntry(discordConfig, {
event: "item",

View File

@@ -274,6 +274,7 @@ export type ChannelProgressDraftLine = {
detail?: string;
status?: string;
toolName?: string;
prefix?: boolean;
};
function compactStrings(values: readonly (string | undefined | null)[]): string[] {
@@ -644,6 +645,18 @@ export function resolveChannelStreamingPreviewToolProgress(
return asBoolean(config?.preview?.toolProgress) ?? defaultValue;
}
export function resolveChannelStreamingProgressCommentary(
entry: StreamingCompatEntry | null | undefined,
defaultValue = false,
): boolean {
const config = getChannelStreamingConfigObject(entry);
if (resolveChannelPreviewStreamMode(entry, "partial") !== "progress") {
return false;
}
const progress = asObjectRecord(config?.progress);
return asBoolean(progress?.commentary) ?? defaultValue;
}
export function resolveChannelStreamingPreviewCommandText(
entry: StreamingCompatEntry | null | undefined,
defaultValue: ChannelStreamingCommandTextMode = "raw",
@@ -968,20 +981,27 @@ export function formatChannelProgressDraftText(params: {
const lines = rawLines
.map((line) => {
const isLabelLine = typeof line === "object" && line !== null && "draftLabel" in line;
const prefix =
!isLabelLine && typeof line === "object" && line !== null ? line.prefix !== false : true;
const rawText = isLabelLine
? line.draftLabel
: typeof line === "string"
? line
: getProgressDraftLineText(line);
const text = compactChannelProgressDraftLine(rawText, maxLineChars);
return text ? { text, isLabelLine } : undefined;
return text ? { text, isLabelLine, prefix } : undefined;
})
.filter((line): line is { text: string; isLabelLine: boolean } => Boolean(line))
.filter((line): line is { text: string; isLabelLine: boolean; prefix: boolean } =>
Boolean(line),
)
.slice(-maxLines)
.map(({ text, isLabelLine }) => {
.map(({ text, isLabelLine, prefix }) => {
const formatted = isLabelLine ? text : formatLine(text);
return {
text: !isLabelLine && shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted,
text:
!isLabelLine && prefix && shouldPrefixProgressLine(text)
? `${bullet} ${formatted}`
: formatted,
isLabelLine,
};
});

File diff suppressed because one or more lines are too long

View File

@@ -4,6 +4,7 @@ import { buildConfigSchema, lookupConfigSchema } from "./schema.js";
import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { OpenClawSchema } from "./zod-schema.js";
import { DiscordConfigSchema, TelegramConfigSchema } from "./zod-schema.providers-core.js";
describe("config schema", () => {
type SchemaInput = NonNullable<Parameters<typeof buildConfigSchema>[0]>;
@@ -236,6 +237,8 @@ describe("config schema", () => {
expect(progressPropsFor("slack")).toHaveProperty("nativeTaskCards");
expect(progressPropsFor("discord")).not.toHaveProperty("nativeTaskCards");
expect(progressPropsFor("telegram")).not.toHaveProperty("nativeTaskCards");
expect(progressPropsFor("discord")).toHaveProperty("commentary");
expect(progressPropsFor("telegram")).not.toHaveProperty("commentary");
expect(res.uiHints["channels.matrix"]?.label).toBe("Matrix");
expect(res.uiHints["channels.matrix.accessToken"]?.sensitive).toBe(true);
expect(res.uiHints["channels.matrix.streaming.progress.label"]?.label).toBe(
@@ -249,6 +252,7 @@ describe("config schema", () => {
expect(res.uiHints["channels.discord.streaming.progress.toolProgress"]?.label).toBe(
"Discord Progress Tool Lines",
);
expect(res.uiHints["channels.telegram.streaming.progress.commentary"]).toBeUndefined();
expect(res.uiHints["channels.mattermost.streaming.progress.label"]?.label).toBe(
"Mattermost Progress Label",
);
@@ -406,6 +410,26 @@ describe("config schema", () => {
).toBe(false);
});
it("accepts progress commentary only for Discord streaming config", () => {
expect(
DiscordConfigSchema.safeParse({
streaming: {
mode: "progress",
progress: { commentary: true },
},
}).success,
).toBe(true);
expect(
TelegramConfigSchema.safeParse({
streaming: {
mode: "progress",
progress: { commentary: true },
},
}).success,
).toBe(false);
});
it("keeps per-agent model overrides limited to model selection", () => {
const result = OpenClawSchema.safeParse({
agents: {

View File

@@ -1,5 +1,6 @@
import type {
ChannelPreviewStreamingConfig,
ChannelStreamingProgressConfig,
ContextVisibilityMode,
DmPolicy,
GroupPolicy,
@@ -17,6 +18,13 @@ import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./typ
import type { TtsConfig } from "./types.tts.js";
export type DiscordStreamMode = "off" | "partial" | "block" | "progress";
export type DiscordStreamingProgressConfig = ChannelStreamingProgressConfig & {
/** Include assistant commentary/preamble text in the progress draft. Default: false. */
commentary?: boolean;
};
export type DiscordChannelStreamingConfig = Omit<ChannelPreviewStreamingConfig, "progress"> & {
progress?: DiscordStreamingProgressConfig;
};
export type DiscordPluralKitConfig = {
enabled?: boolean;
@@ -375,7 +383,7 @@ export type DiscordAccountConfig = {
*/
suppressEmbeds?: boolean;
/** Streaming + chunking settings. Prefer this nested shape over legacy flat keys. */
streaming?: ChannelPreviewStreamingConfig;
streaming?: DiscordChannelStreamingConfig;
/**
* Soft max line count per Discord message.
* Discord clients can clip/collapse very tall messages; splitting by lines

View File

@@ -102,6 +102,9 @@ const ChannelStreamingProgressSchema = z
commandText: z.enum(["raw", "status"]).optional(),
})
.strict();
const DiscordStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
commentary: z.boolean().optional(),
}).strict();
const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
nativeTaskCards: z.boolean().optional(),
}).strict();
@@ -117,6 +120,9 @@ const ChannelPreviewStreamingConfigSchema = z
const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
preview: TelegramStreamingPreviewSchema.optional(),
}).strict();
const DiscordPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
progress: DiscordStreamingProgressSchema.optional(),
}).strict();
const SlackStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
nativeTransport: z.boolean().optional(),
progress: SlackStreamingProgressSchema.optional(),
@@ -650,7 +656,7 @@ export const DiscordAccountSchema = z
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
suppressEmbeds: z.boolean().optional(),
streaming: ChannelPreviewStreamingConfigSchema.optional(),
streaming: DiscordPreviewStreamingConfigSchema.optional(),
maxLinesPerMessage: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(),
retry: RetryConfigSchema,

View File

@@ -19,6 +19,7 @@ import {
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingChunkMode,
resolveChannelStreamingNativeTransport,
resolveChannelStreamingProgressCommentary,
resolveChannelStreamingPreviewCommandText,
resolveChannelStreamingPreviewChunk,
resolveChannelStreamingSuppressDefaultToolProgressMessages,
@@ -98,6 +99,24 @@ describe("channel-streaming", () => {
).toBe(false);
});
it("enables commentary progress only for progress-mode drafts", () => {
expect(
resolveChannelStreamingProgressCommentary({
streaming: { mode: "progress", progress: { commentary: true } },
}),
).toBe(true);
expect(
resolveChannelStreamingProgressCommentary({
streaming: { mode: "partial", progress: { commentary: true } },
}),
).toBe(false);
expect(
resolveChannelStreamingProgressCommentary({
streaming: { mode: "progress" },
}),
).toBe(false);
});
it("falls back to legacy flat fields when the canonical object is absent", () => {
const entry = {
chunkMode: "newline",
@@ -277,6 +296,19 @@ describe("channel-streaming", () => {
lines: ["🛠️ Exec", "plain update"],
}),
).toBe("🛠️ Exec\n• plain update");
expect(
formatChannelProgressDraftText({
entry: { streaming: { progress: { label: false } } },
lines: [
{
kind: "item",
text: "_Checking source data before summarizing._",
label: "Commentary",
prefix: false,
},
],
}),
).toBe("_Checking source data before summarizing._");
});
it("renders progress labels as rolling lines", () => {