fix(agents): keep mention state and group intro out of session-stable prompt hash

This commit is contained in:
Ayaan Zaidi
2026-07-03 17:20:52 -07:00
parent c7aca4f029
commit 3706ebed33
10 changed files with 291 additions and 23 deletions
+92
View File
@@ -6,6 +6,7 @@ import path from "node:path";
import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions";
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildGroupChatContext, buildGroupIntro } from "../../auto-reply/reply/groups.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { registerLegacyContextEngine } from "../../context-engine/legacy.registration.js";
@@ -2117,6 +2118,97 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
},
);
it("reuses CLI session bindings across explicit mention toggles with stable group prompt facts", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const baseGroupCtx = {
ChatType: "group",
Provider: "telegram",
BotUsername: "SirPinchALotBot",
} as const;
const mentionedStaticPrompt = [
buildGroupChatContext({
sessionCtx: {
...baseGroupCtx,
ExplicitlyMentionedBot: true,
},
sourceReplyDeliveryMode: "automatic",
silentReplyPolicy: "allow",
silentToken: "NO_REPLY",
}),
buildGroupIntro({
defaultActivation: "mention",
}),
].join("\n\n");
const unmentionedStaticPrompt = [
buildGroupChatContext({
sessionCtx: {
...baseGroupCtx,
ExplicitlyMentionedBot: false,
},
sourceReplyDeliveryMode: "automatic",
silentReplyPolicy: "allow",
silentToken: "NO_REPLY",
}),
buildGroupIntro({
defaultActivation: "mention",
}),
].join("\n\n");
expect(unmentionedStaticPrompt).toBe(mentionedStaticPrompt);
const first = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:telegram:group:chat123",
sessionFile,
workspaceDir: dir,
prompt: "first ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-mention-binding-a",
extraSystemPrompt: [
"The incoming message explicitly mentions your channel identity @SirPinchALotBot.",
mentionedStaticPrompt,
].join("\n\n"),
sourceReplyDeliveryMode: "automatic",
cliSessionBindingFacts: {
extraSystemPromptStatic: mentionedStaticPrompt,
sourceReplyDeliveryMode: "automatic",
},
config: createCliBackendConfig(),
});
const second = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:telegram:group:chat123",
sessionFile,
workspaceDir: dir,
prompt: "second ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-mention-binding-b",
extraSystemPrompt: unmentionedStaticPrompt,
sourceReplyDeliveryMode: "automatic",
cliSessionBindingFacts: {
extraSystemPromptStatic: unmentionedStaticPrompt,
sourceReplyDeliveryMode: "automatic",
},
cliSessionBinding: {
sessionId: "cli-session",
extraSystemPromptHash: first.extraSystemPromptHash,
messageToolPolicyHash: first.messageToolPolicyHash,
cwdHash: hashCliSessionText(dir),
},
config: createCliBackendConfig(),
});
expect(second.extraSystemPromptHash).toBe(first.extraSystemPromptHash);
expect(second.reusableCliSession).toEqual({ sessionId: "cli-session" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("reuses CLI session bindings across owner sender flips with stable prompt tool scope", async () => {
const { dir, sessionFile } = createSessionFile();
try {
+3 -3
View File
@@ -76,7 +76,7 @@ describe("prompt composition invariants", () => {
expect(always.bodyPrompt).toContain("[Bootstrap truncation warning]");
});
it("keeps the group auto-reply prompt dynamic only across the first-turn intro boundary", () => {
it("keeps the group auto-reply prompt stable across the first-turn intro boundary", () => {
const groupScenario = getScenario(fixture, "auto-reply-group");
const first = getTurn(groupScenario, "t1");
const steady = getTurn(groupScenario, "t2");
@@ -89,10 +89,10 @@ describe("prompt composition invariants", () => {
expect(first.systemPrompt).not.toContain("## Silent Replies");
expect(steady.systemPrompt).toContain("You are in a Slack group chat.");
expect(steady.systemPrompt).toContain("prefer delegating bounded side investigations early");
expect(steady.systemPrompt).toContain("Activation: trigger-only");
expect(steady.systemPrompt).toContain('reply with exactly "NO_REPLY"');
expect(steady.systemPrompt).not.toContain("## Silent Replies");
expect(steady.systemPrompt).not.toContain("Activation: trigger-only");
expect(first.systemPrompt).not.toBe(steady.systemPrompt);
expect(first.systemPrompt).toBe(steady.systemPrompt);
expect(steady.systemPrompt).toBe(eventTurn.systemPrompt);
});
@@ -143,6 +143,7 @@ let drainFormattedSystemEvents: typeof import("./session-system-events.js").drai
let applySessionHints: typeof import("./body.js").applySessionHints;
let resolveTypingMode: typeof import("./typing-mode.js").resolveTypingMode;
let buildDirectChatContext: typeof import("./groups.js").buildDirectChatContext;
let buildGroupIntro: typeof import("./groups.js").buildGroupIntro;
let buildGroupChatContext: typeof import("./groups.js").buildGroupChatContext;
let buildInboundUserContextPrefix: typeof import("./inbound-meta.js").buildInboundUserContextPrefix;
let resolveInboundUserContextPromptJoiner: typeof import("./inbound-meta.js").resolveInboundUserContextPromptJoiner;
@@ -292,7 +293,8 @@ describe("runPreparedReply media-only handling", () => {
({ drainFormattedSystemEvents } = await import("./session-system-events.js"));
({ applySessionHints } = await import("./body.js"));
({ resolveTypingMode } = await import("./typing-mode.js"));
({ buildDirectChatContext, buildGroupChatContext } = await import("./groups.js"));
({ buildDirectChatContext, buildGroupIntro, buildGroupChatContext } =
await import("./groups.js"));
({ buildInboundUserContextPrefix, resolveInboundUserContextPromptJoiner } =
await import("./inbound-meta.js"));
({ testing: replyRunTesting, getActiveReplyRunCount } =
@@ -305,6 +307,7 @@ describe("runPreparedReply media-only handling", () => {
updateAmbientTranscriptWatermarkMock.mockClear();
vi.clearAllMocks();
vi.mocked(buildDirectChatContext).mockReturnValue("");
vi.mocked(buildGroupIntro).mockReturnValue("");
vi.mocked(buildGroupChatContext).mockReturnValue("");
vi.mocked(buildInboundUserContextPrefix).mockReturnValue("");
vi.mocked(resolveInboundUserContextPromptJoiner).mockReturnValue(undefined);
@@ -2792,6 +2795,134 @@ describe("runPreparedReply media-only handling", () => {
expect(secondRun.cliSessionBindingFacts).toEqual(firstRun.cliSessionBindingFacts);
});
it("keeps explicit mention state in user context and out of CLI binding facts", async () => {
vi.mocked(buildGroupChatContext).mockReturnValue("group:telegram:group:automatic");
await runPreparedReply(
baseParams({
opts: {
sourceReplyDeliveryMode: "automatic",
sessionPromptSourceReplyDeliveryMode: "automatic",
},
isNewSession: false,
systemSent: true,
ctx: {
Body: "@SirPinchALotBot check this",
RawBody: "@SirPinchALotBot check this",
CommandBody: "@SirPinchALotBot check this",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
BotUsername: "SirPinchALotBot",
ExplicitlyMentionedBot: true,
},
sessionCtx: {
Body: "@SirPinchALotBot check this",
BodyStripped: "@SirPinchALotBot check this",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
BotUsername: "SirPinchALotBot",
ExplicitlyMentionedBot: true,
},
}),
);
const run = requireRunReplyAgentCall(0).followupRun.run;
const inboundCtx = requireMockCallArg(
vi.mocked(buildInboundUserContextPrefix),
"inbound user context",
) as { ExplicitlyMentionedBot?: boolean; BotUsername?: string };
expect(inboundCtx.ExplicitlyMentionedBot).toBe(true);
expect(inboundCtx.BotUsername).toBe("SirPinchALotBot");
expect(run.extraSystemPromptStatic).toBe("group:telegram:group:automatic");
expect(run.cliSessionBindingFacts).toEqual({
extraSystemPromptStatic: "group:telegram:group:automatic",
sourceReplyDeliveryMode: "automatic",
});
});
it("keeps group intro in the session-stable CLI prompt after turn one", async () => {
vi.mocked(buildGroupChatContext).mockReturnValue("group:telegram:group:automatic");
vi.mocked(buildGroupIntro).mockReturnValue("intro:mention");
const sessionEntry: SessionEntry = {
sessionId: "session-telegram-group",
updatedAt: 1,
systemSent: true,
chatType: "group",
channel: "telegram",
lastChannel: "telegram",
lastTo: "-100123",
origin: {
provider: "telegram",
surface: "telegram",
chatType: "group",
to: "-100123",
},
};
await runPreparedReply(
baseParams({
opts: {
sourceReplyDeliveryMode: "automatic",
sessionPromptSourceReplyDeliveryMode: "automatic",
},
isNewSession: true,
systemSent: false,
sessionEntry,
ctx: {
Body: "@bot first",
RawBody: "@bot first",
CommandBody: "@bot first",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
},
sessionCtx: {
Body: "@bot first",
BodyStripped: "@bot first",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
},
}),
);
await runPreparedReply(
baseParams({
opts: {
sourceReplyDeliveryMode: "automatic",
sessionPromptSourceReplyDeliveryMode: "automatic",
},
isNewSession: false,
systemSent: true,
sessionEntry,
ctx: {
Body: "second",
RawBody: "second",
CommandBody: "second",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
},
sessionCtx: {
Body: "second",
BodyStripped: "second",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
},
}),
);
const firstRun = requireRunReplyAgentCall(0).followupRun.run;
const secondRun = requireRunReplyAgentCall(1).followupRun.run;
expect(firstRun.extraSystemPromptStatic).toBe(
"group:telegram:group:automatic\n\nintro:mention",
);
expect(secondRun.extraSystemPromptStatic).toBe(firstRun.extraSystemPromptStatic);
expect(secondRun.cliSessionBindingFacts).toEqual(firstRun.cliSessionBindingFacts);
});
it.each([
["/new", "new"],
["/reset", "reset"],
+2 -2
View File
@@ -631,8 +631,8 @@ export async function runPreparedReply(
silentToken: SILENT_REPLY_TOKEN,
})
: "";
// Behavioral intro (activation mode, lurking, etc.) only on first turn / activation needed
const groupIntro = shouldInjectGroupIntro
// Claude CLI fixes the system prompt at session creation; group intro must stay session-stable.
const groupIntro = isGroupChat
? buildGroupIntro({
sessionEntry,
defaultActivation,
+7 -7
View File
@@ -144,8 +144,8 @@ describe("group runtime loading", () => {
expect(disallowed).not.toContain("Never say that you are staying quiet");
});
it("binds an explicitly mentioned channel handle to the current assistant identity", () => {
const context = groups.buildGroupChatContext({
it("keeps per-message mention state out of stable group context", () => {
const mentioned = groups.buildGroupChatContext({
sessionCtx: {
ChatType: "group",
Provider: "telegram",
@@ -156,19 +156,19 @@ describe("group runtime loading", () => {
silentReplyPolicy: "allow",
});
expect(context).toContain("explicitly mentions your channel identity @SirPinchALotBot");
expect(context).toContain("Treat that mention as addressed to you");
const notExplicit = groups.buildGroupChatContext({
sessionCtx: {
ChatType: "group",
Provider: "telegram",
BotUsername: "kesslerAIBot",
BotUsername: "SirPinchALotBot",
ExplicitlyMentionedBot: false,
},
silentToken: "NO_REPLY",
silentReplyPolicy: "allow",
});
expect(notExplicit).not.toContain("channel identity @kesslerAIBot");
expect(mentioned).toBe(notExplicit);
expect(mentioned).not.toContain("channel identity @SirPinchALotBot");
});
it("uses channel wording when the authoritative chat type is channel", () => {
-6
View File
@@ -241,17 +241,11 @@ export function buildGroupChatContext(params: {
const providerLabel = resolveProviderLabel(params.sessionCtx.Provider);
const provider = normalizeOptionalLowercaseString(params.sessionCtx.Provider);
const messageToolOnly = params.sourceReplyDeliveryMode === "message_tool_only";
const botUsername = normalizeOptionalString(params.sessionCtx.BotUsername);
const sharedChatNoun = resolveSharedChatNoun(params.sessionCtx.ChatType);
const destinationLabel = sharedChatNoun === "channel" ? "this channel" : "this group chat";
const lines: string[] = [];
lines.push(`You are in a ${providerLabel} ${sharedChatNoun}.`);
if (params.sessionCtx.ExplicitlyMentionedBot === true && botUsername) {
lines.push(
`The incoming message explicitly mentions your channel identity @${botUsername}. Treat that mention as addressed to you, even if your persona name differs.`,
);
}
if (messageToolOnly) {
lines.push(
`Normal final replies are private and are not automatically sent to ${destinationLabel}. To post visible output here, use the message tool with action=send; the target defaults to ${destinationLabel}.`,
+30
View File
@@ -160,6 +160,23 @@ describe("buildInboundMetaSystemPrompt", () => {
expect(payload["flags"]).toBeUndefined();
});
it("keeps explicit bot mentions out of the system metadata", () => {
const prompt = buildInboundMetaSystemPrompt({
OriginatingTo: "telegram:-1001249586642",
OriginatingChannel: "telegram",
Provider: "telegram",
Surface: "telegram",
ChatType: "group",
BotUsername: "SirPinchALotBot",
ExplicitlyMentionedBot: true,
} as TemplateContext);
const payload = parseInboundMetaPayload(prompt);
expect(payload["flags"]).toBeUndefined();
expect(prompt).not.toContain("SirPinchALotBot");
expect(prompt).not.toContain("explicitly mentions your channel identity");
});
it("omits sender_id when blank", () => {
const prompt = buildInboundMetaSystemPrompt({
MessageSid: "458",
@@ -714,6 +731,19 @@ describe("buildInboundUserContextPrefix", () => {
expect(conversationInfo["history_count"]).toBe(1);
});
it("carries explicit bot mentions in current-turn user context", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
BotUsername: "SirPinchALotBot",
ExplicitlyMentionedBot: true,
} as TemplateContext);
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["explicitly_mentioned_bot"]).toBe(true);
expect(text).toContain("explicitly mentions your channel identity @SirPinchALotBot");
expect(text).toContain("Treat that mention as addressed to you");
});
it("trims sender_id in conversation info", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",
+5
View File
@@ -575,6 +575,7 @@ export function buildInboundUserContextPrefix(
e164: normalizePromptMetadataString(ctx.SenderE164),
is_bot: typeof ctx.SenderIsBot === "boolean" ? ctx.SenderIsBot : undefined,
};
const botUsername = normalizePromptMetadataString(ctx.BotUsername);
// Keep volatile conversation/message identifiers in the user-role block so the system
// prompt stays byte-stable across task-scoped sessions and reply turns.
@@ -605,6 +606,10 @@ export function buildInboundUserContextPrefix(
topic_name: normalizePromptMetadataString(ctx.TopicName) ?? undefined,
is_forum: ctx.IsForum === true ? true : undefined,
...buildConversationMentionMetadataPayload(ctx, isDirect),
explicit_bot_mention_note:
ctx.ExplicitlyMentionedBot === true && botUsername
? `The incoming message explicitly mentions your channel identity @${botUsername}. Treat that mention as addressed to you, even if your persona name differs.`
: undefined,
has_reply_context:
replyChainPayload.length > 0 || sanitizePromptBody(ctx.ReplyToBody) ? true : undefined,
has_forwarded_context: normalizePromptMetadataString(ctx.ForwardedFrom) ? true : undefined,
@@ -72,6 +72,20 @@ describe("stripInboundMetadata", () => {
expect(stripInboundMetadata(input)).toBe("What is the weather today?");
});
it("strips explicit bot mention notes with conversation info", () => {
const input = `Conversation info (untrusted metadata):
\`\`\`json
{
"explicitly_mentioned_bot": true,
"explicit_bot_mention_note": "The incoming message explicitly mentions your channel identity @SirPinchALotBot. Treat that mention as addressed to you, even if your persona name differs."
}
\`\`\`
Actual user message`;
expect(stripInboundMetadata(input)).toBe("Actual user message");
});
it("strips multiple chained metadata blocks", () => {
const input = `${CONV_BLOCK}\n\n${SENDER_BLOCK}\n\nCan you help me?`;
expect(stripInboundMetadata(input)).toBe("Can you help me?");
@@ -347,11 +347,11 @@ function createGroupScenario(workspaceDir: string): PromptScenario {
return {
scenario: "auto-reply-group",
focus: "Group chat bootstrap, steady state, and runtime event turns",
expectedStableSystemAfterTurnIds: ["t3"],
expectedStableSystemAfterTurnIds: ["t2", "t3"],
turns: [
{
id: "t1",
label: "First group turn with one-time intro",
label: "First group turn with session-stable intro",
systemPrompt: buildAutoReplySystemPrompt({
workspaceDir,
sessionCtx: {
@@ -372,7 +372,7 @@ function createGroupScenario(workspaceDir: string): PromptScenario {
},
body: "Can you investigate this issue?",
}),
notes: ["Expected first-turn bootstrap churn", "Not steady-state"],
notes: ["Group intro belongs to the session-stable system prompt"],
},
{
id: "t2",
@@ -389,6 +389,7 @@ function createGroupScenario(workspaceDir: string): PromptScenario {
],
},
includeGroupChatContext: true,
includeGroupIntro: true,
}),
bodyPrompt: buildAutoReplyBody({
ctx: {
@@ -402,7 +403,7 @@ function createGroupScenario(workspaceDir: string): PromptScenario {
},
body: "Give a short update.",
}),
notes: ["One-time intro gone", "Should settle afterward"],
notes: ["Group intro remains stable after turn one"],
},
{
id: "t3",
@@ -419,6 +420,7 @@ function createGroupScenario(workspaceDir: string): PromptScenario {
],
},
includeGroupChatContext: true,
includeGroupIntro: true,
}),
bodyPrompt: buildAutoReplyBody({
ctx: {