mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 06:11:37 +00:00
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
|
||||
// Keep channelData-only payloads so channel-specific replies survive normalization.
|
||||
@@ -19,4 +20,30 @@ describe("normalizeReplyPayload", () => {
|
||||
expect(normalized?.text).toBeUndefined();
|
||||
expect(normalized?.channelData).toEqual(payload.channelData);
|
||||
});
|
||||
|
||||
it("records silent skips", () => {
|
||||
const reasons: string[] = [];
|
||||
const normalized = normalizeReplyPayload(
|
||||
{ text: SILENT_REPLY_TOKEN },
|
||||
{
|
||||
onSkip: (reason) => reasons.push(reason),
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized).toBeNull();
|
||||
expect(reasons).toEqual(["silent"]);
|
||||
});
|
||||
|
||||
it("records empty skips", () => {
|
||||
const reasons: string[] = [];
|
||||
const normalized = normalizeReplyPayload(
|
||||
{ text: " " },
|
||||
{
|
||||
onSkip: (reason) => reasons.push(reason),
|
||||
},
|
||||
);
|
||||
|
||||
expect(normalized).toBeNull();
|
||||
expect(reasons).toEqual(["empty"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "./response-prefix-template.js";
|
||||
import { hasLineDirectives, parseLineDirectives } from "./line-directives.js";
|
||||
|
||||
export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
responsePrefix?: string;
|
||||
/** Context for template variable interpolation in responsePrefix */
|
||||
@@ -15,6 +17,7 @@ export type NormalizeReplyOptions = {
|
||||
onHeartbeatStrip?: () => void;
|
||||
stripHeartbeat?: boolean;
|
||||
silentToken?: string;
|
||||
onSkip?: (reason: NormalizeReplySkipReason) => void;
|
||||
};
|
||||
|
||||
export function normalizeReplyPayload(
|
||||
@@ -26,12 +29,18 @@ export function normalizeReplyPayload(
|
||||
payload.channelData && Object.keys(payload.channelData).length > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia && !hasChannelData) return null;
|
||||
if (!trimmed && !hasMedia && !hasChannelData) {
|
||||
opts.onSkip?.("empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && isSilentReplyText(text, silentToken)) {
|
||||
if (!hasMedia && !hasChannelData) return null;
|
||||
if (!hasMedia && !hasChannelData) {
|
||||
opts.onSkip?.("silent");
|
||||
return null;
|
||||
}
|
||||
text = "";
|
||||
}
|
||||
if (text && !trimmed) {
|
||||
@@ -43,14 +52,20 @@ export function normalizeReplyPayload(
|
||||
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.shouldSkip && !hasMedia && !hasChannelData) return null;
|
||||
if (stripped.shouldSkip && !hasMedia && !hasChannelData) {
|
||||
opts.onSkip?.("heartbeat");
|
||||
return null;
|
||||
}
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
if (text) {
|
||||
text = sanitizeUserFacingText(text);
|
||||
}
|
||||
if (!text?.trim() && !hasMedia && !hasChannelData) return null;
|
||||
if (!text?.trim() && !hasMedia && !hasChannelData) {
|
||||
opts.onSkip?.("empty");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse LINE-specific directives from text (quick_replies, location, confirm, buttons)
|
||||
let enrichedPayload: ReplyPayload = { ...payload, text };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HumanDelayConfig } from "../../config/types.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js";
|
||||
import type { ResponsePrefixContext } from "./response-prefix-template.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
@@ -8,6 +8,11 @@ export type ReplyDispatchKind = "tool" | "block" | "final";
|
||||
|
||||
type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void;
|
||||
|
||||
type ReplyDispatchSkipHandler = (
|
||||
payload: ReplyPayload,
|
||||
info: { kind: ReplyDispatchKind; reason: NormalizeReplySkipReason },
|
||||
) => void;
|
||||
|
||||
type ReplyDispatchDeliverer = (
|
||||
payload: ReplyPayload,
|
||||
info: { kind: ReplyDispatchKind },
|
||||
@@ -42,6 +47,8 @@ export type ReplyDispatcherOptions = {
|
||||
onHeartbeatStrip?: () => void;
|
||||
onIdle?: () => void;
|
||||
onError?: ReplyDispatchErrorHandler;
|
||||
// AIDEV-NOTE: onSkip lets channels detect silent/empty drops (e.g. Telegram empty-response fallback).
|
||||
onSkip?: ReplyDispatchSkipHandler;
|
||||
/** Human-like delay between block replies for natural rhythm. */
|
||||
humanDelay?: HumanDelayConfig;
|
||||
};
|
||||
@@ -65,15 +72,16 @@ export type ReplyDispatcher = {
|
||||
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
|
||||
};
|
||||
|
||||
type NormalizeReplyPayloadInternalOptions = Pick<
|
||||
ReplyDispatcherOptions,
|
||||
"responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip"
|
||||
> & {
|
||||
onSkip?: (reason: NormalizeReplySkipReason) => void;
|
||||
};
|
||||
|
||||
function normalizeReplyPayloadInternal(
|
||||
payload: ReplyPayload,
|
||||
opts: Pick<
|
||||
ReplyDispatcherOptions,
|
||||
| "responsePrefix"
|
||||
| "responsePrefixContext"
|
||||
| "responsePrefixContextProvider"
|
||||
| "onHeartbeatStrip"
|
||||
>,
|
||||
opts: NormalizeReplyPayloadInternalOptions,
|
||||
): ReplyPayload | null {
|
||||
// Prefer dynamic context provider over static context
|
||||
const prefixContext = opts.responsePrefixContextProvider?.() ?? opts.responsePrefixContext;
|
||||
@@ -82,6 +90,7 @@ function normalizeReplyPayloadInternal(
|
||||
responsePrefix: opts.responsePrefix,
|
||||
responsePrefixContext: prefixContext,
|
||||
onHeartbeatStrip: opts.onHeartbeatStrip,
|
||||
onSkip: opts.onSkip,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,7 +108,13 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
|
||||
};
|
||||
|
||||
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
||||
const normalized = normalizeReplyPayloadInternal(payload, options);
|
||||
const normalized = normalizeReplyPayloadInternal(payload, {
|
||||
responsePrefix: options.responsePrefix,
|
||||
responsePrefixContext: options.responsePrefixContext,
|
||||
responsePrefixContextProvider: options.responsePrefixContextProvider,
|
||||
onHeartbeatStrip: options.onHeartbeatStrip,
|
||||
onSkip: (reason) => options.onSkip?.(payload, { kind, reason }),
|
||||
});
|
||||
if (!normalized) return false;
|
||||
queuedCounts[kind] += 1;
|
||||
pending += 1;
|
||||
|
||||
Reference in New Issue
Block a user