Skip to content

Commit 0c210e5

Browse files
authored
fix(discord): deliver reasoning replies (#95029)
1 parent 38807ff commit 0c210e5

5 files changed

Lines changed: 49 additions & 41 deletions

File tree

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@ describe("formatDiscordReplySkip", () => {
1616
);
1717
});
1818

19-
it("renders the reasoning-payload reason with the same shape", () => {
19+
it("renders the internal-only-payload reason with the same shape", () => {
2020
expect(
2121
formatDiscordReplySkip({
2222
kind: "block",
23-
reason: "reasoning payload",
23+
reason: "internal-only payload",
2424
target: "channel:456",
2525
sessionKey: "agent:friday:discord:channel:456",
2626
}),
2727
).toBe(
28-
"discord block reply skipped (reasoning payload): target=channel:456 session=agent:friday:discord:channel:456",
28+
"discord block reply skipped (internal-only payload): target=channel:456 session=agent:friday:discord:channel:456",
2929
);
3030
});
3131

@@ -43,11 +43,11 @@ describe("formatDiscordReplySkip", () => {
4343
expect(
4444
formatDiscordReplySkip({
4545
kind: "tool",
46-
reason: "reasoning payload",
46+
reason: "internal-only payload",
4747
target: "channel:c1",
4848
sessionKey: "",
4949
}),
50-
).toBe("discord tool reply skipped (reasoning payload): target=channel:c1");
50+
).toBe("discord tool reply skipped (internal-only payload): target=channel:c1");
5151
});
5252

5353
it("preserves the kind discriminant in the message prefix", () => {

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2639,17 +2639,20 @@ describe("processDiscordMessage draft streaming", () => {
26392639
expect(deliverDiscordReply).not.toHaveBeenCalled();
26402640
});
26412641

2642-
it("suppresses reasoning payload delivery to Discord", async () => {
2642+
it("delivers reasoning block payloads to Discord", async () => {
26432643
mockDispatchSingleBlockReply({ text: "thinking...", isReasoning: true });
26442644
await processStreamOffDiscordMessage();
26452645

2646-
expect(deliverDiscordReply).not.toHaveBeenCalled();
2646+
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
2647+
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
2648+
replies: [{ text: "thinking...", isReasoning: true }],
2649+
});
26472650
});
26482651

2649-
it("suppresses reasoning-tagged final payload delivery to Discord", async () => {
2652+
it("delivers reasoning-tagged final payload to Discord", async () => {
26502653
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
26512654
await params?.dispatcher.sendFinalReply({
2652-
text: "Reasoning:\nthis should stay internal",
2655+
text: "Reasoning:\nthis should be visible",
26532656
isReasoning: true,
26542657
});
26552658
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
@@ -2661,8 +2664,10 @@ describe("processDiscordMessage draft streaming", () => {
26612664

26622665
await runProcessDiscordMessage(ctx);
26632666

2664-
expect(deliverDiscordReply).not.toHaveBeenCalled();
2665-
expect(editMessageDiscord).not.toHaveBeenCalled();
2667+
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
2668+
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
2669+
replies: [{ text: "this should be visible", isReasoning: true }],
2670+
});
26662671
});
26672672

26682673
it("delivers non-reasoning block payloads to Discord", async () => {

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

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,7 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
113113
return !resolveSendableOutboundReplyParts(payload).hasMedia;
114114
}
115115

116-
type DiscordReplySkipReason =
117-
| "aborted before delivery"
118-
| "reasoning payload"
119-
| "internal-only payload";
116+
type DiscordReplySkipReason = "aborted before delivery" | "internal-only payload";
120117

121118
export function formatDiscordReplySkip(params: {
122119
kind: "tool" | "block" | "final";
@@ -609,18 +606,6 @@ async function processDiscordMessageInner(
609606
);
610607
return null;
611608
}
612-
if (payload.isReasoning) {
613-
// Reasoning/thinking payloads should not be delivered to Discord.
614-
logVerbose(
615-
formatDiscordReplySkip({
616-
kind: info.kind,
617-
reason: "reasoning payload",
618-
target: deliverTarget,
619-
sessionKey: ctxPayload.SessionKey,
620-
}),
621-
);
622-
return null;
623-
}
624609
if (draftPreview.draftStream && draftPreview.isProgressMode && info.kind === "block") {
625610
const reply = resolveSendableOutboundReplyParts(payload);
626611
if (!reply.hasMedia && !payload.isError) {
@@ -652,18 +637,6 @@ async function processDiscordMessageInner(
652637
return { visibleReplySent: false };
653638
}
654639
const isFinal = info.kind === "final";
655-
if (payload.isReasoning) {
656-
// Reasoning/thinking payloads should not be delivered to Discord.
657-
logVerbose(
658-
formatDiscordReplySkip({
659-
kind: info.kind,
660-
reason: "reasoning payload",
661-
target: deliverTarget,
662-
sessionKey: ctxPayload.SessionKey,
663-
}),
664-
);
665-
return { visibleReplySent: false };
666-
}
667640
if (
668641
isFinal &&
669642
!options?.allowFallbackOnlyToolWarning &&

extensions/discord/src/monitor/reply-delivery.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,21 @@ describe("deliverDiscordReply", () => {
141141
expect(sendOptions.rest).toBe(rest);
142142
});
143143

144+
it("formats reasoning replies as visible Discord payloads before shared outbound", async () => {
145+
await deliverDiscordReply({
146+
replies: [{ text: "Because it helps", isReasoning: true }],
147+
target: "channel:101",
148+
token: "token",
149+
accountId: "default",
150+
runtime,
151+
cfg,
152+
textLimit: 2000,
153+
kind: "block",
154+
});
155+
156+
expect(firstDeliverParams().payloads).toEqual([{ text: "Thinking\n\n_Because it helps_" }]);
157+
});
158+
144159
it("fails when shared outbound accepts a final reply but delivers no Discord message", async () => {
145160
sendDurableMessageBatchMock.mockResolvedValueOnce({ status: "sent", results: [] });
146161

extensions/discord/src/monitor/reply-delivery.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Discord plugin module implements reply delivery behavior.
2-
import { resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
2+
import { formatReasoningMessage, resolveAgentAvatar } from "openclaw/plugin-sdk/agent-runtime";
33
import {
44
buildOutboundSessionContext,
55
sendDurableMessageBatch,
@@ -156,6 +156,19 @@ function resolveDiscordDeliveryOptions(params: {
156156
};
157157
}
158158

159+
function formatDiscordReasoningPayload(payload: ReplyPayload): ReplyPayload {
160+
if (payload.isReasoning !== true) {
161+
return payload;
162+
}
163+
const text = typeof payload.text === "string" ? payload.text.trim() : "";
164+
const nextPayload: ReplyPayload = {
165+
...payload,
166+
text: formatReasoningMessage(text),
167+
};
168+
delete nextPayload.isReasoning;
169+
return nextPayload;
170+
}
171+
159172
export async function deliverDiscordReply(params: {
160173
cfg: OpenClawConfig;
161174
replies: ReplyPayload[];
@@ -178,7 +191,9 @@ export async function deliverDiscordReply(params: {
178191
void params.runtime;
179192

180193
const delivery = resolveDiscordDeliveryOptions(params);
181-
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, { kind: params.kind });
194+
const payloads = sanitizeDiscordFrontChannelReplyPayloads(params.replies, {
195+
kind: params.kind,
196+
}).map(formatDiscordReasoningPayload);
182197
if (payloads.length === 0) {
183198
return;
184199
}

0 commit comments

Comments
 (0)