mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-05 03:53:32 +00:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user