mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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).
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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" })),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user