Skip to content

Commit ffbde4c

Browse files
Andrew Meyervincentkoc
authored andcommitted
fix(discord): avoid duplicate typing keepalive for tool replies
1 parent 4ae0a5d commit ffbde4c

9 files changed

Lines changed: 122 additions & 2 deletions

extensions/discord/src/monitor/message-handler.process.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ type DispatchInboundParams = {
175175
}) => Promise<void> | void;
176176
onReplyStart?: () => Promise<void> | void;
177177
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
178+
typingKeepalive?: boolean;
178179
disableBlockStreaming?: boolean;
179180
suppressDefaultToolProgressMessages?: boolean;
180181
queuedDeliveryCorrelations?: Array<{ begin: () => () => void }>;
@@ -944,6 +945,7 @@ describe("processDiscordMessage ack reactions", () => {
944945
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
945946
expect(replyTypingFeedback.onIdle).toHaveBeenCalledTimes(1);
946947
expect(replyTypingFeedback.onCleanup).toHaveBeenCalledTimes(1);
948+
expect(getLastDispatchReplyOptions()?.typingKeepalive).toBe(false);
947949
expect(typingMocks.sendTyping).not.toHaveBeenCalled();
948950
});
949951

@@ -984,6 +986,33 @@ describe("processDiscordMessage ack reactions", () => {
984986
}
985987
});
986988

