fix(discord): accumulate reasoning progress deltas

Fix Discord progress-mode reasoning streams so delta chunks accumulate before display formatting, preserving raw Thinking/Reasoning-prefixed content and balanced truncation.\n\nFixes #83983.\n\nThanks @giodl73-repo for the fix and live Discord proof.
This commit is contained in:
Gio Della-Libera
2026-06-02 03:35:29 -07:00
committed by GitHub
parent 6997453098
commit a25338f2b7
3 changed files with 379 additions and 16 deletions
@@ -1,4 +1,4 @@
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
import { EmbeddedBlockChunker, formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
import {
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
@@ -6,6 +6,7 @@ import {
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
normalizeChannelProgressDraftLineIdentity,
resolveChannelProgressDraftMaxLineChars,
resolveChannelProgressDraftMaxLines,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingProgressCommentary,
@@ -281,6 +282,13 @@ export function createDiscordDraftPreviewController(params: {
if (!normalized) {
return;
}
const displayLine = formatReasoningProgressDisplayLine(
normalized,
resolveChannelProgressDraftMaxLineChars(params.discordConfig),
);
if (!displayLine) {
return;
}
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
const priorIndex =
lastReasoningProgressLine === undefined
@@ -288,13 +296,13 @@ export function createDiscordDraftPreviewController(params: {
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
if (priorIndex >= 0) {
previewToolProgressLines = [...previewToolProgressLines];
previewToolProgressLines[priorIndex] = normalized;
previewToolProgressLines[priorIndex] = displayLine;
} else {
previewToolProgressLines = [...previewToolProgressLines, normalized].slice(
previewToolProgressLines = [...previewToolProgressLines, displayLine].slice(
-resolveChannelProgressDraftMaxLines(params.discordConfig),
);
}
lastReasoningProgressLine = normalized;
lastReasoningProgressLine = displayLine;
}
const progressActive = await progressDraftGate.noteWork();
if (progressActive && progressDraftGate.hasStarted) {
@@ -465,11 +473,57 @@ export function createDiscordDraftPreviewController(params: {
function normalizeReasoningProgressLine(text: string): string {
return text
.replace(/^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i, "")
.replace(
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
"",
)
.replace(/\s+/g, " ")
.trim();
}
function normalizeReasoningProgressInput(text: string): string {
const normalized = normalizeReasoningProgressLine(text);
const italic = normalized.match(/^_(.*)_$/u);
return (italic?.[1] ?? normalized).trim();
}
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalizedText = normalizeReasoningProgressInput(text);
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
if (!formatted) {
return "";
}
if (Array.from(formatted).length <= maxChars) {
return formatted;
}
const italic = formatted.match(/^_(.*)_$/u);
if (!italic) {
return compactReasoningProgressDisplayLine(formatted, maxChars);
}
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
return body ? `_${body}_` : "";
}
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
const normalized = text.replace(/\s+/g, " ").trim();
const chars = Array.from(normalized);
if (chars.length <= maxChars) {
return normalized;
}
if (maxChars <= 1) {
return "…";
}
const head = chars
.slice(0, maxChars - 1)
.join("")
.trimEnd();
const boundary = head.search(/\s+\S*$/u);
if (boundary > Math.floor(maxChars * 0.6)) {
return `${head.slice(0, boundary).trimEnd()}`;
}
return `${head}`;
}
function normalizeCommentaryProgressText(text: string): string {
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
@@ -512,7 +566,9 @@ function mergeReasoningProgressText(
}
function isReasoningSnapshotText(text: string): boolean {
return /^\s*(?:>\s*)?(?:Reasoning:|Thinking\.{0,3})\s*/i.test(text);
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
text,
);
}
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {
@@ -3123,6 +3123,320 @@ describe("processDiscordMessage draft streaming", () => {
expect(updates.join("\n")).not.toContain("Thinking\n");
});
it("accumulates reasoning deltas in Discord progress drafts", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
for (const text of ["Considering", " plugin", " installation", "!"]) {
await params?.replyOptions?.onReasoningStream?.({ text });
}
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _Considering plugin installation!_",
);
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates.join("\n")).not.toContain("• _!_");
});
it("preserves raw reasoning content that starts with Thinking", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking" });
await params?.replyOptions?.onReasoningStream?.({ text: " through the install plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _Thinking through the install plan_",
);
});
it("preserves raw reasoning content that starts with Thinking colon", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking: compare install paths" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _Thinking: compare install paths_",
);
});
it("preserves raw reasoning content that starts with Reasoning colon", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning: compare install paths" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _Reasoning: compare install paths_",
);
});
it("strips legacy Reasoning newline wrappers from progress snapshots", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Reasoning:\ncompare install paths",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _compare install paths_",
);
});
it("strips legacy Thinking ellipsis display wrappers from progress snapshots", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Thinking...\n\n_compare install paths_",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _compare install paths_",
);
});
it("preserves raw reasoning content that starts with a Thinking line", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking\nthrough the plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _Thinking through the plan_",
);
});
it("appends raw reasoning chunks that start with Thinking", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking about the plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _I was Thinking about the plan_",
);
});
it("appends raw reasoning chunks that start with Thinking ellipsis", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
await params?.replyOptions?.onReasoningStream?.({ text: "Thinking... through the plan" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _I was Thinking... through the plan_",
);
});
it("appends raw reasoning chunks that start with Reasoning colon", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({ text: "I was " });
await params?.replyOptions?.onReasoningStream?.({ text: "Reasoning: through edge cases" });
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
},
},
},
});
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _I was Reasoning: through edge cases_",
);
});
it("keeps reasoning italics balanced when progress lines truncate", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
await params?.replyOptions?.onReasoningStream?.({
text: "Thinking through a very detailed installation plan with many steps",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Clawing...",
maxLineChars: 36,
},
},
},
});
await runProcessDiscordMessage(ctx);
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
const reasoningLine = lastUpdate?.split("\n").at(-1);
expect(reasoningLine).toMatch(/^ _.*_$/u);
expect(reasoningLine?.match(/_/gu)).toHaveLength(2);
});
it("replaces reasoning snapshots instead of appending duplicates", async () => {
const draftStream = createMockDraftStreamForTest();
@@ -3152,9 +3466,7 @@ describe("processDiscordMessage draft streaming", () => {
await runProcessDiscordMessage(ctx);
expect(draftStream.update).toHaveBeenCalledWith(
"Clawing...\n\n🛠️ Exec\n• _Reading _ _Checking_",
);
expect(draftStream.update.mock.calls.at(-1)?.[0]).toContain("_Reading Checking_");
const updates = draftStream.update.mock.calls.map((call) => call[0]);
expect(updates.join("\n")).not.toContain("_Checking Reading");
});
@@ -1,10 +1,6 @@
import path from "node:path";
import { MessageFlags } from "discord-api-types/v10";
import {
formatReasoningMessage,
resolveAckReaction,
resolveHumanDelayConfig,
} from "openclaw/plugin-sdk/agent-runtime";
import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import {
createStatusReactionController,
DEFAULT_TIMING,
@@ -987,8 +983,7 @@ async function processDiscordMessageInner(
: undefined,
onReasoningStream: async (payload) => {
await statusReactions.setThinking();
const formattedText = payload?.text ? formatReasoningMessage(payload.text) : undefined;
await draftPreview.pushReasoningProgress(formattedText, {
await draftPreview.pushReasoningProgress(payload?.text, {
snapshot: payload?.isReasoningSnapshot === true,
});
},