From cc57d56b92f5d42a030a3d67ff2ce839010b97a6 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:06:54 -0500 Subject: [PATCH] fix: Align silent reply prompt guidance (#70954) * Align silent reply prompt guidance * Pass explicit silent reply conversation types * Handle dm alias in direct prompt guidance * Respect policy session type for routed replies * Preserve routed silent reply policy type * Propagate silent reply dispatcher chat type * Align prompt silent reply target policy * Avoid direct silent fallback prompt token * Use inbound key for prompt silent policy * Rewrite direct silent replies in dispatcher --- docs/.generated/config-baseline.sha256 | 4 +- src/agents/prompt-composition.test.ts | 12 ++++ src/auto-reply/dispatch.test.ts | 68 ++++++++++++++++++ src/auto-reply/dispatch.ts | 14 ++++ .../reply/dispatch-from-config.test.ts | 1 + src/auto-reply/reply/dispatch-from-config.ts | 22 ++++++ .../reply/get-reply-run.exec-hint.test.ts | 56 ++++++++++++++- src/auto-reply/reply/get-reply-run.ts | 49 ++++++++++++- src/auto-reply/reply/groups.test.ts | 70 ++++++++++++++++++ src/auto-reply/reply/groups.ts | 39 ++++++++-- src/auto-reply/reply/reply-dispatcher.ts | 72 ++++++++++++------- src/auto-reply/reply/reply-flow.test.ts | 35 ++++++++- src/auto-reply/reply/route-reply.test.ts | 39 ++++++++++ src/auto-reply/reply/route-reply.ts | 7 ++ src/config/silent-reply.test.ts | 60 ++++++++-------- src/gateway/server-methods/send.ts | 1 + src/infra/outbound/deliver.test.ts | 7 +- src/infra/outbound/deliver.ts | 1 + src/infra/outbound/payloads.test.ts | 22 ++---- src/infra/outbound/payloads.ts | 8 +-- src/infra/outbound/session-context.test.ts | 43 +++++++++++ src/infra/outbound/session-context.ts | 19 +++++ src/shared/silent-reply-policy.test.ts | 62 ++++++++-------- .../agents/prompt-composition-scenarios.ts | 16 ++++- 24 files changed, 604 insertions(+), 123 deletions(-) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 416d64f67e2..4c312c6ac0b 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -f0421335bfd388b7ebe1b8d478036ece4bf5eb8fd7b1de81b8cdc4ec6522ce20 config-baseline.json +de02a4b0ec521fda7d951d2dc0c742fc2fa310647ffd56a666346e5ddc6b5a59 config-baseline.json bf00f7910d8f0d8e12592e8a1c6bd0397f8e62fef2c11eb0cbd3b3a3e2a78ffe config-baseline.core.json 22d7cd6d8279146b2d79c9531a55b80b52a2c99c81338c508104729154fdd02d config-baseline.channel.json -c6f99aed28b98e5914585956ec303b615a8ef975abf5cec186a61781c20b9106 config-baseline.plugin.json +b79dc28c1b6002dc59bd77bde47c1855a9ece72b9fdf94c0baf0c5320b2be12c config-baseline.plugin.json diff --git a/src/agents/prompt-composition.test.ts b/src/agents/prompt-composition.test.ts index 7d2deddee49..b733f8f4a18 100644 --- a/src/agents/prompt-composition.test.ts +++ b/src/agents/prompt-composition.test.ts @@ -70,6 +70,18 @@ describe("prompt composition invariants", () => { expect(steady.systemPrompt).toBe(eventTurn.systemPrompt); }); + it("includes direct-chat guidance that routes NO_REPLY through the default rewrite path", () => { + const directScenario = fixture.scenarios.find( + (entry) => entry.scenario === "auto-reply-direct", + ); + expect(directScenario).toBeDefined(); + const first = getTurn(directScenario!, "t1"); + + expect(first.systemPrompt).toContain("You are in a Slack direct conversation."); + expect(first.systemPrompt).toContain('reply with exactly "NO_REPLY"'); + expect(first.systemPrompt).toContain("so OpenClaw can send a short fallback reply"); + }); + it("keeps maintenance prompts out of the normal stable-turn invariant set", () => { const maintenanceScenario = fixture.scenarios.find( (entry) => entry.scenario === "maintenance-prompts", diff --git a/src/auto-reply/dispatch.test.ts b/src/auto-reply/dispatch.test.ts index 7245c53fca8..ef1f5bb0d99 100644 --- a/src/auto-reply/dispatch.test.ts +++ b/src/auto-reply/dispatch.test.ts @@ -200,4 +200,72 @@ describe("withReplyDispatcher", () => { }), ); }); + + it("passes explicit direct conversation type for generic silent-reply policy keys", async () => { + hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ + dispatcher: createDispatcher([]), + replyOptions: {}, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + }); + hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); + + await dispatchInboundMessageWithBufferedDispatcher({ + ctx: buildTestCtx({ + SessionKey: "agent:test:main", + ChatType: "dm", + Surface: "discord", + }), + cfg: {} as OpenClawConfig, + dispatcherOptions: { + deliver: async () => undefined, + }, + replyResolver: async () => ({ text: "ok" }), + }); + + expect(hoisted.createReplyDispatcherWithTypingMock).toHaveBeenCalledWith( + expect.objectContaining({ + silentReplyContext: expect.objectContaining({ + sessionKey: "agent:test:main", + surface: "discord", + conversationType: "direct", + }), + }), + ); + }); + + it("does not copy source conversation type onto cross-session native silent-reply targets", async () => { + hoisted.createReplyDispatcherWithTypingMock.mockReturnValueOnce({ + dispatcher: createDispatcher([]), + replyOptions: {}, + markDispatchIdle: vi.fn(), + markRunComplete: vi.fn(), + }); + hoisted.dispatchReplyFromConfigMock.mockResolvedValueOnce({ text: "ok" }); + + await dispatchInboundMessageWithBufferedDispatcher({ + ctx: buildTestCtx({ + SessionKey: "agent:test:main", + CommandSource: "native", + CommandTargetSessionKey: "agent:test:direct:user", + ChatType: "group", + Surface: "telegram", + }), + cfg: {} as OpenClawConfig, + dispatcherOptions: { + deliver: async () => undefined, + }, + replyResolver: async () => ({ text: "ok" }), + }); + + const silentReplyContext = + hoisted.createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0]?.silentReplyContext; + expect(silentReplyContext).toEqual( + expect.objectContaining({ + sessionKey: "agent:test:direct:user", + surface: "telegram", + }), + ); + expect(silentReplyContext).not.toEqual(expect.objectContaining({ conversationType: "group" })); + }); }); diff --git a/src/auto-reply/dispatch.ts b/src/auto-reply/dispatch.ts index 3e6b333859a..b7bd73c0eb3 100644 --- a/src/auto-reply/dispatch.ts +++ b/src/auto-reply/dispatch.ts @@ -1,4 +1,6 @@ +import { normalizeChatType } from "../channels/chat-type.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { SilentReplyConversationType } from "../shared/silent-reply-policy.js"; import { withReplyDispatcher } from "./dispatch-dispatcher.js"; import { dispatchReplyFromConfig } from "./reply/dispatch-from-config.js"; import type { DispatchFromConfigResult } from "./reply/dispatch-from-config.types.js"; @@ -23,10 +25,22 @@ function resolveDispatcherSilentReplyContext( finalized.CommandSource === "native" ? (finalized.CommandTargetSessionKey ?? finalized.SessionKey) : finalized.SessionKey; + const chatType = normalizeChatType(finalized.ChatType); + const conversationType: SilentReplyConversationType | undefined = + finalized.CommandSource === "native" && + finalized.CommandTargetSessionKey && + finalized.CommandTargetSessionKey !== finalized.SessionKey + ? undefined + : chatType === "direct" + ? "direct" + : chatType === "group" || chatType === "channel" + ? "group" + : undefined; return { cfg, sessionKey: policySessionKey, surface: finalized.Surface ?? finalized.Provider, + conversationType, }; } diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 1cfd5dadc22..e6e1e1b2790 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -1083,6 +1083,7 @@ describe("dispatchReplyFromConfig", () => { expect(mocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ channel: "imessage", + policyConversationType: "direct", to: "imessage:+15550001111", }), ); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 1e10ff36b0b..df13e655e47 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -9,6 +9,7 @@ import { resolveConversationBindingRecord, touchConversationBindingRecord, } from "../../bindings/records.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js"; import { parseSessionThreadInfoFast } from "../../config/sessions/thread-info.js"; import type { SessionEntry } from "../../config/sessions/types.js"; @@ -151,6 +152,26 @@ const isInboundAudioContext = (ctx: FinalizedMsgContext): boolean => { return AUDIO_HEADER_RE.test(trimmed); }; +const resolveRoutedPolicyConversationType = ( + ctx: FinalizedMsgContext, +): "direct" | "group" | undefined => { + if ( + ctx.CommandSource === "native" && + ctx.CommandTargetSessionKey && + ctx.CommandTargetSessionKey !== ctx.SessionKey + ) { + return undefined; + } + const chatType = normalizeChatType(ctx.ChatType); + if (chatType === "direct") { + return "direct"; + } + if (chatType === "group" || chatType === "channel") { + return "group"; + } + return undefined; +}; + const resolveSessionStoreLookup = ( ctx: FinalizedMsgContext, cfg: OpenClawConfig, @@ -391,6 +412,7 @@ export async function dispatchReplyFromConfig( ctx.CommandSource === "native" ? (ctx.CommandTargetSessionKey ?? ctx.SessionKey) : ctx.SessionKey, + policyConversationType: resolveRoutedPolicyConversationType(ctx), accountId: replyRoute.accountId, requesterSenderId: ctx.SenderId, requesterSenderName: ctx.SenderName, diff --git a/src/auto-reply/reply/get-reply-run.exec-hint.test.ts b/src/auto-reply/reply/get-reply-run.exec-hint.test.ts index 80e922f1386..3d49048a9cc 100644 --- a/src/auto-reply/reply/get-reply-run.exec-hint.test.ts +++ b/src/auto-reply/reply/get-reply-run.exec-hint.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildExecOverridePromptHint } from "./get-reply-run.js"; +import { + buildExecOverridePromptHint, + resolvePromptSilentReplyConversationType, +} from "./get-reply-run.js"; +import { buildGetReplyCtx, buildGetReplyGroupCtx } from "./get-reply.test-fixtures.js"; describe("buildExecOverridePromptHint", () => { it("returns undefined when exec state is fully inherited and elevated is off", () => { @@ -52,3 +56,53 @@ describe("buildExecOverridePromptHint", () => { ); }); }); + +describe("resolvePromptSilentReplyConversationType", () => { + it("treats direct and dm chat types as direct prompt policy context", () => { + expect( + resolvePromptSilentReplyConversationType({ + ctx: buildGetReplyCtx({ + ChatType: "dm", + SessionKey: "agent:main:main", + }), + }), + ).toBe("direct"); + }); + + it("treats group and channel chat types as group prompt policy context", () => { + expect( + resolvePromptSilentReplyConversationType({ + ctx: buildGetReplyGroupCtx({ + ChatType: "channel", + }), + }), + ).toBe("group"); + }); + + it("does not override a native cross-session target policy with the source chat type", () => { + expect( + resolvePromptSilentReplyConversationType({ + ctx: buildGetReplyGroupCtx({ + CommandSource: "native", + SessionKey: "agent:main:telegram:group:source", + CommandTargetSessionKey: "agent:main:telegram:direct:target", + ChatType: "group", + }), + }), + ).toBeUndefined(); + }); + + it("uses the inbound session key when session context was rewritten to the target", () => { + expect( + resolvePromptSilentReplyConversationType({ + ctx: buildGetReplyGroupCtx({ + CommandSource: "native", + SessionKey: "agent:main:telegram:direct:target", + CommandTargetSessionKey: "agent:main:telegram:direct:target", + ChatType: "group", + }), + inboundSessionKey: "agent:main:telegram:group:source", + }), + ).toBeUndefined(); + }); +}); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index a435c02e022..16ee2ec09d7 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -5,6 +5,7 @@ import { resolveFastModeState } from "../../agents/fast-mode.js"; import { resolveEmbeddedFullAccessState } from "../../agents/pi-embedded-runner/sandbox-info.js"; import type { EmbeddedFullAccessBlockedReason } from "../../agents/pi-embedded-runner/types.js"; import { resolveIngressWorkspaceOverrideForSpawnedRun } from "../../agents/spawned-context.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; import { resolveGroupSessionKey } from "../../config/sessions/group.js"; import { resolveSessionFilePath, @@ -12,6 +13,7 @@ import { } from "../../config/sessions/paths.js"; import { resolveSessionStoreEntry } from "../../config/sessions/store.js"; import type { SessionEntry } from "../../config/sessions/types.js"; +import { resolveSilentReplySettings } from "../../config/silent-reply.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { clearCommandLane, getQueueSize } from "../../process/command-queue.js"; @@ -20,6 +22,7 @@ import { isSubagentSessionKey, normalizeMainKey, } from "../../routing/session-key.js"; +import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { hasControlCommand } from "../command-detection.js"; @@ -42,7 +45,7 @@ import type { buildCommandContext } from "./commands.js"; import type { InlineDirectives } from "./directive-handling.js"; import { shouldUseReplyFastTestRuntime } from "./get-reply-fast-path.js"; import { resolvePreparedReplyQueueState } from "./get-reply-run-queue.js"; -import { buildGroupChatContext, buildGroupIntro } from "./groups.js"; +import { buildDirectChatContext, buildGroupChatContext, buildGroupIntro } from "./groups.js"; import { hasInboundMedia } from "./inbound-media.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js"; import type { createModelSelectionState } from "./model-selection.js"; @@ -62,6 +65,28 @@ import type { TypingController } from "./typing.js"; type AgentDefaults = NonNullable["defaults"]; type ExecOverrides = Pick; +export function resolvePromptSilentReplyConversationType(params: { + ctx: Pick; + inboundSessionKey?: string; +}): SilentReplyConversationType | undefined { + const sourceSessionKey = params.inboundSessionKey ?? params.ctx.SessionKey; + if ( + params.ctx.CommandSource === "native" && + params.ctx.CommandTargetSessionKey && + params.ctx.CommandTargetSessionKey !== sourceSessionKey + ) { + return undefined; + } + const chatType = normalizeChatType(params.ctx.ChatType); + if (chatType === "direct") { + return "direct"; + } + if (chatType === "group" || chatType === "channel") { + return "group"; + } + return undefined; +} + export function buildExecOverridePromptHint(params: { execOverrides?: ExecOverrides; elevatedLevel: ElevatedLevel; @@ -239,6 +264,15 @@ export async function runPreparedReply( ctx, sessionKey, }); + const silentReplySettings = resolveSilentReplySettings({ + cfg, + sessionKey: runtimePolicySessionKey, + surface: sessionCtx.Surface ?? sessionCtx.Provider, + conversationType: resolvePromptSilentReplyConversationType({ + ctx: sessionCtx, + inboundSessionKey: ctx.SessionKey, + }), + }); let { sessionEntry, resolvedThinkLevel, @@ -282,6 +316,15 @@ export async function runPreparedReply( const shouldInjectGroupIntro = Boolean( isGroupChat && (isFirstTurnInSession || sessionEntry?.groupActivationNeedsSystemIntro), ); + const directChatContext = + sessionCtx.ChatType === "direct" || sessionCtx.ChatType === "dm" + ? buildDirectChatContext({ + sessionCtx, + silentReplyPolicy: silentReplySettings.policy, + silentReplyRewrite: silentReplySettings.rewrite, + silentToken: SILENT_REPLY_TOKEN, + }) + : ""; // Always include persistent group chat context (provider + reply guidance). const groupChatContext = isGroupChat ? buildGroupChatContext({ sessionCtx }) : ""; // Behavioral intro (activation mode, lurking, etc.) only on first turn / activation needed @@ -292,6 +335,8 @@ export async function runPreparedReply( sessionEntry, defaultActivation, silentToken: SILENT_REPLY_TOKEN, + silentReplyPolicy: silentReplySettings.policy, + silentReplyRewrite: silentReplySettings.rewrite, }) : ""; const groupSystemPrompt = normalizeOptionalString(sessionCtx.GroupSystemPrompt) ?? ""; @@ -301,6 +346,7 @@ export async function runPreparedReply( ); const extraSystemPromptParts = [ inboundMetaPrompt, + directChatContext, groupChatContext, groupIntro, groupSystemPrompt, @@ -313,6 +359,7 @@ export async function runPreparedReply( ].filter(Boolean); // Static parts only (no per-message inbound metadata) for CLI session reuse hashing. const extraSystemPromptStaticParts = [ + directChatContext, groupChatContext, groupIntro, groupSystemPrompt, diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts index 1e8df909817..b4c1445b860 100644 --- a/src/auto-reply/reply/groups.test.ts +++ b/src/auto-reply/reply/groups.test.ts @@ -49,6 +49,76 @@ describe("group runtime loading", () => { vi.doUnmock("./groups.runtime.js"); }); + it("builds direct chat context from the resolved silent reply policy", async () => { + const groups = await import("./groups.js"); + + expect( + groups.buildDirectChatContext({ + sessionCtx: { ChatType: "direct", Provider: "telegram" }, + silentReplyPolicy: "disallow", + silentReplyRewrite: false, + silentToken: "NO_REPLY", + }), + ).toBe( + 'You are in a Telegram direct conversation. Your replies are automatically sent to this conversation. Do not use "NO_REPLY" as your final answer in this conversation.', + ); + + expect( + groups.buildDirectChatContext({ + sessionCtx: { ChatType: "direct", Provider: "telegram" }, + silentReplyPolicy: "disallow", + silentReplyRewrite: true, + silentToken: "NO_REPLY", + }), + ).toContain("so OpenClaw can send a short fallback reply"); + + expect( + groups.buildDirectChatContext({ + sessionCtx: { ChatType: "direct", Provider: "telegram" }, + silentReplyPolicy: "allow", + silentToken: "NO_REPLY", + }), + ).toContain('reply with exactly "NO_REPLY"'); + }); + + it("gates group silent-token instructions on the resolved silent reply policy", async () => { + const groups = await import("./groups.js"); + + const allowed = groups.buildGroupIntro({ + cfg: {} as OpenClawConfig, + sessionCtx: { Provider: "whatsapp" }, + defaultActivation: "always", + silentToken: "NO_REPLY", + silentReplyPolicy: "allow", + }); + expect(allowed).toContain('reply with exactly "NO_REPLY"'); + expect(allowed).toContain("Otherwise stay silent."); + + const disallowed = groups.buildGroupIntro({ + cfg: {} as OpenClawConfig, + sessionCtx: { Provider: "whatsapp" }, + defaultActivation: "always", + silentToken: "NO_REPLY", + silentReplyPolicy: "disallow", + silentReplyRewrite: false, + }); + expect(disallowed).toContain("Activation: always-on"); + expect(disallowed).not.toContain("NO_REPLY"); + expect(disallowed).not.toContain("Otherwise stay silent."); + + const rewritten = groups.buildGroupIntro({ + cfg: {} as OpenClawConfig, + sessionCtx: { Provider: "whatsapp" }, + defaultActivation: "always", + silentToken: "NO_REPLY", + silentReplyPolicy: "disallow", + silentReplyRewrite: true, + }); + expect(rewritten).toContain('reply with exactly "NO_REPLY"'); + expect(rewritten).toContain("short fallback reply"); + expect(rewritten).not.toContain("Otherwise stay silent."); + }); + it("loads the group runtime only when requireMention resolution needs it", async () => { const groupsRuntimeLoads = vi.fn(); vi.doMock("./groups.runtime.js", () => { diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index 94f708c2c9f..ac67e8b2309 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,6 +1,7 @@ import { resolveChannelGroupRequireMention } from "../../config/group-policy.js"; import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { SilentReplyPolicy } from "../../shared/silent-reply-policy.js"; import { normalizeOptionalLowercaseString, normalizeOptionalString, @@ -227,29 +228,59 @@ export function buildGroupChatContext(params: { sessionCtx: TemplateContext }): return lines.join(" "); } +export function buildDirectChatContext(params: { + sessionCtx: TemplateContext; + silentReplyPolicy?: SilentReplyPolicy; + silentReplyRewrite?: boolean; + silentToken: string; +}): string { + const providerLabel = resolveProviderLabel(params.sessionCtx.Provider); + const lines: string[] = []; + lines.push(`You are in a ${providerLabel} direct conversation.`); + lines.push("Your replies are automatically sent to this conversation."); + if (params.silentReplyPolicy === "allow") { + lines.push( + `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw stays silent.`, + ); + } else if (params.silentReplyRewrite === true) { + lines.push( + `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw can send a short fallback reply.`, + ); + } else { + lines.push(`Do not use "${params.silentToken}" as your final answer in this conversation.`); + } + return lines.join(" "); +} + export function buildGroupIntro(params: { cfg: OpenClawConfig; sessionCtx: TemplateContext; sessionEntry?: SessionEntry; defaultActivation: "always" | "mention"; silentToken: string; + silentReplyPolicy?: SilentReplyPolicy; + silentReplyRewrite?: boolean; }): string { const activation = normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation; + const canUseSilentReply = + params.silentReplyPolicy !== "disallow" || params.silentReplyRewrite === true; const activationLine = activation === "always" ? "Activation: always-on (you receive every group message)." : "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included)."; const silenceLine = - activation === "always" - ? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.` + activation === "always" && canUseSilentReply + ? params.silentReplyPolicy === "allow" + ? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.` + : `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw can send a short fallback reply. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.` : undefined; const toolSilenceLine = - activation === "always" + activation === "always" && canUseSilentReply ? `If you only react or otherwise handle the message without a text reply, your final answer must still be exactly "${params.silentToken}". Never say that you are staying quiet, keeping channel noise low, making a context-only note, or sending no channel reply.` : undefined; const cautionLine = - activation === "always" + activation === "always" && params.silentReplyPolicy === "allow" ? "Be extremely selective: reply only when directly addressed or clearly helpful. Otherwise stay silent." : undefined; const lurkLine = diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index ad895cce244..0bee71b169c 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -1,10 +1,13 @@ import type { TypingCallbacks } from "../../channels/typing.js"; -import { resolveSilentReplyPolicy } from "../../config/silent-reply.js"; +import { resolveSilentReplySettings } from "../../config/silent-reply.js"; import type { HumanDelayConfig } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { generateSecureInt } from "../../infra/secure-random.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; +import { + resolveSilentReplyRewriteText, + type SilentReplyConversationType, +} from "../../shared/silent-reply-policy.js"; import { sleep } from "../../utils.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -115,37 +118,56 @@ function normalizeReplyPayloadInternal( }); } -function shouldPreserveSilentFinalPayload(params: { +function resolveSilentFinalPayload(params: { kind: ReplyDispatchKind; payload: ReplyPayload; silentReplyContext?: ReplyDispatcherOptions["silentReplyContext"]; -}): boolean { +}): ReplyPayload | null | undefined { if (params.kind !== "final") { - return false; + return undefined; } if (!isSilentReplyText(params.payload.text, SILENT_REPLY_TOKEN)) { - return false; + return undefined; } const context = params.silentReplyContext; if (!context) { - return false; + return undefined; } - const resolvedPolicy = resolveSilentReplyPolicy({ + const resolvedSettings = resolveSilentReplySettings({ cfg: context.cfg, sessionKey: context.sessionKey, surface: context.surface, conversationType: context.conversationType, }); - const shouldPreserve = resolvedPolicy !== "allow"; - if (shouldPreserve) { + if (resolvedSettings.policy === "allow") { + return undefined; + } + if (resolvedSettings.rewrite) { + silentReplyLogger.debug("rewriting exact NO_REPLY final payload before delivery", { + hasSessionKey: Boolean(context.sessionKey), + surface: context.surface, + conversationType: context.conversationType, + resolvedPolicy: resolvedSettings.policy, + }); + return { + ...params.payload, + text: resolveSilentReplyRewriteText({ + seed: `${context.sessionKey ?? context.surface ?? "silent-reply"}:${params.payload.text ?? ""}`, + }), + }; + } + if (!resolvedSettings.rewrite) { silentReplyLogger.debug("preserving exact NO_REPLY final payload before normalization", { hasSessionKey: Boolean(context.sessionKey), surface: context.surface, conversationType: context.conversationType, - resolvedPolicy, + resolvedPolicy: resolvedSettings.policy, }); } - return shouldPreserve; + return { + ...params.payload, + text: params.payload.text?.trim() || SILENT_REPLY_TOKEN, + }; } export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDispatcher { @@ -177,23 +199,21 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { const originalWasExactSilent = isSilentReplyText(payload.text, SILENT_REPLY_TOKEN); - const normalized = shouldPreserveSilentFinalPayload({ + const silentFinalPayload = resolveSilentFinalPayload({ kind, payload, silentReplyContext: options.silentReplyContext, - }) - ? { - ...payload, - text: payload.text?.trim() || SILENT_REPLY_TOKEN, - } - : normalizeReplyPayloadInternal(payload, { - responsePrefix: options.responsePrefix, - responsePrefixContext: options.responsePrefixContext, - responsePrefixContextProvider: options.responsePrefixContextProvider, - transformReplyPayload: options.transformReplyPayload, - onHeartbeatStrip: options.onHeartbeatStrip, - onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), - }); + }); + const normalized = + silentFinalPayload ?? + normalizeReplyPayloadInternal(payload, { + responsePrefix: options.responsePrefix, + responsePrefixContext: options.responsePrefixContext, + responsePrefixContextProvider: options.responsePrefixContextProvider, + transformReplyPayload: options.transformReplyPayload, + onHeartbeatStrip: options.onHeartbeatStrip, + onSkip: (reason) => options.onSkip?.(payload, { kind, reason }), + }); if (!normalized) { if (kind === "final" && originalWasExactSilent) { silentReplyLogger.debug("exact NO_REPLY final payload was skipped before delivery", { diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 8a36850a216..f68a496cddc 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -21,7 +21,7 @@ describe("createReplyDispatcher", () => { expect(deliver.mock.calls[1]?.[0]?.text).toBe(`interject.${SILENT_REPLY_TOKEN}`); }); - it("preserves exact NO_REPLY final payloads for direct sessions where silence is disallowed", async () => { + it("rewrites exact NO_REPLY final payloads for direct sessions where rewrite is enabled", async () => { const deliver = vi.fn().mockResolvedValue(undefined); const cfg: OpenClawConfig = { agents: { @@ -48,6 +48,39 @@ describe("createReplyDispatcher", () => { expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(true); + await dispatcher.waitForIdle(); + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver.mock.calls[0]?.[0]?.text).not.toBe(SILENT_REPLY_TOKEN); + expect(deliver.mock.calls[0]?.[0]?.text).toBeTruthy(); + }); + + it("preserves exact NO_REPLY final payloads for direct sessions where rewrite is disabled", async () => { + const deliver = vi.fn().mockResolvedValue(undefined); + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + group: "allow", + internal: "allow", + }, + silentReplyRewrite: { + direct: false, + }, + }, + }, + }; + const dispatcher = createReplyDispatcher({ + deliver, + silentReplyContext: { + cfg, + sessionKey: "agent:main:telegram:direct:123", + surface: "telegram", + }, + }); + + expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(true); + await dispatcher.waitForIdle(); expect(deliver).toHaveBeenCalledTimes(1); expect(deliver.mock.calls[0]?.[0]?.text).toBe(SILENT_REPLY_TOKEN); diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index faa2084e7e3..c084130a64b 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -245,6 +245,7 @@ describe("routeReply", () => { cfg, sessionKey: "agent:main:main", policySessionKey: "agent:main:direct:U123", + isGroup: true, }); expect(res.ok).toBe(true); @@ -255,6 +256,44 @@ describe("routeReply", () => { policyKey: "agent:main:direct:U123", }), }); + const session = mocks.deliverOutboundPayloads.mock.calls.at(-1)?.[0]?.session; + expect(session).not.toEqual(expect.objectContaining({ conversationType: "group" })); + }); + + it("uses explicit policy conversation type to preserve routed direct silent replies", async () => { + const cfg = { + agents: { + defaults: { + silentReply: { + direct: "disallow", + internal: "allow", + }, + silentReplyRewrite: { + direct: true, + }, + }, + }, + } as unknown as OpenClawConfig; + + const res = await routeReply({ + payload: { text: SILENT_REPLY_TOKEN }, + channel: "slack", + to: "channel:C123", + cfg, + sessionKey: "agent:main:main", + policySessionKey: "agent:main:main", + policyConversationType: "direct", + }); + + expect(res.ok).toBe(true); + expectLastDelivery({ + payloads: [expect.objectContaining({ text: SILENT_REPLY_TOKEN })], + session: expect.objectContaining({ + key: "agent:main:main", + policyKey: "agent:main:main", + conversationType: "direct", + }), + }); }); it("applies responsePrefix when routing", async () => { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 0b8c67286be..92b3cef0e48 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; import { hasReplyPayloadContent } from "../../interactive/payload.js"; +import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; import { normalizeOptionalLowercaseString } from "../../shared/string-coerce.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; @@ -48,6 +49,8 @@ export type RouteReplyParams = { sessionKey?: string; /** Session key for policy resolution when native-command delivery targets a different session. */ policySessionKey?: string; + /** Explicit conversation type for policy resolution when the policy key is generic. */ + policyConversationType?: SilentReplyConversationType; /** Provider account id (multi-account). */ accountId?: string; /** Originating sender id for sender-scoped outbound media policy. */ @@ -125,6 +128,7 @@ export async function routeReply(params: RouteReplyParams): Promise { - it("uses the default direct/group/internal policy and rewrite flags", () => { + it("uses the default direct/group/internal policy", () => { expect(resolveSilentReplyPolicy({ surface: "webchat" })).toBe("disallow"); - expect(resolveSilentReplyRewriteEnabled({ surface: "webchat" })).toBe(true); expect( resolveSilentReplyPolicy({ sessionKey: "agent:main:telegram:group:123", surface: "telegram", }), ).toBe("allow"); - expect( - resolveSilentReplyRewriteEnabled({ - sessionKey: "agent:main:telegram:group:123", - surface: "telegram", - }), - ).toBe(false); expect( resolveSilentReplyPolicy({ sessionKey: "agent:main:subagent:abc", @@ -34,17 +27,11 @@ describe("silent reply config resolution", () => { group: "disallow", internal: "allow", }, - silentReplyRewrite: { - direct: false, - group: true, - internal: false, - }, }, }, }; expect(resolveSilentReplyPolicy({ cfg, surface: "webchat" })).toBe("disallow"); - expect(resolveSilentReplyRewriteEnabled({ cfg, surface: "webchat" })).toBe(false); expect( resolveSilentReplyPolicy({ cfg, @@ -52,16 +39,9 @@ describe("silent reply config resolution", () => { surface: "discord", }), ).toBe("disallow"); - expect( - resolveSilentReplyRewriteEnabled({ - cfg, - sessionKey: "agent:main:discord:group:123", - surface: "discord", - }), - ).toBe(true); }); - it("lets surface overrides beat the default policy and rewrite flags", () => { + it("lets surface overrides beat the default policy", () => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -70,11 +50,6 @@ describe("silent reply config resolution", () => { group: "allow", internal: "allow", }, - silentReplyRewrite: { - direct: true, - group: false, - internal: false, - }, }, }, surfaces: { @@ -82,9 +57,6 @@ describe("silent reply config resolution", () => { silentReply: { direct: "allow", }, - silentReplyRewrite: { - direct: false, - }, }, }, }; @@ -96,6 +68,34 @@ describe("silent reply config resolution", () => { surface: "telegram", }), ).toBe("allow"); + }); + + it("resolves rewrite defaults and surface overrides by conversation type", () => { + expect(resolveSilentReplyRewriteEnabled({ surface: "webchat" })).toBe(true); + expect( + resolveSilentReplyRewriteEnabled({ + sessionKey: "agent:main:telegram:group:123", + surface: "telegram", + }), + ).toBe(false); + + const cfg: OpenClawConfig = { + agents: { + defaults: { + silentReplyRewrite: { + direct: true, + }, + }, + }, + surfaces: { + telegram: { + silentReplyRewrite: { + direct: false, + }, + }, + }, + }; + expect( resolveSilentReplyRewriteEnabled({ cfg, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index a7d16aa4fe8..558ca7e3718 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -526,6 +526,7 @@ export const sendHandlers: GatewayRequestHandlers = { cfg, agentId: effectiveAgentId, sessionKey: outboundSessionKey, + conversationType: outboundRoute?.chatType, }); const results = await deliverOutboundPayloads({ cfg, diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 21b7c990ee0..68ea95c4989 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -915,7 +915,7 @@ describe("deliverOutboundPayloads", () => { ); }); - it("applies silent-reply policy from the outbound session", async () => { + it("applies silent-reply rewrite policy from the outbound session", async () => { const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m-silent", roomId: "!room" }); const cfg: OpenClawConfig = { agents: { @@ -925,9 +925,6 @@ describe("deliverOutboundPayloads", () => { group: "allow", internal: "allow", }, - silentReplyRewrite: { - direct: true, - }, }, }, }; @@ -945,7 +942,7 @@ describe("deliverOutboundPayloads", () => { }); expect(sendMatrix).toHaveBeenCalledTimes(1); - expect(sendMatrix.mock.calls[0]?.[1]).toEqual(expect.any(String)); + expect(sendMatrix.mock.calls[0]?.[1]).toBeTruthy(); expect(sendMatrix.mock.calls[0]?.[1]).not.toBe("NO_REPLY"); }); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 26688b30938..0ae718ca2de 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -730,6 +730,7 @@ async function deliverOutboundPayloadsCore( cfg, sessionKey: params.session?.policyKey ?? params.session?.key, surface: channel, + conversationType: params.session?.conversationType, }); const accountId = params.accountId; const deps = params.deps; diff --git a/src/infra/outbound/payloads.test.ts b/src/infra/outbound/payloads.test.ts index f5f5ef08490..2bb1cc862ab 100644 --- a/src/infra/outbound/payloads.test.ts +++ b/src/infra/outbound/payloads.test.ts @@ -189,7 +189,7 @@ describe("normalizeReplyPayloadsForDelivery", () => { ]); }); - it("rewrites bare silent replies for direct conversations when requested", () => { + it("rewrites bare silent replies for direct conversations where silence is disallowed", () => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -198,9 +198,6 @@ describe("normalizeReplyPayloadsForDelivery", () => { group: "allow", internal: "allow", }, - silentReplyRewrite: { - direct: true, - }, }, }, }; @@ -214,7 +211,7 @@ describe("normalizeReplyPayloadsForDelivery", () => { }), ); expect(projected).toHaveLength(1); - expect(projected[0]?.text).toEqual(expect.any(String)); + expect(projected[0]?.text?.trim()).toBeTruthy(); expect(projected[0]?.text?.trim()).not.toBe("NO_REPLY"); }); @@ -227,9 +224,6 @@ describe("normalizeReplyPayloadsForDelivery", () => { group: "allow", internal: "allow", }, - silentReplyRewrite: { - direct: true, - }, }, }, }; @@ -245,7 +239,7 @@ describe("normalizeReplyPayloadsForDelivery", () => { ).toEqual([]); }); - it("does not add rewrite chatter when visible content is already being delivered", () => { + it("does not add silent-reply chatter when visible content is already being delivered", () => { const cfg: OpenClawConfig = { agents: { defaults: { @@ -254,9 +248,6 @@ describe("normalizeReplyPayloadsForDelivery", () => { group: "allow", internal: "allow", }, - silentReplyRewrite: { - direct: true, - }, }, }, }; @@ -281,7 +272,6 @@ describe("normalizeReplyPayloadsForDelivery", () => { agents: { defaults: { silentReply: { direct: "disallow", group: "allow", internal: "allow" }, - silentReplyRewrite: { direct: true }, }, }, }; @@ -309,7 +299,7 @@ describe("normalizeReplyPayloadsForDelivery", () => { } }); - it("falls back to the rewrite path when the query throws", () => { + it("falls back to the visible rewrite path when the query throws", () => { const previousQuery = registerPendingSpawnedChildrenQuery(() => { throw new Error("registry unavailable"); }); @@ -317,14 +307,14 @@ describe("normalizeReplyPayloadsForDelivery", () => { const delivery = planSilent("agent:main:telegram:direct:789"); expect(delivery).toHaveLength(1); expect(delivery[0]?.text).toBeTruthy(); - expect(delivery[0]?.text).not.toMatch(/NO_REPLY/i); + expect(delivery[0]?.text).not.toBe("NO_REPLY"); } finally { registerPendingSpawnedChildrenQuery(previousQuery); } }); }); - it("keeps bare NO_REPLY visible when silence is disallowed but rewrite is off", () => { + it("keeps bare NO_REPLY visible when silence is disallowed and rewrite is disabled", () => { const cfg: OpenClawConfig = { agents: { defaults: { diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index 5b0569f92de..fdfca6846c8 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -243,18 +243,18 @@ export function createOutboundPayloadPlan( }); continue; } - const rewrittenPayload: ReplyPayload = { + const visibleSilentPayload: ReplyPayload = { ...entry.payload, text: resolveSilentReplyRewriteText({ seed: `${context.sessionKey ?? context.surface ?? "silent-reply"}:${entry.payload.text ?? ""}`, }), }; - if (!isRenderablePayload(rewrittenPayload)) { + if (!isRenderablePayload(visibleSilentPayload)) { continue; } plan.push({ - payload: rewrittenPayload, - parts: resolveSendableOutboundReplyParts(rewrittenPayload), + payload: visibleSilentPayload, + parts: resolveSendableOutboundReplyParts(visibleSilentPayload), hasPresentation: entry.hasPresentation, hasInteractive: entry.hasInteractive, hasChannelData: entry.hasChannelData, diff --git a/src/infra/outbound/session-context.test.ts b/src/infra/outbound/session-context.test.ts index f5c86bd7110..6f03be36829 100644 --- a/src/infra/outbound/session-context.test.ts +++ b/src/infra/outbound/session-context.test.ts @@ -115,6 +115,49 @@ describe("buildOutboundSessionContext", () => { }); }); + it("normalizes explicit conversation type for policy resolution", () => { + expect( + buildOutboundSessionContext({ + cfg: {} as never, + sessionKey: "agent:main:generic", + conversationType: "channel", + }), + ).toEqual({ + key: "agent:main:generic", + conversationType: "group", + }); + + expect( + buildOutboundSessionContext({ + cfg: {} as never, + conversationType: "dm", + }), + ).toEqual({ + conversationType: "direct", + }); + }); + + it("falls back to isGroup when no explicit conversation type is provided", () => { + expect( + buildOutboundSessionContext({ + cfg: {} as never, + sessionKey: "agent:main:generic", + isGroup: true, + }), + ).toEqual({ + key: "agent:main:generic", + conversationType: "group", + }); + expect( + buildOutboundSessionContext({ + cfg: {} as never, + isGroup: false, + }), + ).toEqual({ + conversationType: "direct", + }); + }); + it("returns undefined when all sender and session fields are blank", () => { expect( buildOutboundSessionContext({ diff --git a/src/infra/outbound/session-context.ts b/src/infra/outbound/session-context.ts index 4f4ae41f1c9..ce0439c8e5c 100644 --- a/src/infra/outbound/session-context.ts +++ b/src/infra/outbound/session-context.ts @@ -1,5 +1,7 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; export type OutboundSessionContext = { @@ -7,6 +9,8 @@ export type OutboundSessionContext = { key?: string; /** Session key used for policy resolution when delivery differs from the control session. */ policyKey?: string; + /** Explicit conversation type for policy resolution when a session key is generic. */ + conversationType?: SilentReplyConversationType; /** Active agent id used for workspace-scoped media roots. */ agentId?: string; /** Originating account id used for requester-scoped group policy resolution. */ @@ -25,6 +29,8 @@ export function buildOutboundSessionContext(params: { cfg: OpenClawConfig; sessionKey?: string | null; policySessionKey?: string | null; + conversationType?: string | null; + isGroup?: boolean | null; agentId?: string | null; requesterAccountId?: string | null; requesterSenderId?: string | null; @@ -34,6 +40,17 @@ export function buildOutboundSessionContext(params: { }): OutboundSessionContext | undefined { const key = normalizeOptionalString(params.sessionKey); const policyKey = normalizeOptionalString(params.policySessionKey); + const normalizedChatType = normalizeChatType(params.conversationType ?? undefined); + const conversationType: SilentReplyConversationType | undefined = + normalizedChatType === "group" || normalizedChatType === "channel" + ? "group" + : normalizedChatType === "direct" + ? "direct" + : params.isGroup === true + ? "group" + : params.isGroup === false + ? "direct" + : undefined; const explicitAgentId = normalizeOptionalString(params.agentId); const requesterAccountId = normalizeOptionalString(params.requesterAccountId); const requesterSenderId = normalizeOptionalString(params.requesterSenderId); @@ -47,6 +64,7 @@ export function buildOutboundSessionContext(params: { if ( !key && !policyKey && + !conversationType && !agentId && !requesterAccountId && !requesterSenderId && @@ -59,6 +77,7 @@ export function buildOutboundSessionContext(params: { return { ...(key ? { key } : {}), ...(policyKey ? { policyKey } : {}), + ...(conversationType ? { conversationType } : {}), ...(agentId ? { agentId } : {}), ...(requesterAccountId ? { requesterAccountId } : {}), ...(requesterSenderId ? { requesterSenderId } : {}), diff --git a/src/shared/silent-reply-policy.test.ts b/src/shared/silent-reply-policy.test.ts index bdd23623013..99c0de2c8e4 100644 --- a/src/shared/silent-reply-policy.test.ts +++ b/src/shared/silent-reply-policy.test.ts @@ -37,6 +37,37 @@ describe("classifySilentReplyConversationType", () => { }); }); +describe("resolveSilentReplyRewriteFromPolicies", () => { + it("uses defaults when no overrides exist", () => { + expect(resolveSilentReplyRewriteFromPolicies({ conversationType: "direct" })).toBe( + DEFAULT_SILENT_REPLY_REWRITE.direct, + ); + expect(resolveSilentReplyRewriteFromPolicies({ conversationType: "group" })).toBe( + DEFAULT_SILENT_REPLY_REWRITE.group, + ); + }); + + it("prefers surface rewrite settings over defaults", () => { + expect( + resolveSilentReplyRewriteFromPolicies({ + conversationType: "direct", + defaultRewrite: { direct: true }, + surfaceRewrite: { direct: false }, + }), + ).toBe(false); + }); +}); + +describe("resolveSilentReplyRewriteText", () => { + it("picks a deterministic rewrite for a given seed", () => { + const first = resolveSilentReplyRewriteText({ seed: "main:NO_REPLY" }); + const second = resolveSilentReplyRewriteText({ seed: "main:NO_REPLY" }); + expect(first).toBe(second); + expect(first).not.toBe("NO_REPLY"); + expect(first.length).toBeGreaterThan(0); + }); +}); + describe("resolveSilentReplyPolicyFromPolicies", () => { it("uses defaults when no overrides exist", () => { expect(resolveSilentReplyPolicyFromPolicies({ conversationType: "direct" })).toBe( @@ -57,34 +88,3 @@ describe("resolveSilentReplyPolicyFromPolicies", () => { ).toBe("allow"); }); }); - -describe("resolveSilentReplyRewriteFromPolicies", () => { - it("uses default rewrite flags when no overrides exist", () => { - expect(resolveSilentReplyRewriteFromPolicies({ conversationType: "direct" })).toBe( - DEFAULT_SILENT_REPLY_REWRITE.direct, - ); - expect(resolveSilentReplyRewriteFromPolicies({ conversationType: "group" })).toBe( - DEFAULT_SILENT_REPLY_REWRITE.group, - ); - }); - - it("prefers surface rewrite flags over defaults", () => { - expect( - resolveSilentReplyRewriteFromPolicies({ - conversationType: "direct", - defaultRewrite: { direct: true }, - surfaceRewrite: { direct: false }, - }), - ).toBe(false); - }); -}); - -describe("resolveSilentReplyRewriteText", () => { - it("picks a deterministic rewrite for a given seed", () => { - const first = resolveSilentReplyRewriteText({ seed: "main:NO_REPLY" }); - const second = resolveSilentReplyRewriteText({ seed: "main:NO_REPLY" }); - expect(first).toBe(second); - expect(first).not.toBe("NO_REPLY"); - expect(first.length).toBeGreaterThan(0); - }); -}); diff --git a/test/helpers/agents/prompt-composition-scenarios.ts b/test/helpers/agents/prompt-composition-scenarios.ts index ffcc44ae4f9..97a47f55280 100644 --- a/test/helpers/agents/prompt-composition-scenarios.ts +++ b/test/helpers/agents/prompt-composition-scenarios.ts @@ -10,7 +10,11 @@ import { resolveBootstrapContextForRun } from "../../../src/agents/bootstrap-fil import { buildEmbeddedSystemPrompt } from "../../../src/agents/pi-embedded-runner/system-prompt.js"; import { buildAgentSystemPrompt } from "../../../src/agents/system-prompt.js"; import { createStubTool } from "../../../src/agents/test-helpers/pi-tool-stubs.js"; -import { buildGroupChatContext, buildGroupIntro } from "../../../src/auto-reply/reply/groups.js"; +import { + buildDirectChatContext, + buildGroupChatContext, + buildGroupIntro, +} from "../../../src/auto-reply/reply/groups.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix, @@ -118,6 +122,14 @@ function buildAutoReplySystemPrompt(params: { }) { const extraSystemPromptParts = [ buildInboundMetaSystemPrompt(params.sessionCtx), + params.sessionCtx.ChatType === "direct" || params.sessionCtx.ChatType === "dm" + ? buildDirectChatContext({ + sessionCtx: params.sessionCtx, + silentToken: SILENT_REPLY_TOKEN, + silentReplyPolicy: "disallow", + silentReplyRewrite: true, + }) + : "", params.includeGroupChatContext ? buildGroupChatContext({ sessionCtx: params.sessionCtx }) : "", params.includeGroupIntro ? buildGroupIntro({ @@ -179,7 +191,7 @@ function createDirectScenario(workspaceDir: string): PromptScenario { OriginatingChannel: "slack", OriginatingTo: "D123", AccountId: "A1", - ChatType: "direct", + ChatType: "dm", SenderId: "U1", SenderName: "Alice", Body: "hi",