mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 04:21:38 +00:00
refactor: unify typing dispatch lifecycle and policy boundaries
This commit is contained in:
@@ -22,6 +22,7 @@ import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js";
|
||||
import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js";
|
||||
import { shouldSuppressReasoningPayload } from "./reply-payloads.js";
|
||||
import { isRoutableChannel, routeReply } from "./route-reply.js";
|
||||
import { resolveRunTypingPolicy } from "./typing-policy.js";
|
||||
|
||||
const AUDIO_PLACEHOLDER_RE = /^<media:audio>(\s*\([^)]*\))?$/i;
|
||||
const AUDIO_HEADER_RE = /^\[Audio\b/i;
|
||||
@@ -395,19 +396,19 @@ export async function dispatchReplyFromConfig(params: {
|
||||
}
|
||||
return { ...payload, text: undefined };
|
||||
};
|
||||
const typing = resolveRunTypingPolicy({
|
||||
requestedPolicy: params.replyOptions?.typingPolicy,
|
||||
suppressTyping: params.replyOptions?.suppressTyping === true || shouldSuppressTyping,
|
||||
originatingChannel,
|
||||
systemEvent: shouldRouteToOriginating,
|
||||
});
|
||||
|
||||
const replyResult = await (params.replyResolver ?? getReplyFromConfig)(
|
||||
ctx,
|
||||
{
|
||||
...params.replyOptions,
|
||||
typingPolicy:
|
||||
params.replyOptions?.typingPolicy ??
|
||||
(originatingChannel === INTERNAL_MESSAGE_CHANNEL
|
||||
? "internal_webchat"
|
||||
: shouldRouteToOriginating
|
||||
? "system_event"
|
||||
: undefined),
|
||||
suppressTyping: params.replyOptions?.suppressTyping === true || shouldSuppressTyping,
|
||||
typingPolicy: typing.typingPolicy,
|
||||
suppressTyping: typing.suppressTyping,
|
||||
onToolResult: (payload: ReplyPayload) => {
|
||||
const run = async () => {
|
||||
const ttsPayload = await maybeApplyTtsToPayload({
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||
import { hasControlCommand } from "../command-detection.js";
|
||||
import { buildInboundMediaNote } from "../media-note.js";
|
||||
@@ -47,6 +46,7 @@ import { routeReply } from "./route-reply.js";
|
||||
import { BARE_SESSION_RESET_PROMPT } from "./session-reset-prompt.js";
|
||||
import { ensureSkillSnapshot, prependSystemEvents } from "./session-updates.js";
|
||||
import { resolveTypingMode } from "./typing-mode.js";
|
||||
import { resolveRunTypingPolicy } from "./typing-policy.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
import { appendUntrustedContext } from "./untrusted-context.js";
|
||||
|
||||
@@ -234,14 +234,12 @@ export async function runPreparedReply(
|
||||
const isGroupChat = sessionCtx.ChatType === "group";
|
||||
const wasMentioned = ctx.WasMentioned === true;
|
||||
const isHeartbeat = opts?.isHeartbeat === true;
|
||||
const typingPolicy =
|
||||
opts?.typingPolicy ??
|
||||
(isHeartbeat
|
||||
? "heartbeat"
|
||||
: ctx.OriginatingChannel === INTERNAL_MESSAGE_CHANNEL
|
||||
? "internal_webchat"
|
||||
: "auto");
|
||||
const suppressTyping = opts?.suppressTyping === true;
|
||||
const { typingPolicy, suppressTyping } = resolveRunTypingPolicy({
|
||||
requestedPolicy: opts?.typingPolicy,
|
||||
suppressTyping: opts?.suppressTyping === true,
|
||||
isHeartbeat,
|
||||
originatingChannel: ctx.OriginatingChannel,
|
||||
});
|
||||
const typingMode = resolveTypingMode({
|
||||
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
|
||||
isGroupChat,
|
||||
|
||||
61
src/auto-reply/reply/typing-policy.test.ts
Normal file
61
src/auto-reply/reply/typing-policy.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveRunTypingPolicy } from "./typing-policy.js";
|
||||
|
||||
describe("resolveRunTypingPolicy", () => {
|
||||
it("forces heartbeat policy for heartbeat runs", () => {
|
||||
const resolved = resolveRunTypingPolicy({
|
||||
requestedPolicy: "user_message",
|
||||
isHeartbeat: true,
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
typingPolicy: "heartbeat",
|
||||
suppressTyping: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("forces internal webchat policy", () => {
|
||||
const resolved = resolveRunTypingPolicy({
|
||||
requestedPolicy: "user_message",
|
||||
originatingChannel: "webchat",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
typingPolicy: "internal_webchat",
|
||||
suppressTyping: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("forces system event policy for routed turns", () => {
|
||||
const resolved = resolveRunTypingPolicy({
|
||||
requestedPolicy: "user_message",
|
||||
systemEvent: true,
|
||||
originatingChannel: "telegram",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
typingPolicy: "system_event",
|
||||
suppressTyping: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves requested policy for regular user turns", () => {
|
||||
const resolved = resolveRunTypingPolicy({
|
||||
requestedPolicy: "user_message",
|
||||
originatingChannel: "telegram",
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
typingPolicy: "user_message",
|
||||
suppressTyping: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects explicit suppressTyping", () => {
|
||||
const resolved = resolveRunTypingPolicy({
|
||||
requestedPolicy: "auto",
|
||||
originatingChannel: "telegram",
|
||||
suppressTyping: true,
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
typingPolicy: "auto",
|
||||
suppressTyping: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
35
src/auto-reply/reply/typing-policy.ts
Normal file
35
src/auto-reply/reply/typing-policy.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js";
|
||||
import type { TypingPolicy } from "../types.js";
|
||||
|
||||
export type ResolveRunTypingPolicyParams = {
|
||||
requestedPolicy?: TypingPolicy;
|
||||
suppressTyping?: boolean;
|
||||
isHeartbeat?: boolean;
|
||||
originatingChannel?: string;
|
||||
systemEvent?: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedRunTypingPolicy = {
|
||||
typingPolicy: TypingPolicy;
|
||||
suppressTyping: boolean;
|
||||
};
|
||||
|
||||
export function resolveRunTypingPolicy(
|
||||
params: ResolveRunTypingPolicyParams,
|
||||
): ResolvedRunTypingPolicy {
|
||||
const typingPolicy = params.isHeartbeat
|
||||
? "heartbeat"
|
||||
: params.originatingChannel === INTERNAL_MESSAGE_CHANNEL
|
||||
? "internal_webchat"
|
||||
: params.systemEvent
|
||||
? "system_event"
|
||||
: (params.requestedPolicy ?? "auto");
|
||||
|
||||
const suppressTyping =
|
||||
params.suppressTyping === true ||
|
||||
typingPolicy === "heartbeat" ||
|
||||
typingPolicy === "system_event" ||
|
||||
typingPolicy === "internal_webchat";
|
||||
|
||||
return { typingPolicy, suppressTyping };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createTypingKeepaliveLoop } from "../../channels/typing-lifecycle.js";
|
||||
import { createTypingStartGuard } from "../../channels/typing-start-guard.js";
|
||||
import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
|
||||
export type TypingController = {
|
||||
@@ -99,15 +100,16 @@ export function createTypingController(params: {
|
||||
|
||||
const isActive = () => active && !sealed;
|
||||
|
||||
const startGuard = createTypingStartGuard({
|
||||
isSealed: () => sealed,
|
||||
shouldBlock: () => runComplete,
|
||||
rethrowOnError: true,
|
||||
});
|
||||
|
||||
const triggerTyping = async () => {
|
||||
if (sealed) {
|
||||
return;
|
||||
}
|
||||
// Late callbacks after a run completed should never restart typing.
|
||||
if (runComplete) {
|
||||
return;
|
||||
}
|
||||
await onReplyStart?.();
|
||||
await startGuard.run(async () => {
|
||||
await onReplyStart?.();
|
||||
});
|
||||
};
|
||||
|
||||
const typingLoop = createTypingKeepaliveLoop({
|
||||
|
||||
Reference in New Issue
Block a user