fix(message-tool): apply responsePrefix to outbound sends

* fix(message-tool): apply messages.responsePrefix to outbound sends

* fix(message-tool): interpolate responsePrefix templates on sends and skip unresolved model tokens
This commit is contained in:
Wynne668
2026-07-01 18:57:30 +08:00
committed by GitHub
parent 36e7f214db
commit 2af2eb2dfb
2 changed files with 177 additions and 0 deletions

View File

@@ -343,6 +343,146 @@ describe("runMessageAction core send routing", () => {
expect(sendText).toHaveBeenCalledOnce();
});
it("prepends messages.responsePrefix to message-tool sends", async () => {
const sendText = registerSlackTextPlugin();
await runMessageAction({
cfg: {
channels: { slack: { enabled: true } },
messages: { responsePrefix: "[Nexus]" },
} as OpenClawConfig,
action: "send",
params: {
channel: "slack",
target: "channel:OTHER",
message: "hello world",
},
dryRun: false,
});
expect(sendText).toHaveBeenCalledOnce();
expect(firstMockArg(sendText, "send text").text).toBe("[Nexus] hello world");
});
it("does not double-apply responsePrefix when the text already carries it", async () => {
const sendText = registerSlackTextPlugin();
await runMessageAction({
cfg: {
channels: { slack: { enabled: true } },
messages: { responsePrefix: "[Nexus]" },
} as OpenClawConfig,
action: "send",
params: {
channel: "slack",
target: "channel:OTHER",
message: "[Nexus] already prefixed",
},
dryRun: false,
});
expect(sendText).toHaveBeenCalledOnce();
expect(firstMockArg(sendText, "send text").text).toBe("[Nexus] already prefixed");
});
it("leaves media-only sends without a responsePrefix", async () => {
const sendMedia = vi.fn().mockResolvedValue({
channel: "slack",
messageId: "m1",
chatId: "C123",
});
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "slack",
source: "test",
plugin: {
...createOutboundTestPlugin({
id: "slack",
outbound: {
deliveryMode: "direct",
sendText: vi.fn().mockResolvedValue({
channel: "slack",
messageId: "t1",
chatId: "C123",
}),
sendMedia,
},
}),
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ enabled: true }),
isConfigured: () => true,
},
},
},
]),
);
await runMessageAction({
cfg: {
channels: { slack: { enabled: true } },
messages: { responsePrefix: "[Nexus]" },
} as OpenClawConfig,
action: "send",
params: {
channel: "slack",
target: "channel:OTHER",
media: "https://example.com/cat.png",
},
dryRun: false,
});
expect(sendMedia).toHaveBeenCalledOnce();
expect(firstMockArg(sendMedia, "send media").text ?? "").toBe("");
});
it("resolves identity templates in responsePrefix on message-tool sends", async () => {
const sendText = registerSlackTextPlugin();
await runMessageAction({
cfg: {
channels: { slack: { enabled: true } },
messages: { responsePrefix: "[{identity.name}]" },
agents: { list: [{ id: "main", identity: { name: "Nexus" } }] },
} as OpenClawConfig,
action: "send",
params: {
channel: "slack",
target: "channel:OTHER",
message: "hello world",
},
agentId: "main",
dryRun: false,
});
expect(sendText).toHaveBeenCalledOnce();
expect(firstMockArg(sendText, "send text").text).toBe("[Nexus] hello world");
});
it("skips responsePrefix on tool sends when a model template cannot be resolved", async () => {
const sendText = registerSlackTextPlugin();
await runMessageAction({
cfg: {
channels: { slack: { enabled: true } },
messages: { responsePrefix: "[{provider}/{model}]" },
} as OpenClawConfig,
action: "send",
params: {
channel: "slack",
target: "channel:OTHER",
message: "hello world",
},
dryRun: false,
});
expect(sendText).toHaveBeenCalledOnce();
// A tool send performs no live model selection, so the unresolved template is dropped
// rather than leaked as a literal `{provider}/{model}` prefix.
expect(firstMockArg(sendText, "send text").text).toBe("hello world");
});
it("uses best-effort delivery for explicit current-source message-tool-only replies", async () => {
const sendText = registerSlackTextPlugin();

View File

@@ -7,6 +7,7 @@ import {
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolveAgentIdentity, resolveResponsePrefix } from "../../agents/identity.js";
import type { AgentToolResult } from "../../agents/runtime/index.js";
import {
readPositiveIntegerParam,
@@ -15,6 +16,7 @@ import {
} from "../../agents/tools/common.js";
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
import { resolveResponsePrefixTemplate } from "../../auto-reply/reply/response-prefix-template.js";
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
import type { InboundEventKind } from "../../channels/inbound-event/kind.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
@@ -1044,6 +1046,10 @@ async function buildSendPayloadParts(params: {
};
}
// Detects leftover `{variable}` placeholders after prefix interpolation. Non-global so
// `.test()` stays stateless; mirrors the variable shape in response-prefix-template.ts.
const UNRESOLVED_PREFIX_VAR_PATTERN = /\{[a-zA-Z][a-zA-Z0-9.]*\}/;
async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
const {
cfg,
@@ -1070,6 +1076,37 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
agentId,
});
// `message(action=send)` crosses into other conversations, so mirror the direct-reply
// egress and prepend messages.responsePrefix here too; otherwise the disambiguation
// prefix is silently dropped on tool sends while replies keep it. Interpolate the
// template like normalize-reply.ts so identity tokens render. model/provider/thinking
// tokens need the live model selection that a tool send never performs, so when any
// placeholder stays unresolved we skip prefixing instead of leaking a literal `{model}`.
// The startsWith guard matches normalize-reply.ts and keeps re-runs idempotent.
const responsePrefix = resolveResponsePrefixTemplate(
resolveResponsePrefix(cfg, agentId ?? "", {
channel,
accountId: accountId ?? undefined,
}),
{ identityName: normalizeOptionalString(resolveAgentIdentity(cfg, agentId ?? "")?.name) },
);
const prefixHasUnresolvedVar =
responsePrefix !== undefined && UNRESOLVED_PREFIX_VAR_PATTERN.test(responsePrefix);
if (
responsePrefix &&
!prefixHasUnresolvedVar &&
sendPayload.message &&
!sendPayload.message.startsWith(responsePrefix)
) {
const prefixedMessage = `${responsePrefix} ${sendPayload.message}`;
sendPayload = {
...sendPayload,
message: prefixedMessage,
payload: { ...sendPayload.payload, text: prefixedMessage },
};
applySendPayloadPartsToActionParams(params, sendPayload);
}
const replyToIsExplicit = Boolean(readStringParam(params, "replyTo"));
resolveAndApplyOutboundReplyToId(params, {
channel,