fix(gateway): hide webchat reasoning payloads

This commit is contained in:
Peter Steinberger
2026-04-26 09:00:47 +01:00
parent 164aaa48db
commit 4823288b3b
4 changed files with 78 additions and 1 deletions

View File

@@ -43,6 +43,26 @@ describe("buildWebchatAudioContentBlocksFromReplyPayloads", () => {
);
});
it("suppresses reasoning payload audio", async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-webchat-audio-"));
const audioPath = path.join(tmpDir, "clip.mp3");
fs.writeFileSync(audioPath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
[
{
text: "Reasoning:\n_step_",
mediaUrl: audioPath,
trustedLocalMedia: true,
isReasoning: true,
},
],
{ localRoots: [tmpDir] },
);
expect(blocks).toHaveLength(0);
});
it("skips remote URLs", async () => {
const blocks = await buildWebchatAudioContentBlocksFromReplyPayloads([
{ mediaUrl: "https://example.com/a.mp3", trustedLocalMedia: true },
@@ -212,6 +232,18 @@ describe("buildWebchatAssistantMessageFromReplyPayloads", () => {
});
});
it("suppresses reasoning payload media transcripts", async () => {
const message = await buildWebchatAssistantMessageFromReplyPayloads([
{
text: "Reasoning:\n_step_",
mediaUrl: "data:image/png;base64,cG5n",
isReasoning: true,
},
]);
expect(message).toBeNull();
});
it("suppresses control tokens and falls back to synthetic image text", async () => {
const message = await buildWebchatAssistantMessageFromReplyPayloads([
{

View File

@@ -162,6 +162,9 @@ export async function buildWebchatAudioContentBlocksFromReplyPayloads(
const seen = new Set<string>();
const blocks: Array<Record<string, unknown>> = [];
for (const payload of payloads) {
if (payload.isReasoning === true) {
continue;
}
const parts = resolveSendableOutboundReplyParts(payload);
for (const raw of parts.mediaUrls) {
const url = raw.trim();
@@ -194,6 +197,9 @@ export async function buildWebchatAssistantMessageFromReplyPayloads(
let hasImage = false;
for (const payload of payloads) {
if (payload.isReasoning === true) {
continue;
}
const visibleText = payload.text?.trim();
const text =
visibleText && !isSuppressedControlReplyText(visibleText) ? visibleText : undefined;

View File

@@ -26,6 +26,7 @@ const mockState = vi.hoisted(() => ({
sensitiveMedia?: boolean;
replyToId?: string;
replyToCurrent?: boolean;
isReasoning?: boolean;
} | null,
dispatchedReplies: [] as Array<{
kind: "tool" | "block" | "final";
@@ -36,6 +37,7 @@ const mockState = vi.hoisted(() => ({
trustedLocalMedia?: boolean;
replyToId?: string;
replyToCurrent?: boolean;
isReasoning?: boolean;
};
}>,
dispatchError: null as Error | null,
@@ -114,6 +116,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
sensitiveMedia?: boolean;
replyToId?: string;
replyToCurrent?: boolean;
isReasoning?: boolean;
}) => boolean;
sendBlockReply: (payload: {
text?: string;
@@ -122,6 +125,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
trustedLocalMedia?: boolean;
replyToId?: string;
replyToCurrent?: boolean;
isReasoning?: boolean;
}) => boolean;
sendToolResult: (payload: {
text?: string;
@@ -130,6 +134,7 @@ vi.mock("../../auto-reply/dispatch.js", () => ({
trustedLocalMedia?: boolean;
replyToId?: string;
replyToCurrent?: boolean;
isReasoning?: boolean;
}) => boolean;
markComplete: () => void;
waitForIdle: () => Promise<void>;
@@ -599,6 +604,31 @@ describe("chat directive tag stripping for non-streaming final payloads", () =>
expect(JSON.stringify(payload?.message)).not.toContain("MEDIA:data:image/png;base64,cG5n");
});
it("suppresses reasoning payloads from webchat transcript replies", async () => {
createTranscriptFixture("openclaw-chat-send-reasoning-hidden-");
mockState.dispatchedReplies = [
{
kind: "final",
payload: { text: "Reasoning:\n_step_", isReasoning: true },
},
{
kind: "final",
payload: { text: "final answer" },
},
];
const respond = vi.fn();
const context = createChatContext();
const payload = await runNonStreamingChatSend({
context,
respond,
idempotencyKey: "idem-reasoning-hidden",
});
expect(JSON.stringify(payload?.message)).toContain("final answer");
expect(JSON.stringify(payload?.message)).not.toContain("Reasoning");
});
it("chat.inject keeps message defined when directive tag is the only content", async () => {
createTranscriptFixture("openclaw-chat-inject-directive-only-");
const respond = vi.fn();

View File

@@ -131,6 +131,9 @@ type ChatAbortRequester = {
/** True when a reply payload carries at least one media reference (mediaUrl or mediaUrls). */
function isMediaBearingPayload(payload: ReplyPayload): boolean {
if (payload.isReasoning === true) {
return false;
}
if (payload.mediaUrl?.trim()) {
return true;
}
@@ -227,6 +230,9 @@ type SideResultPayload = {
function buildTranscriptReplyText(payloads: ReplyPayload[]): string {
const chunks = payloads
.map((payload) => {
if (payload.isReasoning === true) {
return "";
}
const parts = resolveSendableOutboundReplyParts(payload);
const lines: string[] = [];
const replyToId = sanitizeReplyDirectiveId(payload.replyToId);
@@ -301,7 +307,10 @@ async function buildAssistantDisplayContentFromReplyPayloads(params: {
onManagedImagePrepareError?: (message: string) => void;
}): Promise<AssistantDisplayContentBlock[] | undefined> {
const rawTextPayloadCount = params.payloads.filter(
(payload) => typeof payload.text === "string" && payload.text.trim().length > 0,
(payload) =>
payload.isReasoning !== true &&
typeof payload.text === "string" &&
payload.text.trim().length > 0,
).length;
const normalized = normalizeReplyPayloadsForDelivery(params.payloads);
if (normalized.length === 0) {