Skip to content

Commit 6354569

Browse files
fix(message-tool): normalize send body aliases (#84102)
1 parent e0fda55 commit 6354569

5 files changed

Lines changed: 166 additions & 36 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ Docs: https://docs.openclaw.ai
253253
- CLI: preserve embedded equals signs in inline root option values instead of truncating after the second separator. (#83995) Thanks @ThiagoCAltoe.
254254
- Matrix/config: accept `messages.queue.byChannel.matrix` queue overrides and keep queue provider schema/type keys aligned for Matrix, Google Chat, and Mattermost. Thanks @bdjben.
255255
- CLI: format `openclaw acp client` failures through the shared error formatter so object-shaped errors stay readable instead of printing `[object Object]`. Fixes #83904. (#84080)
256+
- Agents/message-tool: normalize non-canonical message body aliases (`SendMessage`, `content`, `text`) to `message` before send validation so model-emitted tool calls with aliased body keys are delivered instead of rejected. (#84079)
256257
- Providers/Ollama: default unknown-capabilities models to tool-capable so discovered native Ollama models can use tools when `/api/show` omits capabilities. (#84055) Thanks @dutifulbob.
257258
- Codex app-server: disable native Code Mode, user MCP, and app-backed plugin execution while OpenClaw sandboxing is active, routing shell access through `sandbox_exec`/`sandbox_process` instead. (#84388) Thanks @joshavant.
258259
- Installer/Windows: launch `install.ps1` onboarding as an attached child process so fresh native Windows installs do not freeze visibly at `Starting setup...` or corrupt the wizard's terminal rendering.

src/agents/tools/message-tool.ts

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
parseThreadSessionSuffix,
2929
} from "../../routing/session-key.js";
3030
import { normalizeOptionalString } from "../../shared/string-coerce.js";
31-
import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js";
31+
import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js";
3232
import { normalizeMessageChannel } from "../../utils/message-channel.js";
3333
import { resolveSessionAgentId } from "../agent-scope.js";
3434
import { listAllChannelSupportedActions, listChannelSupportedActions } from "../channel-tools.js";
@@ -54,40 +54,6 @@ function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean {
5454
return EXPLICIT_TARGET_ACTIONS.has(action);
5555
}
5656

57-
function stripFormattedReasoningMessage(text: string): string {
58-
const stripped = stripReasoningTagsFromText(text);
59-
const lines = stripped.split(/\r?\n/u);
60-
const prefix = lines[0]?.trim();
61-
if (prefix !== "Reasoning:" && !/^Thinking\.{0,3}$/u.test(prefix ?? "")) {
62-
return stripped;
63-
}
64-
if (/^Thinking\.{0,3}$/u.test(prefix ?? "")) {
65-
const firstBodyLine = lines.slice(1).find((line) => line.trim());
66-
const trimmedBodyLine = firstBodyLine?.trim() ?? "";
67-
if (
68-
!trimmedBodyLine ||
69-
!(
70-
trimmedBodyLine.startsWith("_") &&
71-
trimmedBodyLine.endsWith("_") &&
72-
trimmedBodyLine.length >= 2
73-
)
74-
) {
75-
return stripped;
76-
}
77-
}
78-
79-
let index = 1;
80-
while (index < lines.length) {
81-
const trimmed = lines[index]?.trim() ?? "";
82-
if (!trimmed || (trimmed.startsWith("_") && trimmed.endsWith("_") && trimmed.length >= 2)) {
83-
index += 1;
84-
continue;
85-
}
86-
break;
87-
}
88-
return lines.slice(index).join("\n").trim();
89-
}
90-
9157
function normalizeToolCallIdForIdempotencyKey(toolCallId: unknown): string | undefined {
9258
const value = normalizeOptionalString(toolCallId);
9359
if (!value) {

src/infra/outbound/message-action-runner.send-validation.test.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../../config/config.js";
33
import { setActivePluginRegistry } from "../../plugins/runtime.js";
44
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
@@ -278,3 +278,119 @@ describe("runMessageAction send validation", () => {
278278
).rejects.toThrow(/use action "poll" instead of "send"/i);
279279
});
280280
});
281+
282+
describe("message body alias normalization", () => {
283+
beforeEach(() => {
284+
setActivePluginRegistry(
285+
createTestRegistry([
286+
{
287+
pluginId: "workspace",
288+
source: "test",
289+
plugin: workspaceTestPlugin,
290+
},
291+
]),
292+
);
293+
});
294+
295+
afterEach(() => {
296+
setActivePluginRegistry(createTestRegistry([]));
297+
vi.restoreAllMocks();
298+
});
299+
300+
it.each([
301+
{ alias: "SendMessage", value: "hello from alias" },
302+
{ alias: "content", value: "hello from content" },
303+
{ alias: "text", value: "hello from text" },
304+
])("normalizes $alias alias to message for send", async ({ alias, value }) => {
305+
const result = await runDrySend({
306+
cfg: workspaceConfig,
307+
actionParams: {
308+
channel: "workspace",
309+
target: "#C12345678",
310+
[alias]: value,
311+
},
312+
toolContext: { currentChannelId: "C12345678" },
313+
});
314+
315+
expect(result.kind).toBe("send");
316+
});
317+
318+
it("does not overwrite an explicit message with an alias", async () => {
319+
const result = await runDrySend({
320+
cfg: workspaceConfig,
321+
actionParams: {
322+
channel: "workspace",
323+
target: "#C12345678",
324+
message: "explicit",
325+
SendMessage: "alias value",
326+
},
327+
toolContext: { currentChannelId: "C12345678" },
328+
});
329+
330+
expect(result.kind).toBe("send");
331+
});
332+
333+
it("emits a diagnostic warning when normalizing an alias", async () => {
334+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
335+
336+
await runDrySend({
337+
cfg: workspaceConfig,
338+
actionParams: {
339+
channel: "workspace",
340+
target: "#C12345678",
341+
SendMessage: "alias body",
342+
},
343+
toolContext: { currentChannelId: "C12345678" },
344+
});
345+
346+
expect(warnSpy).toHaveBeenCalledWith(
347+
expect.stringContaining('[message-tool] normalized alias "SendMessage" to "message"'),
348+
);
349+
});
350+
351+
it.each([
352+
{
353+
name: "reasoning tag",
354+
SendMessage: "<think>internal reasoning</think>Visible answer",
355+
},
356+
{
357+
name: "formatted reasoning prefix",
358+
SendMessage: "Reasoning:\n_internal plan_\n\nVisible answer",
359+
},
360+
])("sanitizes SendMessage alias $name before delivery", async ({ SendMessage }) => {
361+
const result = await runMessageAction({
362+
cfg: emptyConfig,
363+
action: "send",
364+
params: {
365+
SendMessage,
366+
},
367+
toolContext: {
368+
currentChannelProvider: "webchat",
369+
},
370+
sessionKey: "agent:main",
371+
sourceReplyDeliveryMode: "message_tool_only",
372+
});
373+
374+
expect(result).toMatchObject({
375+
kind: "send",
376+
payload: {
377+
sourceReply: {
378+
text: "Visible answer",
379+
},
380+
},
381+
});
382+
});
383+
384+
it("still rejects send with no message and no alias", async () => {
385+
await expect(
386+
runDrySend({
387+
cfg: workspaceConfig,
388+
actionParams: {
389+
channel: "workspace",
390+
target: "#C12345678",
391+
},
392+
toolContext: { currentChannelId: "C12345678" },
393+
}),
394+
).rejects.toThrow(/message required/i);
395+
});
396+
});

src/infra/outbound/message-action-runner.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
normalizeOptionalString,
3939
} from "../../shared/string-coerce.js";
4040
import { stripUnsupportedCitationControlMarkers } from "../../shared/text/citation-control-markers.js";
41+
import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js";
4142
import {
4243
GATEWAY_CLIENT_MODES,
4344
GATEWAY_CLIENT_NAMES,
@@ -749,6 +750,17 @@ async function buildSendPayloadParts(params: {
749750
if (actionParams.pin === true && actionParams.delivery == null) {
750751
actionParams.delivery = { pin: { enabled: true } };
751752
}
753+
// Models may emit message body under non-canonical aliases.
754+
if (typeof actionParams.message !== "string" || !actionParams.message.trim()) {
755+
for (const alias of ["SendMessage", "content", "text"] as const) {
756+
const value = actionParams[alias];
757+
if (typeof value === "string" && value.trim()) {
758+
actionParams.message = stripFormattedReasoningMessage(value);
759+
console.warn(`[message-tool] normalized alias "${alias}" to "message" for send action`);
760+
break;
761+
}
762+
}
763+
}
752764
const mediaHint =
753765
readStringParam(actionParams, "media", { trim: false }) ??
754766
readStringParam(actionParams, "mediaUrl", { trim: false }) ??
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { stripReasoningTagsFromText } from "./reasoning-tags.js";
2+
3+
export function stripFormattedReasoningMessage(text: string): string {
4+
const stripped = stripReasoningTagsFromText(text);
5+
const lines = stripped.split(/\r?\n/u);
6+
const prefix = lines[0]?.trim();
7+
if (prefix !== "Reasoning:" && !/^Thinking\.{0,3}$/u.test(prefix ?? "")) {
8+
return stripped;
9+
}
10+
if (/^Thinking\.{0,3}$/u.test(prefix ?? "")) {
11+
const firstBodyLine = lines.slice(1).find((line) => line.trim());
12+
const trimmedBodyLine = firstBodyLine?.trim() ?? "";
13+
if (
14+
!trimmedBodyLine ||
15+
!(
16+
trimmedBodyLine.startsWith("_") &&
17+
trimmedBodyLine.endsWith("_") &&
18+
trimmedBodyLine.length >= 2
19+
)
20+
) {
21+
return stripped;
22+
}
23+
}
24+
25+
let index = 1;
26+
while (index < lines.length) {
27+
const trimmed = lines[index]?.trim() ?? "";
28+
if (!trimmed || (trimmed.startsWith("_") && trimmed.endsWith("_") && trimmed.length >= 2)) {
29+
index += 1;
30+
continue;
31+
}
32+
break;
33+
}
34+
return lines.slice(index).join("\n").trim();
35+
}

0 commit comments

Comments
 (0)