feat: add no_reply tuning by conversation type

This commit is contained in:
Tak Hoffman
2026-04-18 11:44:35 -05:00
parent 666270cceb
commit 89684fab22
24 changed files with 1072 additions and 36 deletions

View File

@@ -3,6 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveChunkMode as resolveChunkModeRuntime } from "../../../src/auto-reply/chunk.js";
import { resolveMarkdownTableMode as resolveMarkdownTableModeRuntime } from "../../../src/config/markdown-tables.js";
import { resolveSessionStoreEntry as resolveSessionStoreEntryRuntime } from "../../../src/config/sessions/store.js";
import type { OpenClawConfig } from "../../../src/config/types.openclaw.js";
import { getAgentScopedMediaLocalRoots as getAgentScopedMediaLocalRootsRuntime } from "../../../src/media/local-roots.js";
import { resolveAutoTopicLabelConfig as resolveAutoTopicLabelConfigRuntime } from "./auto-topic-label.js";
import type { TelegramBotDeps } from "./bot-deps.js";
@@ -2550,6 +2551,85 @@ describe("dispatchTelegramMessage draft streaming", () => {
expect(draftStream.clear).toHaveBeenCalledTimes(1);
});
it("rewrites a no-visible-response DM turn through silent-reply fallback", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({
queuedFinal: false,
});
deliverReplies.mockResolvedValueOnce({ delivered: true });
await dispatchWithContext({
context: createContext({
ctxPayload: {
SessionKey: "agent:main:telegram:direct:123",
} as unknown as TelegramMessageContext["ctxPayload"],
}),
cfg: {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
} as unknown as OpenClawConfig,
});
expect(deliverReplies).toHaveBeenCalledTimes(1);
const deliveredReplies = deliverReplies.mock.calls[0]?.[0]?.replies;
expect(Array.isArray(deliveredReplies)).toBe(true);
expect(deliveredReplies?.[0]?.text).toEqual(expect.any(String));
expect(deliveredReplies?.[0]?.text?.trim()).not.toBe("NO_REPLY");
});
it("keeps no-visible-response group turns silent when policy allows silence", async () => {
const draftStream = createDraftStream(999);
createTelegramDraftStream.mockReturnValue(draftStream);
dispatchReplyWithBufferedBlockDispatcher.mockResolvedValue({
queuedFinal: false,
});
await dispatchWithContext({
context: createContext({
isGroup: true,
primaryCtx: {
message: { chat: { id: 123, type: "supergroup" } },
} as TelegramMessageContext["primaryCtx"],
msg: {
chat: { id: 123, type: "supergroup" },
message_id: 456,
message_thread_id: 777,
} as TelegramMessageContext["msg"],
threadSpec: { id: 777, scope: "group" },
ctxPayload: {
SessionKey: "agent:main:telegram:group:123",
} as unknown as TelegramMessageContext["ctxPayload"],
}),
cfg: {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
} as unknown as OpenClawConfig,
});
expect(deliverReplies).not.toHaveBeenCalled();
});
it("sends fallback and clears preview when deliver throws (dispatcher swallows error)", async () => {
const draftStream = createDraftStream();
createTelegramDraftStream.mockReturnValue(draftStream);

View File

@@ -12,11 +12,15 @@ import type {
TelegramAccountConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
createOutboundPayloadPlan,
projectOutboundPayloadPlanForDelivery,
} from "openclaw/plugin-sdk/outbound-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { isAbortRequestText, type ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger, danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js";
import type { TelegramMessageContext } from "./bot-message-context.js";
import {
@@ -65,6 +69,7 @@ import { editMessageTelegram } from "./send.js";
import { cacheSticker, describeStickerImage } from "./sticker-cache.js";
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
const silentReplyDispatchLogger = createSubsystemLogger("telegram/silent-reply-dispatch");
/** Minimum chars before sending first streaming message (improves push notification UX) */
const DRAFT_MIN_INITIAL_CHARS = 30;
@@ -1062,6 +1067,35 @@ export const dispatchTelegramMessage = async ({
sentFallback = result.delivered;
}
if (!queuedFinal && !sentFallback && !dispatchError) {
const policySessionKey =
ctxPayload.CommandSource === "native"
? (ctxPayload.CommandTargetSessionKey ?? ctxPayload.SessionKey)
: ctxPayload.SessionKey;
const silentReplyFallback = projectOutboundPayloadPlanForDelivery(
createOutboundPayloadPlan([{ text: "NO_REPLY" }], {
cfg,
sessionKey: policySessionKey,
surface: "telegram",
}),
);
if (silentReplyFallback.length > 0) {
const result = await (telegramDeps.deliverReplies ?? deliverReplies)({
replies: silentReplyFallback,
...deliveryBaseOptions,
silent: false,
mediaLoader: telegramDeps.loadWebMedia,
});
sentFallback = result.delivered;
}
silentReplyDispatchLogger.info("telegram turn ended without visible final response", {
sessionKey: policySessionKey,
chatId: String(chatId),
queuedFinal,
sentFallback,
});
}
const hasFinalResponse = queuedFinal || sentFallback;
if (statusReactionController && !hasFinalResponse) {

View File

@@ -19,7 +19,6 @@ import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import type {
ReplyToMode,
TelegramAccountConfig,
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-runtime";
@@ -322,7 +321,7 @@ async function resolveTelegramCommandAuth(params: {
!isGroup && groupConfig && "dmPolicy" in groupConfig
? (groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing")
: (telegramCfg.dmPolicy ?? "pairing");
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
const requireTopic = groupConfig?.requireTopic;
if (!isGroup && requireTopic === true && dmThreadId == null) {
logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`);
return null;
@@ -694,6 +693,7 @@ export const registerTelegramNativeCommands = ({
chunkMode: TelegramChunkMode;
linkPreview?: boolean;
}) => ({
cfg,
chatId: String(params.chatId),
accountId: params.accountId,
sessionKeyForInternalHooks: params.sessionKeyForInternalHooks,

View File

@@ -11,11 +11,16 @@ import {
} from "openclaw/plugin-sdk/hook-runtime";
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
createOutboundPayloadPlan,
projectOutboundPayloadPlanForDelivery,
} from "openclaw/plugin-sdk/outbound-runtime";
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import type { TelegramInlineButtons } from "../button-types.js";
@@ -45,6 +50,7 @@ const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
const CAPTION_TOO_LONG_RE = /caption is too long/i;
const GrammyErrorCtor: typeof GrammyError | undefined =
typeof GrammyError === "function" ? GrammyError : undefined;
const silentReplyLogger = createSubsystemLogger("telegram/silent-reply");
type DeliveryProgress = ReplyThreadDeliveryProgress & {
deliveredCount: number;
@@ -581,6 +587,7 @@ export function emitTelegramMessageSentHooks(params: EmitMessageSentHookParams):
export async function deliverReplies(params: {
replies: ReplyPayload[];
cfg?: import("openclaw/plugin-sdk/config-runtime").OpenClawConfig;
chatId: string;
accountId?: string;
sessionKeyForInternalHooks?: string;
@@ -620,7 +627,35 @@ export async function deliverReplies(params: {
chunkMode: params.chunkMode ?? "length",
tableMode: params.tableMode,
});
for (const originalReply of params.replies) {
const candidateReplies: ReplyPayload[] = [];
for (const reply of params.replies) {
if (!reply || typeof reply !== "object") {
params.runtime.error?.(danger("reply missing text/media"));
continue;
}
candidateReplies.push(reply);
}
const normalizedReplies = projectOutboundPayloadPlanForDelivery(
createOutboundPayloadPlan(candidateReplies, {
cfg: params.cfg,
sessionKey: params.sessionKeyForInternalHooks,
surface: "telegram",
}),
);
const originalExactSilentCount = candidateReplies.filter(
(reply) => typeof reply.text === "string" && reply.text.trim().toUpperCase() === "NO_REPLY",
).length;
if (originalExactSilentCount > 0) {
silentReplyLogger.info("telegram delivery normalized NO_REPLY candidates", {
sessionKey: params.sessionKeyForInternalHooks,
chatId: params.chatId,
originalCount: candidateReplies.length,
normalizedCount: normalizedReplies.length,
originalExactSilentCount,
normalizedTexts: normalizedReplies.map((reply) => reply.text ?? ""),
});
}
for (const originalReply of normalizedReplies) {
let reply = originalReply;
const mediaList = reply?.mediaUrls?.length
? reply.mediaUrls

View File

@@ -1,7 +1,6 @@
import type { Bot } from "grammy";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
}));
@@ -294,6 +293,38 @@ describe("deliverReplies", () => {
expect(triggerInternalHook).not.toHaveBeenCalled();
});
it("rewrites exact NO_REPLY for direct Telegram sessions", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 12, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
sessionKeyForInternalHooks: "agent:test:telegram:direct:123",
replies: [{ text: "NO_REPLY" }],
runtime,
bot,
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls[0]?.[1]).toEqual(expect.any(String));
expect(sendMessage.mock.calls[0]?.[1]?.trim()).not.toBe("NO_REPLY");
});
it("suppresses exact NO_REPLY for group Telegram sessions", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" } });
const bot = createBot({ sendMessage });
await deliverWith({
sessionKeyForInternalHooks: "agent:test:telegram:group:123",
replies: [{ text: "NO_REPLY" }],
runtime,
bot,
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("emits internal message:sent with success=false on delivery failure", async () => {
const runtime = createRuntime(false);
const sendMessage = vi.fn().mockRejectedValue(new Error("network error"));

View File

@@ -167,4 +167,37 @@ describe("withReplyDispatcher", () => {
expect(typing.markRunComplete).toHaveBeenCalledTimes(1);
expect(typing.markDispatchIdle).toHaveBeenCalled();
});
it("uses CommandTargetSessionKey for silent-reply policy on native command turns", 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:telegram:slash:8231046597",
CommandSource: "native",
CommandTargetSessionKey: "agent:test:telegram:direct:8231046597",
Surface: "telegram",
}),
cfg: {} as OpenClawConfig,
dispatcherOptions: {
deliver: async () => undefined,
},
replyResolver: async () => ({ text: "ok" }),
});
expect(hoisted.createReplyDispatcherWithTypingMock).toHaveBeenCalledWith(
expect.objectContaining({
silentReplyContext: expect.objectContaining({
sessionKey: "agent:test:telegram:direct:8231046597",
surface: "telegram",
}),
}),
);
});
});

View File

@@ -14,6 +14,22 @@ import type { ReplyDispatcher } from "./reply/reply-dispatcher.types.js";
import type { FinalizedMsgContext, MsgContext } from "./templating.js";
import type { GetReplyOptions } from "./types.js";
function resolveDispatcherSilentReplyContext(
ctx: MsgContext | FinalizedMsgContext,
cfg: OpenClawConfig,
) {
const finalized = finalizeInboundContext(ctx);
const policySessionKey =
finalized.CommandSource === "native"
? (finalized.CommandTargetSessionKey ?? finalized.SessionKey)
: finalized.SessionKey;
return {
cfg,
sessionKey: policySessionKey,
surface: finalized.Surface ?? finalized.Provider,
};
}
export type DispatchInboundResult = DispatchFromConfigResult;
export { withReplyDispatcher } from "./dispatch-dispatcher.js";
@@ -45,8 +61,12 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: GetReplyFromConfig;
}): Promise<DispatchInboundResult> {
const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg);
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
createReplyDispatcherWithTyping(params.dispatcherOptions);
createReplyDispatcherWithTyping({
...params.dispatcherOptions,
silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext,
});
try {
return await dispatchInboundMessage({
ctx: params.ctx,
@@ -71,7 +91,11 @@ export async function dispatchInboundMessageWithDispatcher(params: {
replyOptions?: Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
replyResolver?: GetReplyFromConfig;
}): Promise<DispatchInboundResult> {
const dispatcher = createReplyDispatcher(params.dispatcherOptions);
const silentReplyContext = resolveDispatcherSilentReplyContext(params.ctx, params.cfg);
const dispatcher = createReplyDispatcher({
...params.dispatcherOptions,
silentReplyContext: params.dispatcherOptions.silentReplyContext ?? silentReplyContext,
});
return await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,

View File

@@ -381,6 +381,10 @@ export async function dispatchReplyFromConfig(
channel: originatingChannel,
to: originatingTo,
sessionKey: ctx.SessionKey,
policySessionKey:
ctx.CommandSource === "native"
? (ctx.CommandTargetSessionKey ?? ctx.SessionKey)
: ctx.SessionKey,
accountId: ctx.AccountId,
requesterSenderId: ctx.SenderId,
requesterSenderName: ctx.SenderName,

View File

@@ -1,7 +1,12 @@
import type { TypingCallbacks } from "../../channels/typing.js";
import { resolveSilentReplyPolicy } 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 { sleep } from "../../utils.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { registerDispatcher } from "./dispatcher-registry.js";
import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js";
@@ -25,6 +30,7 @@ type ReplyDispatchDeliverer = (
const DEFAULT_HUMAN_DELAY_MIN_MS = 800;
const DEFAULT_HUMAN_DELAY_MAX_MS = 2500;
const silentReplyLogger = createSubsystemLogger("silent-reply/dispatcher");
/** Generate a random delay within the configured range. */
function getHumanDelay(config: HumanDelayConfig | undefined): number {
@@ -44,6 +50,12 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number {
export type ReplyDispatcherOptions = {
deliver: ReplyDispatchDeliverer;
silentReplyContext?: {
cfg?: OpenClawConfig;
sessionKey?: string;
surface?: string;
conversationType?: SilentReplyConversationType;
};
responsePrefix?: string;
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
/** Static context for response prefix template interpolation. */
@@ -103,6 +115,39 @@ function normalizeReplyPayloadInternal(
});
}
function shouldPreserveSilentFinalPayload(params: {
kind: ReplyDispatchKind;
payload: ReplyPayload;
silentReplyContext?: ReplyDispatcherOptions["silentReplyContext"];
}): boolean {
if (params.kind !== "final") {
return false;
}
if (!isSilentReplyText(params.payload.text, SILENT_REPLY_TOKEN)) {
return false;
}
const context = params.silentReplyContext;
if (!context) {
return false;
}
const resolvedPolicy = resolveSilentReplyPolicy({
cfg: context.cfg,
sessionKey: context.sessionKey,
surface: context.surface,
conversationType: context.conversationType,
});
const shouldPreserve = resolvedPolicy !== "allow";
if (shouldPreserve) {
silentReplyLogger.info("preserving exact NO_REPLY final payload before normalization", {
sessionKey: context.sessionKey,
surface: context.surface,
conversationType: context.conversationType,
resolvedPolicy,
});
}
return shouldPreserve;
}
export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDispatcher {
let sendChain: Promise<void> = Promise.resolve();
// Track in-flight deliveries so we can emit a reliable "idle" signal.
@@ -131,15 +176,32 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
});
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
const normalized = 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 originalWasExactSilent = isSilentReplyText(payload.text, SILENT_REPLY_TOKEN);
const normalized = shouldPreserveSilentFinalPayload({
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 }),
});
if (!normalized) {
if (kind === "final" && originalWasExactSilent) {
silentReplyLogger.info("exact NO_REPLY final payload was skipped before delivery", {
sessionKey: options.silentReplyContext?.sessionKey,
surface: options.silentReplyContext?.surface,
conversationType: options.silentReplyContext?.conversationType,
});
}
return false;
}
queuedCounts[kind] += 1;

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
import { createReplyDispatcher } from "./reply-dispatcher.js";
import { createReplyToModeFilter } from "./reply-threading.js";
@@ -20,6 +21,69 @@ 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 () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
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);
});
it("still drops exact NO_REPLY final payloads for group sessions where silence is allowed", async () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
const dispatcher = createReplyDispatcher({
deliver,
silentReplyContext: {
cfg,
sessionKey: "agent:main:telegram:group:123",
surface: "telegram",
},
});
expect(dispatcher.sendFinalReply({ text: SILENT_REPLY_TOKEN })).toBe(false);
await dispatcher.waitForIdle();
expect(deliver).not.toHaveBeenCalled();
});
it("strips heartbeat tokens and applies responsePrefix", async () => {
const deliver = vi.fn().mockResolvedValue(undefined);
const onHeartbeatStrip = vi.fn();

View File

@@ -16,9 +16,15 @@ const mocks = vi.hoisted(() => ({
deliverOutboundPayloads: vi.fn(),
}));
vi.mock("../../infra/outbound/deliver-runtime.js", () => ({
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
}));
vi.mock("../../infra/outbound/deliver-runtime.js", async () => {
const actual = await vi.importActual<typeof import("../../infra/outbound/deliver-runtime.js")>(
"../../infra/outbound/deliver-runtime.js",
);
return {
...actual,
deliverOutboundPayloads: mocks.deliverOutboundPayloads,
};
});
const { routeReply } = await import("./route-reply.js");
@@ -222,6 +228,40 @@ describe("routeReply", () => {
});
});
it("passes policySessionKey through to outbound delivery for native command targets", async () => {
const cfg = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
} as unknown as OpenClawConfig;
const res = await routeReply({
payload: { text: SILENT_REPLY_TOKEN },
channel: "telegram",
to: "8231046597",
cfg,
sessionKey: "agent:test:telegram:slash:8231046597",
policySessionKey: "agent:test:telegram:direct:8231046597",
});
expect(res.ok).toBe(true);
expectLastDelivery({
session: expect.objectContaining({
key: "agent:test:telegram:slash:8231046597",
policyKey: "agent:test:telegram:direct:8231046597",
}),
});
});
it("applies responsePrefix when routing", async () => {
const cfg = {
messages: { responsePrefix: "[openclaw]" },

View File

@@ -44,6 +44,8 @@ export type RouteReplyParams = {
to: string;
/** Session key for deriving agent identity defaults (multi-agent). */
sessionKey?: string;
/** Session key for policy resolution when native-command delivery targets a different session. */
policySessionKey?: string;
/** Provider account id (multi-account). */
accountId?: string;
/** Originating sender id for sender-scoped outbound media policy. */
@@ -93,11 +95,10 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
const normalizedChannel = normalizeMessageChannel(channel);
const channelId =
normalizeChannelId(channel) ?? normalizeOptionalLowercaseString(channel) ?? null;
const plugin = channelId
? (getLoadedChannelPlugin(channelId) ?? getBundledChannelPlugin(channelId))
: undefined;
const messaging = plugin?.messaging;
const threading = plugin?.threading;
const loadedPlugin = channelId ? getLoadedChannelPlugin(channelId) : undefined;
const bundledPlugin = channelId ? getBundledChannelPlugin(channelId) : undefined;
const messaging = loadedPlugin?.messaging ?? bundledPlugin?.messaging;
const threading = loadedPlugin?.threading ?? bundledPlugin?.threading;
const resolvedAgentId = params.sessionKey
? resolveSessionAgentId({
sessionKey: params.sessionKey,
@@ -196,6 +197,7 @@ export async function routeReply(params: RouteReplyParams): Promise<RouteReplyRe
cfg,
agentId: resolvedAgentId,
sessionKey: params.sessionKey,
policySessionKey: params.policySessionKey,
requesterSenderId: params.requesterSenderId,
requesterSenderName: params.requesterSenderName,
requesterSenderUsername: params.requesterSenderUsername,

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { resolveSilentReplyPolicy, resolveSilentReplyRewriteEnabled } from "./silent-reply.js";
import type { OpenClawConfig } from "./types.openclaw.js";
describe("silent reply config resolution", () => {
it("uses the default direct/group/internal policy and rewrite flags", () => {
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",
}),
).toBe("allow");
});
it("applies configured defaults by conversation type", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
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,
sessionKey: "agent:main:discord:group:123",
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", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
group: false,
internal: false,
},
},
},
surfaces: {
telegram: {
silentReply: {
direct: "allow",
},
silentReplyRewrite: {
direct: false,
},
},
},
};
expect(
resolveSilentReplyPolicy({
cfg,
sessionKey: "agent:main:telegram:direct:123",
surface: "telegram",
}),
).toBe("allow");
expect(
resolveSilentReplyRewriteEnabled({
cfg,
sessionKey: "agent:main:telegram:direct:123",
surface: "telegram",
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,48 @@
import {
classifySilentReplyConversationType,
resolveSilentReplyPolicyFromPolicies,
resolveSilentReplyRewriteFromPolicies,
type SilentReplyConversationType,
type SilentReplyPolicy,
} from "../shared/silent-reply-policy.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import type { OpenClawConfig } from "./types.openclaw.js";
type ResolveSilentReplyParams = {
cfg?: OpenClawConfig;
sessionKey?: string;
surface?: string;
conversationType?: SilentReplyConversationType;
};
export function resolveSilentReplyPolicy(params: ResolveSilentReplyParams): SilentReplyPolicy {
const conversationType = classifySilentReplyConversationType({
sessionKey: params.sessionKey,
surface: params.surface,
conversationType: params.conversationType,
});
const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface);
return resolveSilentReplyPolicyFromPolicies({
conversationType,
defaultPolicy: params.cfg?.agents?.defaults?.silentReply,
surfacePolicy: normalizedSurface
? params.cfg?.surfaces?.[normalizedSurface]?.silentReply
: undefined,
});
}
export function resolveSilentReplyRewriteEnabled(params: ResolveSilentReplyParams): boolean {
const conversationType = classifySilentReplyConversationType({
sessionKey: params.sessionKey,
surface: params.surface,
conversationType: params.conversationType,
});
const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface);
return resolveSilentReplyRewriteFromPolicies({
conversationType,
defaultRewrite: params.cfg?.agents?.defaults?.silentReplyRewrite,
surfaceRewrite: normalizedSurface
? params.cfg?.surfaces?.[normalizedSurface]?.silentReplyRewrite
: undefined,
});
}

View File

@@ -1,3 +1,7 @@
import type {
SilentReplyPolicyShape,
SilentReplyRewriteShape,
} from "../shared/silent-reply-policy.js";
import type {
AgentEmbeddedHarnessConfig,
AgentModelConfig,
@@ -191,6 +195,10 @@ export type AgentDefaultsConfig = {
workspace?: string;
/** Optional default allowlist of skills for agents that do not set agents.list[].skills. */
skills?: string[];
/** Silent-reply policy by conversation type. */
silentReply?: SilentReplyPolicyShape;
/** Whether disallowed silent replies should be rewritten by conversation type. */
silentReplyRewrite?: SilentReplyRewriteShape;
/** Optional repository root for system prompt runtime line (overrides auto-detect). */
repoRoot?: string;
/** Optional full system prompt replacement. Primarily for prompt debugging and controlled experiments. */
@@ -205,9 +213,9 @@ export type AgentDefaultsConfig = {
* transcript already contains a completed assistant turn
*/
contextInjection?: AgentContextInjection;
/** Max chars for injected bootstrap files before truncation (default: 12000). */
/** Max chars for injected bootstrap files before truncation (default: 20000). */
bootstrapMaxChars?: number;
/** Max total chars across all injected bootstrap files (default: 60000). */
/** Max total chars across all injected bootstrap files (default: 150000). */
bootstrapTotalMaxChars?: number;
/** Experimental agent-default flags. Keep off unless you are intentionally testing a preview surface. */
experimental?: {

View File

@@ -1,3 +1,7 @@
import type {
SilentReplyPolicyShape,
SilentReplyRewriteShape,
} from "../shared/silent-reply-policy.js";
import type { AcpConfig } from "./types.acp.js";
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
import type { ApprovalsConfig } from "./types.approvals.js";
@@ -29,9 +33,12 @@ import type { SecretsConfig } from "./types.secrets.js";
import type { SkillsConfig } from "./types.skills.js";
import type { ToolsConfig } from "./types.tools.js";
export type SurfaceConfigEntry = {
silentReply?: SilentReplyPolicyShape;
silentReplyRewrite?: SilentReplyRewriteShape;
};
export type OpenClawConfig = {
/** JSON Schema URL for editor tooling (VS Code, etc.). Preserved across config rewrites. */
$schema?: string;
meta?: {
/** Last OpenClaw version that wrote this config. */
lastTouchedVersion?: string;
@@ -97,6 +104,7 @@ export type OpenClawConfig = {
secrets?: SecretsConfig;
skills?: SkillsConfig;
plugins?: PluginsConfig;
surfaces?: Record<string, SurfaceConfigEntry>;
models?: ModelsConfig;
nodeHost?: NodeHostConfig;
agents?: AgentsConfig;

View File

@@ -17,6 +17,24 @@ import {
TypingModeSchema,
} from "./zod-schema.core.js";
export const SilentReplyPolicySchema = z.union([z.literal("allow"), z.literal("disallow")]);
export const SilentReplyPolicyConfigSchema = z
.object({
direct: SilentReplyPolicySchema.optional(),
group: SilentReplyPolicySchema.optional(),
internal: SilentReplyPolicySchema.optional(),
})
.strict();
export const SilentReplyRewriteConfigSchema = z
.object({
direct: z.boolean().optional(),
group: z.boolean().optional(),
internal: z.boolean().optional(),
})
.strict();
export const AgentDefaultsSchema = z
.object({
/** Global default provider params applied to all models before per-model and per-agent overrides. */
@@ -47,6 +65,8 @@ export const AgentDefaultsSchema = z
.optional(),
workspace: z.string().optional(),
skills: z.array(z.string()).optional(),
silentReply: SilentReplyPolicyConfigSchema.optional(),
silentReplyRewrite: SilentReplyRewriteConfigSchema.optional(),
repoRoot: z.string().optional(),
systemPromptOverride: z.string().optional(),
skipBootstrap: z.boolean().optional(),

View File

@@ -5,6 +5,10 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeStringifiedOptionalString,
} from "../shared/string-coerce.js";
import {
SilentReplyPolicyConfigSchema,
SilentReplyRewriteConfigSchema,
} from "./zod-schema.agent-defaults.js";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
import { ApprovalsSchema } from "./zod-schema.approvals.js";
@@ -964,6 +968,17 @@ export const OpenClawSchema = z
})
.strict()
.optional(),
surfaces: z
.record(
z.string(),
z
.object({
silentReply: SilentReplyPolicyConfigSchema.optional(),
silentReplyRewrite: SilentReplyRewriteConfigSchema.optional(),
})
.strict(),
)
.optional(),
})
.strict()
.superRefine((cfg, ctx) => {

View File

@@ -1,6 +1,7 @@
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { describe, expect, it } from "vitest";
import type { ReplyPayload } from "../../auto-reply/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { typedCases } from "../../test-utils/typed-cases.js";
import {
createOutboundPayloadPlan,
@@ -187,6 +188,124 @@ describe("normalizeReplyPayloadsForDelivery", () => {
]);
});
it("rewrites bare silent replies for direct conversations when requested", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
const sessionKey = "agent:main:telegram:direct:123";
const projected = projectOutboundPayloadPlanForDelivery(
createOutboundPayloadPlan([{ text: "NO_REPLY" }], {
cfg,
sessionKey,
surface: "telegram",
}),
);
expect(projected).toHaveLength(1);
expect(projected[0]?.text).toEqual(expect.any(String));
expect(projected[0]?.text?.trim()).not.toBe("NO_REPLY");
});
it("drops bare silent replies for groups when policy allows silence", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
expect(
projectOutboundPayloadPlanForDelivery(
createOutboundPayloadPlan([{ text: "NO_REPLY" }], {
cfg,
sessionKey: "agent:main:telegram:group:123",
surface: "telegram",
}),
),
).toEqual([]);
});
it("does not add rewrite chatter when visible content is already being delivered", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: true,
},
},
},
};
expect(
projectOutboundPayloadPlanForDelivery(
createOutboundPayloadPlan([{ text: "NO_REPLY" }, { text: "visible reply" }], {
cfg,
sessionKey: "agent:main:telegram:direct:123",
surface: "telegram",
}),
),
).toEqual([
expect.objectContaining({
text: "visible reply",
}),
]);
});
it("keeps bare NO_REPLY visible when silence is disallowed but rewrite is off", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
silentReply: {
direct: "disallow",
group: "allow",
internal: "allow",
},
silentReplyRewrite: {
direct: false,
},
},
},
};
expect(
projectOutboundPayloadPlanForDelivery(
createOutboundPayloadPlan([{ text: "NO_REPLY" }], {
cfg,
sessionKey: "agent:main:telegram:direct:123",
surface: "telegram",
}),
),
).toEqual([
expect.objectContaining({
text: "NO_REPLY",
}),
]);
});
it("is idempotent for already-normalized delivery payloads", () => {
const once = normalizeReplyPayloadsForDelivery([
{

View File

@@ -6,12 +6,21 @@ import {
shouldSuppressReasoningPayload,
} from "../../auto-reply/reply/reply-payloads.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
import {
resolveSilentReplyPolicy,
resolveSilentReplyRewriteEnabled,
} from "../../config/silent-reply.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
hasInteractiveReplyBlocks,
hasReplyChannelData,
hasReplyPayloadContent,
type InteractiveReply,
} from "../../interactive/payload.js";
import {
resolveSilentReplyRewriteText,
type SilentReplyConversationType,
} from "../../shared/silent-reply-policy.js";
export type NormalizedOutboundPayload = {
text: string;
@@ -37,6 +46,13 @@ export type OutboundPayloadPlan = {
hasChannelData: boolean;
};
type OutboundPayloadPlanContext = {
cfg?: OpenClawConfig;
sessionKey?: string;
surface?: string;
conversationType?: SilentReplyConversationType;
};
export type OutboundPayloadMirror = {
text: string;
mediaUrls: string[];
@@ -89,7 +105,16 @@ function mergeMediaUrls(...lists: Array<ReadonlyArray<string | undefined> | unde
return merged;
}
function createOutboundPayloadPlanEntry(payload: ReplyPayload): OutboundPayloadPlan | null {
type PreparedOutboundPayloadPlanEntry = {
payload: ReplyPayload;
hasInteractive: boolean;
hasChannelData: boolean;
isSilent: boolean;
};
function createOutboundPayloadPlanEntry(
payload: ReplyPayload,
): PreparedOutboundPayloadPlanEntry | null {
if (shouldSuppressReasoningPayload(payload)) {
return null;
}
@@ -104,9 +129,7 @@ function createOutboundPayloadPlanEntry(payload: ReplyPayload): OutboundPayloadP
if (isSuppressedRelayStatusText(parsedText) && mergedMedia.length === 0) {
return null;
}
if (parsed.isSilent && mergedMedia.length === 0) {
return null;
}
const isSilent = parsed.isSilent && mergedMedia.length === 0;
const hasMultipleMedia = (explicitMediaUrls?.length ?? 0) > 1;
const resolvedMediaUrl = hasMultipleMedia ? undefined : explicitMediaUrl;
const normalizedPayload: ReplyPayload = {
@@ -123,32 +146,100 @@ function createOutboundPayloadPlanEntry(payload: ReplyPayload): OutboundPayloadP
replyToCurrent: payload.replyToCurrent || parsed.replyToCurrent,
audioAsVoice: Boolean(payload.audioAsVoice || parsed.audioAsVoice),
};
if (!isRenderablePayload(normalizedPayload)) {
if (!isRenderablePayload(normalizedPayload) && !isSilent) {
return null;
}
const parts = resolveSendableOutboundReplyParts(normalizedPayload);
const hasChannelData = hasReplyChannelData(normalizedPayload.channelData);
return {
payload: normalizedPayload,
parts,
hasInteractive: hasInteractiveReplyBlocks(normalizedPayload.interactive),
hasChannelData,
isSilent,
};
}
export function createOutboundPayloadPlan(
payloads: readonly ReplyPayload[],
context: OutboundPayloadPlanContext = {},
): OutboundPayloadPlan[] {
// Intentionally scoped to channel-agnostic normalization and projection inputs.
// Transport concerns (queueing, hooks, retries), channel transforms, and
// heartbeat-specific token semantics remain outside this plan boundary.
const plan: OutboundPayloadPlan[] = [];
const resolvedSilentReplyPolicy = resolveSilentReplyPolicy({
cfg: context.cfg,
sessionKey: context.sessionKey,
surface: context.surface,
conversationType: context.conversationType,
});
const resolvedSilentReplyRewrite = resolveSilentReplyRewriteEnabled({
cfg: context.cfg,
sessionKey: context.sessionKey,
surface: context.surface,
conversationType: context.conversationType,
});
const prepared: PreparedOutboundPayloadPlanEntry[] = [];
for (const payload of payloads) {
const entry = createOutboundPayloadPlanEntry(payload);
if (!entry) {
continue;
}
plan.push(entry);
prepared.push(entry);
}
const hasVisibleNonSilentContent = prepared.some((entry) => {
if (entry.isSilent) {
return false;
}
const parts = resolveSendableOutboundReplyParts(entry.payload);
return hasReplyPayloadContent(
{ ...entry.payload, text: parts.text, mediaUrls: parts.mediaUrls },
{ hasChannelData: entry.hasChannelData },
);
});
const plan: OutboundPayloadPlan[] = [];
for (const entry of prepared) {
if (!entry.isSilent) {
plan.push({
payload: entry.payload,
parts: resolveSendableOutboundReplyParts(entry.payload),
hasInteractive: entry.hasInteractive,
hasChannelData: entry.hasChannelData,
});
continue;
}
if (hasVisibleNonSilentContent || resolvedSilentReplyPolicy === "allow") {
continue;
}
if (!resolvedSilentReplyRewrite) {
const visibleSilentPayload: ReplyPayload = {
...entry.payload,
text: entry.payload.text?.trim() || "NO_REPLY",
};
if (!isRenderablePayload(visibleSilentPayload)) {
continue;
}
plan.push({
payload: visibleSilentPayload,
parts: resolveSendableOutboundReplyParts(visibleSilentPayload),
hasInteractive: entry.hasInteractive,
hasChannelData: entry.hasChannelData,
});
continue;
}
const rewrittenPayload: ReplyPayload = {
...entry.payload,
text: resolveSilentReplyRewriteText({
seed: `${context.sessionKey ?? context.surface ?? "silent-reply"}:${entry.payload.text ?? ""}`,
}),
};
if (!isRenderablePayload(rewrittenPayload)) {
continue;
}
plan.push({
payload: rewrittenPayload,
parts: resolveSendableOutboundReplyParts(rewrittenPayload),
hasInteractive: entry.hasInteractive,
hasChannelData: entry.hasChannelData,
});
}
return plan;
}

View File

@@ -5,6 +5,8 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js";
export type OutboundSessionContext = {
/** Canonical session key used for internal hook dispatch. */
key?: string;
/** Session key used for policy resolution when delivery differs from the control session. */
policyKey?: string;
/** Active agent id used for workspace-scoped media roots. */
agentId?: string;
/** Originating account id used for requester-scoped group policy resolution. */
@@ -22,6 +24,7 @@ export type OutboundSessionContext = {
export function buildOutboundSessionContext(params: {
cfg: OpenClawConfig;
sessionKey?: string | null;
policySessionKey?: string | null;
agentId?: string | null;
requesterAccountId?: string | null;
requesterSenderId?: string | null;
@@ -30,6 +33,7 @@ export function buildOutboundSessionContext(params: {
requesterSenderE164?: string | null;
}): OutboundSessionContext | undefined {
const key = normalizeOptionalString(params.sessionKey);
const policyKey = normalizeOptionalString(params.policySessionKey);
const explicitAgentId = normalizeOptionalString(params.agentId);
const requesterAccountId = normalizeOptionalString(params.requesterAccountId);
const requesterSenderId = normalizeOptionalString(params.requesterSenderId);
@@ -42,6 +46,7 @@ export function buildOutboundSessionContext(params: {
const agentId = explicitAgentId ?? derivedAgentId;
if (
!key &&
!policyKey &&
!agentId &&
!requesterAccountId &&
!requesterSenderId &&
@@ -53,6 +58,7 @@ export function buildOutboundSessionContext(params: {
}
return {
...(key ? { key } : {}),
...(policyKey ? { policyKey } : {}),
...(agentId ? { agentId } : {}),
...(requesterAccountId ? { requesterAccountId } : {}),
...(requesterSenderId ? { requesterSenderId } : {}),

View File

@@ -2,3 +2,7 @@ export { createRuntimeOutboundDelegates } from "../channels/plugins/runtime-forw
export { resolveOutboundSendDep, type OutboundSendDeps } from "../infra/outbound/send-deps.js";
export { resolveAgentOutboundIdentity, type OutboundIdentity } from "../infra/outbound/identity.js";
export { sanitizeForPlainText } from "../infra/outbound/sanitize-text.js";
export {
createOutboundPayloadPlan,
projectOutboundPayloadPlanForDelivery,
} from "../infra/outbound/payloads.js";

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_SILENT_REPLY_POLICY,
DEFAULT_SILENT_REPLY_REWRITE,
classifySilentReplyConversationType,
resolveSilentReplyPolicyFromPolicies,
resolveSilentReplyRewriteFromPolicies,
resolveSilentReplyRewriteText,
} from "./silent-reply-policy.js";
describe("classifySilentReplyConversationType", () => {
it("prefers an explicit conversation type", () => {
expect(
classifySilentReplyConversationType({
sessionKey: "agent:main:group:123",
conversationType: "internal",
}),
).toBe("internal");
});
it("classifies direct and group session keys", () => {
expect(
classifySilentReplyConversationType({
sessionKey: "agent:main:telegram:direct:123",
}),
).toBe("direct");
expect(
classifySilentReplyConversationType({
sessionKey: "agent:main:discord:group:123",
}),
).toBe("group");
});
it("treats webchat as direct by default and unknown surfaces as internal", () => {
expect(classifySilentReplyConversationType({ surface: "webchat" })).toBe("direct");
expect(classifySilentReplyConversationType({ surface: "subagent" })).toBe("internal");
});
});
describe("resolveSilentReplyPolicyFromPolicies", () => {
it("uses defaults when no overrides exist", () => {
expect(resolveSilentReplyPolicyFromPolicies({ conversationType: "direct" })).toBe(
DEFAULT_SILENT_REPLY_POLICY.direct,
);
expect(resolveSilentReplyPolicyFromPolicies({ conversationType: "group" })).toBe(
DEFAULT_SILENT_REPLY_POLICY.group,
);
});
it("prefers surface policy over defaults", () => {
expect(
resolveSilentReplyPolicyFromPolicies({
conversationType: "direct",
defaultPolicy: { direct: "disallow" },
surfacePolicy: { direct: "allow" },
}),
).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);
});
});

View File

@@ -0,0 +1,111 @@
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.js";
export type SilentReplyPolicy = "allow" | "disallow";
export type SilentReplyConversationType = "direct" | "group" | "internal";
export type SilentReplyPolicyShape = Partial<
Record<SilentReplyConversationType, SilentReplyPolicy>
>;
export type SilentReplyRewriteShape = Partial<Record<SilentReplyConversationType, boolean>>;
export const DEFAULT_SILENT_REPLY_POLICY: Record<SilentReplyConversationType, SilentReplyPolicy> = {
direct: "disallow",
group: "allow",
internal: "allow",
};
export const DEFAULT_SILENT_REPLY_REWRITE: Record<SilentReplyConversationType, boolean> = {
direct: true,
group: false,
internal: false,
};
const SILENT_REPLY_REWRITE_TEXTS = [
"Nothing to add right now.",
"All quiet on my side.",
"No extra notes from me.",
"Standing by.",
"No update from me on this one.",
"Nothing further to report.",
"I have nothing else to add.",
"No follow-up needed from me.",
"No additional reply from me here.",
"No extra comment on my end.",
"No further note from me.",
"That is all from me for now.",
"No added response from me.",
"Nothing else to say here.",
"No extra message needed from me.",
"No additional note on this one.",
"No further response from me.",
"Nothing new to add from my side.",
"No extra update from me.",
"I have no further reply here.",
"Nothing additional from me.",
"No added note from my side.",
"No more to report from me.",
"No extra reply needed here.",
"No further word from me.",
"Nothing further on my end.",
"No extra answer from me.",
"No additional response from my side.",
] as const;
function hashSeed(seed: string): number {
let hash = 0;
for (let index = 0; index < seed.length; index += 1) {
hash = (hash * 31 + seed.charCodeAt(index)) >>> 0;
}
return hash;
}
export function classifySilentReplyConversationType(params: {
sessionKey?: string;
surface?: string;
conversationType?: SilentReplyConversationType;
}): SilentReplyConversationType {
if (params.conversationType) {
return params.conversationType;
}
const normalizedSessionKey = normalizeLowercaseStringOrEmpty(params.sessionKey);
if (normalizedSessionKey.includes(":group:") || normalizedSessionKey.includes(":channel:")) {
return "group";
}
if (normalizedSessionKey.includes(":direct:") || normalizedSessionKey.includes(":dm:")) {
return "direct";
}
const normalizedSurface = normalizeLowercaseStringOrEmpty(params.surface);
if (normalizedSurface === "webchat") {
return "direct";
}
return "internal";
}
export function resolveSilentReplyPolicyFromPolicies(params: {
conversationType: SilentReplyConversationType;
defaultPolicy?: SilentReplyPolicyShape;
surfacePolicy?: SilentReplyPolicyShape;
}): SilentReplyPolicy {
return (
params.surfacePolicy?.[params.conversationType] ??
params.defaultPolicy?.[params.conversationType] ??
DEFAULT_SILENT_REPLY_POLICY[params.conversationType]
);
}
export function resolveSilentReplyRewriteFromPolicies(params: {
conversationType: SilentReplyConversationType;
defaultRewrite?: SilentReplyRewriteShape;
surfaceRewrite?: SilentReplyRewriteShape;
}): boolean {
return (
params.surfaceRewrite?.[params.conversationType] ??
params.defaultRewrite?.[params.conversationType] ??
DEFAULT_SILENT_REPLY_REWRITE[params.conversationType]
);
}
export function resolveSilentReplyRewriteText(params: { seed?: string }): string {
const seed = params.seed?.trim() || "silent-reply";
const index = hashSeed(seed) % SILENT_REPLY_REWRITE_TEXTS.length;
return SILENT_REPLY_REWRITE_TEXTS[index] ?? SILENT_REPLY_REWRITE_TEXTS[0];
}