Skip to content

Commit 95c6b01

Browse files
committed
feat(discord): show commentary in progress drafts
1 parent c8a35c4 commit 95c6b01

10 files changed

Lines changed: 241 additions & 20 deletions

docs/channels/discord.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,7 @@ Default slash command settings:
696696
maxLines: 8,
697697
maxLineChars: 120,
698698
toolProgress: true,
699+
commentary: false,
699700
},
700701
},
701702
},
@@ -708,6 +709,7 @@ Default slash command settings:
708709
- Media, error, and explicit-reply finals cancel pending preview edits.
709710
- `streaming.preview.toolProgress` (default `true`) controls whether tool/progress updates reuse the preview message.
710711
- Tool/progress rows render as compact emoji + title + detail when available, for example `🛠️ Bash: run tests` or `🔎 Web Search: for "query"`.
712+
- `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.
711713
- `streaming.progress.maxLineChars` controls the per-line progress preview budget. Prose is shortened on word boundaries; command and path details keep useful suffixes.
712714
- `streaming.preview.commandText` / `streaming.progress.commandText` controls command/exec detail in compact progress lines: `raw` (default) or `status` (tool label only).
713715

extensions/discord/src/config-ui-hints.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ export const discordChannelConfigUiHints = {
8989
label: "Discord Progress Tool Lines",
9090
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
9191
},
92+
"streaming.progress.commentary": {
93+
label: "Discord Progress Commentary",
94+
help: "Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged.",
95+
},
9296
"streaming.progress.commandText": {
9397
label: "Discord Progress Command Text",
9498
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',

extensions/discord/src/monitor/message-handler.draft-preview.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
normalizeChannelProgressDraftLineIdentity,
99
resolveChannelProgressDraftMaxLines,
1010
resolveChannelStreamingBlockEnabled,
11+
resolveChannelStreamingProgressCommentary,
1112
resolveChannelStreamingPreviewToolProgress,
1213
resolveChannelStreamingSuppressDefaultToolProgressMessages,
1314
} from "openclaw/plugin-sdk/channel-streaming";
@@ -81,6 +82,8 @@ export function createDiscordDraftPreviewController(params: {
8182
let finalReplyDelivered = false;
8283
const previewToolProgressEnabled =
8384
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
85+
const commentaryProgressEnabled =
86+
Boolean(draftStream) && resolveChannelStreamingProgressCommentary(params.discordConfig);
8487
const suppressDefaultToolProgressMessages =
8588
Boolean(draftStream) &&
8689
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.discordConfig, {
@@ -140,6 +143,7 @@ export function createDiscordDraftPreviewController(params: {
140143
return {
141144
draftStream,
142145
previewToolProgressEnabled,
146+
commentaryProgressEnabled,
143147
suppressDefaultToolProgressMessages,
144148
get isProgressMode() {
145149
return discordStreamMode === "progress";
@@ -266,6 +270,33 @@ export function createDiscordDraftPreviewController(params: {
266270
await renderProgressDraft();
267271
}
268272
},
273+
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
274+
if (!draftStream || discordStreamMode !== "progress" || !commentaryProgressEnabled || !text) {
275+
return;
276+
}
277+
if (finalReplyStarted || finalReplyDelivered) {
278+
return;
279+
}
280+
const normalized = normalizeCommentaryProgressText(text);
281+
if (!normalized) {
282+
return;
283+
}
284+
const lineId = options?.itemId?.trim()
285+
? `commentary:${options.itemId.trim()}`
286+
: `commentary:${normalized}`;
287+
const line: ChannelProgressDraftLine = {
288+
id: lineId,
289+
kind: "item",
290+
text: normalized,
291+
label: "Commentary",
292+
prefix: false,
293+
};
294+
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
295+
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
296+
});
297+
await progressDraftGate.startNow();
298+
await renderProgressDraft();
299+
},
269300
resolvePreviewFinalText(text?: string) {
270301
if (typeof text !== "string") {
271302
return undefined;
@@ -403,6 +434,24 @@ function normalizeReasoningProgressLine(text: string): string {
403434
.trim();
404435
}
405436

437+
function normalizeCommentaryProgressText(text: string): string {
438+
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
439+
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
440+
return "";
441+
}
442+
return cleaned
443+
.split(/\r?\n/u)
444+
.map((line) => line.replace(/\s+/g, " ").trim())
445+
.filter(Boolean)
446+
.map((line) => `_${line}_`)
447+
.join("\n");
448+
}
449+
450+
function isSilentCommentaryProgressText(text: string): boolean {
451+
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
452+
return /^NO_REPLY$/iu.test(normalized);
453+
}
454+
406455
function mergeReasoningProgressText(current: string, incoming: string): string {
407456
if (!current) {
408457
return incoming;

extensions/discord/src/monitor/message-handler.process.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ type DispatchInboundParams = {
111111
detailMode?: "explain" | "raw";
112112
}) => Promise<void> | void;
113113
onItemEvent?: (payload: {
114+
itemId?: string;
114115
kind?: string;
115116
progressText?: string;
116117
summary?: string;
@@ -2286,6 +2287,104 @@ describe("processDiscordMessage draft streaming", () => {
22862287
).toBe(true);
22872288
});
22882289

2290+
it("shows opt-in Discord commentary progress independently from tool progress", async () => {
2291+
const draftStream = createMockDraftStreamForTest();
2292+
2293+
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
2294+
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
2295+
await params?.replyOptions?.onItemEvent?.({
2296+
itemId: "preamble-1",
2297+
kind: "preamble",
2298+
progressText: "Checking the current weather source before summarizing.",
2299+
});
2300+
await params?.replyOptions?.onItemEvent?.({
2301+
itemId: "preamble-1",
2302+
kind: "preamble",
2303+
progressText: "Checking the current weather source before summarizing clearly.",
2304+
});
2305+
await params?.replyOptions?.onItemEvent?.({
2306+
itemId: "preamble-2",
2307+
kind: "preamble",
2308+
progressText: "[[reply_to_current]] Checking route impacts.",
2309+
});
2310+
await params?.replyOptions?.onItemEvent?.({
2311+
itemId: "preamble-3",
2312+
kind: "preamble",
2313+
progressText: "**NO_REPLY",
2314+
});
2315+
await params?.replyOptions?.onItemEvent?.({
2316+
itemId: "tool-1",
2317+
kind: "tool",
2318+
name: "exec",
2319+
progressText: "curl weather api",
2320+
});
2321+
return createNoQueuedDispatchResult();
2322+
});
2323+
2324+
const ctx = await createAutomaticSourceDeliveryContext({
2325+
discordConfig: {
2326+
streaming: {
2327+
mode: "progress",
2328+
progress: {
2329+
label: false,
2330+
toolProgress: false,
2331+
commentary: true,
2332+
},
2333+
},
2334+
},
2335+
});
2336+
2337+
await runProcessDiscordMessage(ctx);
2338+
2339+
expect(draftStream.update).toHaveBeenLastCalledWith(
2340+
"_Checking the current weather source before summarizing clearly._\n_Checking route impacts._",
2341+
);
2342+
const updates = draftStream.update.mock.calls.map((call) => call[0]).join("\n");
2343+
expect(updates).not.toContain("Exec");
2344+
expect(updates).not.toContain("curl weather api");
2345+
expect(updates).not.toContain("reply_to_current");
2346+
expect(updates).not.toContain("NO_REPLY");
2347+
});
2348+
2349+
it("does not update Discord commentary progress after final answer delivery starts", async () => {
2350+
const draftStream = createMockDraftStreamForTest();
2351+
2352+
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
2353+
await params?.replyOptions?.onItemEvent?.({
2354+
itemId: "preamble-1",
2355+
kind: "preamble",
2356+
progressText: "Checking source data.",
2357+
});
2358+
void params?.dispatcher.sendFinalReply({ text: "done" });
2359+
await params?.replyOptions?.onItemEvent?.({
2360+
itemId: "preamble-2",
2361+
kind: "preamble",
2362+
progressText: "Late commentary should not edit the draft.",
2363+
});
2364+
await params?.dispatcher.waitForIdle();
2365+
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
2366+
});
2367+
2368+
const ctx = await createAutomaticSourceDeliveryContext({
2369+
discordConfig: {
2370+
streaming: {
2371+
mode: "progress",
2372+
progress: {
2373+
label: false,
2374+
commentary: true,
2375+
},
2376+
},
2377+
},
2378+
});
2379+
2380+
await runProcessDiscordMessage(ctx);
2381+
2382+
const updates = draftStream.update.mock.calls.map((call) => call[0]);
2383+
expect(updates).toEqual(["_Checking source data._"]);
2384+
expectPreviewEditContent("done");
2385+
expect(deliverDiscordReply).not.toHaveBeenCalled();
2386+
});
2387+
22892388
it("does not start Discord progress drafts for text-only accepted turns", async () => {
22902389
const draftStream = createMockDraftStreamForTest();
22912390

extensions/discord/src/monitor/message-handler.process.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,16 @@ export async function processDiscordMessage(
856856
);
857857
},
858858
onItemEvent: async (payload) => {
859+
if (
860+
draftPreview.commentaryProgressEnabled &&
861+
payload.kind === "preamble" &&
862+
payload.progressText
863+
) {
864+
await draftPreview.pushCommentaryProgress(payload.progressText, {
865+
itemId: payload.itemId,
866+
});
867+
return;
868+
}
859869
await draftPreview.pushToolProgress(
860870
buildChannelProgressDraftLineForEntry(discordConfig, {
861871
event: "item",

src/config/bundled-channel-config-metadata.generated.ts

Lines changed: 16 additions & 16 deletions
Large diffs are not rendered by default.

src/config/types.base.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ export type ChannelStreamingProgressConfig = {
4848
render?: "text" | "rich";
4949
/** Include compact tool/task progress in the draft. Default: true. */
5050
toolProgress?: boolean;
51+
/**
52+
* Include assistant commentary/preamble text in the progress draft.
53+
* Default: false, so interim assistant notes remain hidden unless a channel opts in.
54+
*/
55+
commentary?: boolean;
5156
/** Command/exec progress detail in the draft. "raw" preserves released behavior; "status" shows only the tool label. Default: "raw". */
5257
commandText?: ChannelStreamingCommandTextMode;
5358
};

src/config/zod-schema.providers-core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ const ChannelStreamingProgressSchema = z
100100
maxLineChars: z.number().int().positive().optional(),
101101
render: z.enum(["text", "rich"]).optional(),
102102
toolProgress: z.boolean().optional(),
103+
commentary: z.boolean().optional(),
103104
commandText: z.enum(["raw", "status"]).optional(),
104105
})
105106
.strict();

src/plugin-sdk/channel-streaming.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
resolveChannelStreamingBlockEnabled,
2020
resolveChannelStreamingChunkMode,
2121
resolveChannelStreamingNativeTransport,
22+
resolveChannelStreamingProgressCommentary,
2223
resolveChannelStreamingPreviewCommandText,
2324
resolveChannelStreamingPreviewChunk,
2425
resolveChannelStreamingSuppressDefaultToolProgressMessages,
@@ -98,6 +99,24 @@ describe("channel-streaming", () => {
9899
).toBe(false);
99100
});
100101

102+
it("enables commentary progress only for progress-mode drafts", () => {
103+
expect(
104+
resolveChannelStreamingProgressCommentary({
105+
streaming: { mode: "progress", progress: { commentary: true } },
106+
}),
107+
).toBe(true);
108+
expect(
109+
resolveChannelStreamingProgressCommentary({
110+
streaming: { mode: "partial", progress: { commentary: true } },
111+
}),
112+
).toBe(false);
113+
expect(
114+
resolveChannelStreamingProgressCommentary({
115+
streaming: { mode: "progress" },
116+
}),
117+
).toBe(false);
118+
});
119+
101120
it("falls back to legacy flat fields when the canonical object is absent", () => {
102121
const entry = {
103122
chunkMode: "newline",
@@ -277,6 +296,19 @@ describe("channel-streaming", () => {
277296
lines: ["🛠️ Exec", "plain update"],
278297
}),
279298
).toBe("🛠️ Exec\n• plain update");
299+
expect(
300+
formatChannelProgressDraftText({
301+
entry: { streaming: { progress: { label: false } } },
302+
lines: [
303+
{
304+
kind: "item",
305+
text: "_Checking source data before summarizing._",
306+
label: "Commentary",
307+
prefix: false,
308+
},
309+
],
310+
}),
311+
).toBe("_Checking source data before summarizing._");
280312
});
281313

282314
it("renders progress labels as rolling lines", () => {

src/plugin-sdk/channel-streaming.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ export type ChannelProgressDraftLine = {
270270
detail?: string;
271271
status?: string;
272272
toolName?: string;
273+
prefix?: boolean;
273274
};
274275

275276
function compactStrings(values: readonly (string | undefined | null)[]): string[] {
@@ -635,6 +636,17 @@ export function resolveChannelStreamingPreviewToolProgress(
635636
return asBoolean(config?.preview?.toolProgress) ?? defaultValue;
636637
}
637638

639+
export function resolveChannelStreamingProgressCommentary(
640+
entry: StreamingCompatEntry | null | undefined,
641+
defaultValue = false,
642+
): boolean {
643+
const config = getChannelStreamingConfigObject(entry);
644+
if (resolveChannelPreviewStreamMode(entry, "partial") !== "progress") {
645+
return false;
646+
}
647+
return asBoolean(config?.progress?.commentary) ?? defaultValue;
648+
}
649+
638650
export function resolveChannelStreamingPreviewCommandText(
639651
entry: StreamingCompatEntry | null | undefined,
640652
defaultValue: ChannelStreamingCommandTextMode = "raw",
@@ -961,20 +973,27 @@ export function formatChannelProgressDraftText(params: {
961973
const lines = rawLines
962974
.map((line) => {
963975
const isLabelLine = typeof line === "object" && line !== null && "draftLabel" in line;
976+
const prefix =
977+
!isLabelLine && typeof line === "object" && line !== null ? line.prefix !== false : true;
964978
const rawText = isLabelLine
965979
? line.draftLabel
966980
: typeof line === "string"
967981
? line
968982
: getProgressDraftLineText(line);
969983
const text = compactChannelProgressDraftLine(rawText, maxLineChars);
970-
return text ? { text, isLabelLine } : undefined;
984+
return text ? { text, isLabelLine, prefix } : undefined;
971985
})
972-
.filter((line): line is { text: string; isLabelLine: boolean } => Boolean(line))
986+
.filter((line): line is { text: string; isLabelLine: boolean; prefix: boolean } =>
987+
Boolean(line),
988+
)
973989
.slice(-maxLines)
974-
.map(({ text, isLabelLine }) => {
990+
.map(({ text, isLabelLine, prefix }) => {
975991
const formatted = isLabelLine ? text : formatLine(text);
976992
return {
977-
text: !isLabelLine && shouldPrefixProgressLine(text) ? `${bullet} ${formatted}` : formatted,
993+
text:
994+
!isLabelLine && prefix && shouldPrefixProgressLine(text)
995+
? `${bullet} ${formatted}`
996+
: formatted,
978997
isLabelLine,
979998
};
980999
});

0 commit comments

Comments
 (0)