fix(discord): normalize tagged reasoning in user-visible replies

This commit is contained in:
Tak Hoffman
2026-03-06 18:31:31 -06:00
parent 5320ee7731
commit d65d1b166f
4 changed files with 60 additions and 5 deletions

View File

@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord/reply normalization parity: strip `<think>/<final>` reasoning tags from final/block outbound payload text before preview-finalization and delivery so user-visible replies stay aligned with cleaned final-answer policy even when transcripts contain raw tagged content. (#38291)
- Onboarding/headless Linux daemon probe hardening: treat `systemctl --user is-enabled` probe failures as non-fatal during daemon install flow so onboarding no longer crashes on SSH/headless VPS environments before showing install guidance. (#37297) Thanks @acarbajal-web.
- Memory/QMD mcporter Windows spawn hardening: when `mcporter.cmd` launch fails with `spawn EINVAL`, retry via bare `mcporter` shell resolution so QMD recall can continue instead of falling back to builtin memory search. (#27402) Thanks @i0ivi0i.
- Tools/web_search Brave language-code validation: align `search_lang` handling with Brave-supported codes (including `zh-hans`, `zh-hant`, `en-gb`, and `pt-br`), map common alias inputs (`zh`, `ja`) to valid Brave values, and reject unsupported codes before upstream requests to prevent 422 failures. (#37260) Thanks @heyanming.

View File

@@ -46,4 +46,24 @@ describe("readLatestAssistantReply", () => {
expect(result).toBe("older output");
});
it("normalizes reasoning tags when reading from transcript history", async () => {
callGatewayMock.mockResolvedValue({
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: "<think>private reasoning</think><final>Clean answer</final>",
},
],
},
],
});
const result = await readLatestAssistantReply({ sessionKey: "agent:main:child" });
expect(result).toBe("Clean answer");
});
});

View File

@@ -527,6 +527,24 @@ describe("processDiscordMessage draft streaming", () => {
expect(editMessageDiscord).not.toHaveBeenCalled();
});
it("strips reasoning tags from final payload delivery", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.dispatcher.sendFinalReply({
text: "<think>internal chain of thought</think><final>Visible answer</final>",
});
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
});
await processStreamOffDiscordMessage();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
expect(deliverDiscordReply).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "Visible answer" })],
}),
);
});
it("delivers non-reasoning block payloads to Discord", async () => {
mockDispatchSingleBlockReply({ text: "hello from block stream" });
await processStreamOffDiscordMessage();

View File

@@ -592,6 +592,17 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
await draftStream.flush();
};
const sanitizeVisibleReplyText = (text?: string) => {
if (typeof text !== "string") {
return text;
}
const cleaned = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
if (cleaned.startsWith("Reasoning:\n")) {
return "";
}
return cleaned;
};
// When draft streaming is active, suppress block streaming to avoid double-streaming.
const disableBlockStreamingForDraft = draftStream ? true : undefined;
@@ -609,10 +620,15 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
// Reasoning/thinking payloads should not be delivered to Discord.
return;
}
const visiblePayload =
typeof payload.text === "string"
? { ...payload, text: sanitizeVisibleReplyText(payload.text) }
: payload;
if (draftStream && isFinal) {
await flushDraft();
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const finalText = payload.text;
const hasMedia =
Boolean(visiblePayload.mediaUrl) || (visiblePayload.mediaUrls?.length ?? 0) > 0;
const finalText = visiblePayload.text;
const previewFinalText = resolvePreviewFinalText(finalText);
const previewMessageId = draftStream.messageId();
@@ -622,7 +638,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
!hasMedia &&
typeof previewFinalText === "string" &&
typeof previewMessageId === "string" &&
!payload.isError;
!visiblePayload.isError;
if (canFinalizeViaPreviewEdit) {
await draftStream.stop();
@@ -657,7 +673,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
typeof messageIdAfterStop === "string" &&
typeof previewFinalText === "string" &&
!hasMedia &&
!payload.isError
!visiblePayload.isError
) {
try {
await editMessageDiscord(
@@ -688,7 +704,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
const replyToId = replyReference.use();
await deliverDiscordReply({
replies: [payload],
replies: [visiblePayload],
target: deliverTarget,
token,
accountId,