Skip to content

Commit d656910

Browse files
committed
fix(message-tool): interpolate responsePrefix templates on sends and skip unresolved model tokens
1 parent 50beb78 commit d656910

2 files changed

Lines changed: 72 additions & 8 deletions

File tree

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,52 @@ describe("runMessageAction core send routing", () => {
437437
expect(firstMockArg(sendMedia, "send media").text ?? "").toBe("");
438438
});
439439

440+
it("resolves identity templates in responsePrefix on message-tool sends", async () => {
441+
const sendText = registerSlackTextPlugin();
442+
443+
await runMessageAction({
444+
cfg: {
445+
channels: { slack: { enabled: true } },
446+
messages: { responsePrefix: "[{identity.name}]" },
447+
agents: { list: [{ id: "main", identity: { name: "Nexus" } }] },
448+
} as OpenClawConfig,
449+
action: "send",
450+
params: {
451+
channel: "slack",
452+
target: "channel:OTHER",
453+
message: "hello world",
454+
},
455+
agentId: "main",
456+
dryRun: false,
457+
});
458+
459+
expect(sendText).toHaveBeenCalledOnce();
460+
expect(firstMockArg(sendText, "send text").text).toBe("[Nexus] hello world");
461+
});
462+
463+
it("skips responsePrefix on tool sends when a model template cannot be resolved", async () => {
464+
const sendText = registerSlackTextPlugin();
465+
466+
await runMessageAction({
467+
cfg: {
468+
channels: { slack: { enabled: true } },
469+
messages: { responsePrefix: "[{provider}/{model}]" },
470+
} as OpenClawConfig,
471+
action: "send",
472+
params: {
473+
channel: "slack",
474+
target: "channel:OTHER",
475+
message: "hello world",
476+
},
477+
dryRun: false,
478+
});
479+
480+
expect(sendText).toHaveBeenCalledOnce();
481+
// A tool send performs no live model selection, so the unresolved template is dropped
482+
// rather than leaked as a literal `{provider}/{model}` prefix.
483+
expect(firstMockArg(sendText, "send text").text).toBe("hello world");
484+
});
485+
440486
it("uses best-effort delivery for explicit current-source message-tool-only replies", async () => {
441487
const sendText = registerSlackTextPlugin();
442488

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
88
import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js";
99
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
10-
import { resolveResponsePrefix } from "../../agents/identity.js";
10+
import { resolveAgentIdentity, resolveResponsePrefix } from "../../agents/identity.js";
1111
import type { AgentToolResult } from "../../agents/runtime/index.js";
1212
import {
1313
readPositiveIntegerParam,
@@ -16,6 +16,7 @@ import {
1616
} from "../../agents/tools/common.js";
1717
import type { SourceReplyDeliveryMode } from "../../auto-reply/get-reply-options.types.js";
1818
import type { ReplyPayload } from "../../auto-reply/reply-payload.js";
19+
import { resolveResponsePrefixTemplate } from "../../auto-reply/reply/response-prefix-template.js";
1920
import { normalizeChatType, type ChatType } from "../../channels/chat-type.js";
2021
import type { InboundEventKind } from "../../channels/inbound-event/kind.js";
2122
import { getChannelPlugin } from "../../channels/plugins/index.js";
@@ -1025,6 +1026,10 @@ async function buildSendPayloadParts(params: {
10251026
};
10261027
}
10271028

1029+
// Detects leftover `{variable}` placeholders after prefix interpolation. Non-global so
1030+
// `.test()` stays stateless; mirrors the variable shape in response-prefix-template.ts.
1031+
const UNRESOLVED_PREFIX_VAR_PATTERN = /\{[a-zA-Z][a-zA-Z0-9.]*\}/;
1032+
10281033
async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
10291034
const {
10301035
cfg,
@@ -1053,13 +1058,26 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
10531058

10541059
// `message(action=send)` crosses into other conversations, so mirror the direct-reply
10551060
// egress and prepend messages.responsePrefix here too; otherwise the disambiguation
1056-
// prefix is silently dropped on tool sends while replies keep it. The startsWith guard
1057-
// matches normalize-reply.ts and keeps re-runs idempotent.
1058-
const responsePrefix = resolveResponsePrefix(cfg, agentId ?? "", {
1059-
channel,
1060-
accountId: accountId ?? undefined,
1061-
});
1062-
if (responsePrefix && sendPayload.message && !sendPayload.message.startsWith(responsePrefix)) {
1061+
// prefix is silently dropped on tool sends while replies keep it. Interpolate the
1062+
// template like normalize-reply.ts so identity tokens render. model/provider/thinking
1063+
// tokens need the live model selection that a tool send never performs, so when any
1064+
// placeholder stays unresolved we skip prefixing instead of leaking a literal `{model}`.
1065+
// The startsWith guard matches normalize-reply.ts and keeps re-runs idempotent.
1066+
const responsePrefix = resolveResponsePrefixTemplate(
1067+
resolveResponsePrefix(cfg, agentId ?? "", {
1068+
channel,
1069+
accountId: accountId ?? undefined,
1070+
}),
1071+
{ identityName: normalizeOptionalString(resolveAgentIdentity(cfg, agentId ?? "")?.name) },
1072+
);
1073+
const prefixHasUnresolvedVar =
1074+
responsePrefix !== undefined && UNRESOLVED_PREFIX_VAR_PATTERN.test(responsePrefix);
1075+
if (
1076+
responsePrefix &&
1077+
!prefixHasUnresolvedVar &&
1078+
sendPayload.message &&
1079+
!sendPayload.message.startsWith(responsePrefix)
1080+
) {
10631081
const prefixedMessage = `${responsePrefix} ${sendPayload.message}`;
10641082
sendPayload = {
10651083
...sendPayload,

0 commit comments

Comments
 (0)