989+
it("keeps one typing refresh loop for default message-tool replies", async () => {
990+
vi.useFakeTimers();
991+
try {
992+
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
993+
await params?.replyOptions?.onReplyStart?.();
994+
await vi.advanceTimersByTimeAsync(3_500);
995+
return createNoQueuedDispatchResult();
996+
});
997+
const ctx = await createBaseContext({
998+
shouldRequireMention: false,
999+
effectiveWasMentioned: false,
1000+
cfg: {
1001+
messages: { groupChat: { visibleReplies: "message_tool" } },
1002+
session: { store: "/tmp/openclaw-discord-process-test-sessions.json" },
1003+
},
1004+
route: BASE_CHANNEL_ROUTE,
1005+
});
1006+
1007+
await runProcessDiscordMessage(ctx);
1008+
1009+
expect(getLastDispatchReplyOptions()?.typingKeepalive).toBe(false);
1010+
expect(typingMocks.sendTyping).toHaveBeenCalledTimes(2);
1011+
} finally {
1012+
vi.useRealTimers();
1013+
}
1014+
});
1015+
9871016
it("debounces intermediate phase reactions and jumps to done for short runs", async () => {
9881017
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
9891018
await params?.replyOptions?.onReasoningStream?.();
@@ -1532,6 +1561,7 @@ describe("processDiscordMessage session routing", () => {
15321561

15331562
expectRecordFields(requireRecord(getLastDispatchReplyOptions(), "dispatch reply options"), {
15341563
sourceReplyDeliveryMode: "message_tool_only",
1564+
typingKeepalive: false,
15351565
disableBlockStreaming: true,
15361566
});
15371567
expect(createDiscordDraftStream).not.toHaveBeenCalled();

extensions/discord/src/monitor/message-handler.process.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,14 @@ async function processDiscordMessageInner(
251251
},
252252
});
253253
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
254+
const configuredTypingMode = cfg.session?.typingMode ?? cfg.agents?.defaults?.typingMode;
255+
const configuredTypingInterval =
256+
cfg.agents?.defaults?.typingIntervalSeconds ?? cfg.session?.typingIntervalSeconds;
257+
const shouldDisableCoreTypingKeepalive =
258+
Boolean(replyTypingFeedback) ||
259+
(sourceRepliesAreToolOnly &&
260+
configuredTypingMode === undefined &&
261+
configuredTypingInterval === undefined);
254262
const ackReaction = resolveAckReaction(cfg, route.agentId, {
255263
channel: "discord",
256264
accountId,
@@ -460,6 +468,7 @@ async function processDiscordMessageInner(
460468
channelId: typingChannelId,
461469
rest: feedbackRest,
462470
log: logVerbose,
471+
keepaliveIntervalMs: shouldDisableCoreTypingKeepalive ? undefined : 0,
463472
});
464473
if (replyTypingFeedback) {
465474
// A carried prestart only covers queue wait time; dispatch needs a fresh
@@ -955,6 +964,7 @@ async function processDiscordMessageInner(
955964
abortSignal,
956965
skillFilter: channelConfig?.skills,
957966
sourceReplyDeliveryMode,
967+
typingKeepalive: shouldDisableCoreTypingKeepalive ? false : undefined,
958968
queuedDeliveryCorrelations: isRoomEvent ? [{ begin: beginDeliveryCorrelation }] : undefined,
959969
suppressTyping: isRoomEvent ? true : undefined,
960970
allowProgressCallbacksWhenSourceDeliverySuppressed:

extensions/discord/src/monitor/message-handler.queue.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,34 @@ describe("createDiscordMessageHandler queue behavior", () => {
222222
);
223223
});
224224

225+
it("keeps the configured typing cadence for prestarted feedback", async () => {
226+
preflightDiscordMessageMock.mockReset();
227+
processDiscordMessageMock.mockReset();
228+
preflightDiscordMessageMock.mockImplementation(async () =>
229+
createAcceptedDmPreflightContext({
230+
cfg: {
231+
...createPreflightContext().cfg,
232+
agents: { defaults: { typingIntervalSeconds: 7 } },
233+
session: { typingIntervalSeconds: 5 },
234+
},
235+
}),
236+
);
237+
processDiscordMessageMock.mockResolvedValue(undefined);
238+
const replyTypingFeedback = createReplyTypingFeedbackMock("dm-1");
239+
const createReplyTypingFeedback = vi.fn(() => replyTypingFeedback);
240+
241+
const handler = createDiscordMessageHandler({
242+
...createDiscordHandlerParams(),
243+
testing: { createReplyTypingFeedback },
244+
});
245+
await handler(createMessageData("m-typing-cadence", "dm-1") as never, {} as never);
246+
await flushQueueWork();
247+
248+
expect(createReplyTypingFeedback).toHaveBeenCalledWith(
249+
expect.objectContaining({ keepaliveIntervalMs: 7_000 }),
250+
);
251+
});
252+
225253
it("keeps accepted DM dispatch running when accepted typing feedback fails", async () => {
226254
preflightDiscordMessageMock.mockReset();
227255
processDiscordMessageMock.mockReset();

extensions/discord/src/monitor/message-handler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
createChannelInboundDebouncer,
44
shouldDebounceTextInbound,
55
} from "openclaw/plugin-sdk/channel-inbound";
6+
import { finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime";
67
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
78
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
89
import type { Client } from "../internal/discord.js";
@@ -102,6 +103,9 @@ function startAcceptedTypingFeedback(params: {
102103
accountId: ctx.accountId,
103104
channelId: ctx.messageChannelId,
104105
log: logVerbose,
106+
keepaliveIntervalMs: finiteSecondsToTimerSafeMilliseconds(
107+
ctx.cfg.agents?.defaults?.typingIntervalSeconds ?? ctx.cfg.session?.typingIntervalSeconds,
108+
),
105109
});
106110
const cleanup = replyTypingFeedback.onCleanup;
107111
replyTypingFeedback.onCleanup = () => {

extensions/discord/src/monitor/reply-typing-feedback.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function createDiscordReplyTypingFeedback(params: {
2424
rest?: RequestClient;
2525
log: (message: string) => void;
2626
maxDurationMs?: number;
27+
keepaliveIntervalMs?: number;
2728
}): DiscordReplyTypingFeedback {
2829
let channelId = params.channelId;
2930
const rest =
@@ -44,6 +45,7 @@ export function createDiscordReplyTypingFeedback(params: {
4445
error: err,
4546
});
4647
},
48+
keepaliveIntervalMs: params.keepaliveIntervalMs,
4749
maxDurationMs: params.maxDurationMs ?? DISCORD_REPLY_TYPING_MAX_DURATION_MS,
4850
});
4951
const updateChannelId = (nextChannelId: string) => {

src/auto-reply/get-reply-options.types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
12
/** Public option types for reply generation callbacks, streaming, and delivery policy. */
23
import type { ImageContent } from "../llm/types.js";
34
import type { PromptImageOrderEntry } from "../media/prompt-image-order.js";
45
import type { UserTurnTranscriptRecorder } from "../sessions/user-turn-transcript.types.js";
5-
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
66
import type { ReplyPayload } from "./reply-payload.js";
77
import type { TypingController } from "./reply/typing.js";
88

@@ -73,6 +73,8 @@ export type GetReplyOptions = {
7373
/** Called when the typing controller cleans up (e.g., run ended with NO_REPLY). */
7474
onTypingCleanup?: () => void;
7575
onTypingController?: (typing: TypingController) => void;
76+
/** If false, send only the initial typing signal without periodic keepalive refreshes. */
77+
typingKeepalive?: boolean;
7678
isHeartbeat?: boolean;
7779
/** Policy-level typing control for run classes (user/system/internal/heartbeat). */
7880
typingPolicy?: TypingPolicy;

src/auto-reply/reply/get-reply.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ export async function getReplyFromConfig(
389389
onReplyStart: opts?.onReplyStart,
390390
onCleanup: opts?.onTypingCleanup,
391391
typingIntervalSeconds,
392+
keepalive: opts?.typingKeepalive ?? true,
392393
silentToken: SILENT_REPLY_TOKEN,
393394
log: defaultRuntime.log,
394395
});

src/auto-reply/reply/reply-utils.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,27 @@ describe("typing controller", () => {
483483
await vi.advanceTimersByTimeAsync(5_000);
484484
expect(onReplyStart).toHaveBeenCalledTimes(1);
485485
});
486+
487+
it("can send the first typing signal without periodic keepalive refreshes", async () => {
488+
vi.useFakeTimers();
489+
const onReplyStart = vi.fn();
490+
const typing = createTypingController({
491+
onReplyStart,
492+
typingIntervalSeconds: 1,
493+
typingTtlMs: 30_000,
494+
keepalive: false,
495+
});
496+
497+
await typing.startTypingLoop();
498+
expect(onReplyStart).toHaveBeenCalledTimes(1);
499+
500+
await vi.advanceTimersByTimeAsync(5_000);
501+
expect(onReplyStart).toHaveBeenCalledTimes(1);
502+
503+
await typing.startTypingLoop();
504+
await vi.advanceTimersByTimeAsync(5_000);
505+
expect(onReplyStart).toHaveBeenCalledTimes(1);
506+
});
486507
});
487508

488509
describe("resolveTypingMode", () => {
@@ -530,6 +551,17 @@ describe("resolveTypingMode", () => {
530551
},
531552
expected: "message",
532553
},
554+
{
555+
name: "configured instant typing mode wins over message-tool-only default",
556+
input: {
557+
configured: "instant" as const,
558+
isGroupChat: true,
559+
wasMentioned: false,
560+
isHeartbeat: false,
561+
sourceReplyDeliveryMode: "message_tool_only" as const,
562+
},
563+
expected: "instant",
564+
},
533565
{
534566
name: "default mentioned group chat",
535567
input: {

src/auto-reply/reply/typing.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,17 @@ export function createTypingController(params: {
3939
onCleanup?: () => void;
4040
typingIntervalSeconds?: number;
4141
typingTtlMs?: number;
42+
keepalive?: boolean;
4243
silentToken?: string;
4344
log?: (message: string) => void;
4445
}): TypingController {
45-
const { onReplyStart, onCleanup, silentToken = SILENT_REPLY_TOKEN, log } = params;
46+
const {
47+
onReplyStart,
48+
onCleanup,
49+
keepalive = true,
50+
silentToken = SILENT_REPLY_TOKEN,
51+
log,
52+
} = params;
4653
if (!onReplyStart && !onCleanup) {
4754
return {
4855
onReplyStart: async () => {},
@@ -202,6 +209,10 @@ export function createTypingController(params: {
202209
if (!onReplyStart) {
203210
return;
204211
}
212+
if (!keepalive) {
213+
await ensureStart();
214+
return;
215+
}
205216
if (typingLoop.isRunning()) {
206217
return;
207218
}

0 commit comments

Comments
 (0)