mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user