diff --git a/src/infra/outbound/message-action-runner.core-send.test.ts b/src/infra/outbound/message-action-runner.core-send.test.ts index 677c1e474eea..df9d6d7fa73e 100644 --- a/src/infra/outbound/message-action-runner.core-send.test.ts +++ b/src/infra/outbound/message-action-runner.core-send.test.ts @@ -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(); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 6517c508aa09..468a641a997b 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -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 { const { cfg, @@ -1070,6 +1076,37 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise