Skip to content

Commit 3439ab1

Browse files
author
Andrew Meyer
committed
fix(discord): avoid duplicate typing keepalive for tool replies
1 parent 3d96111 commit 3439ab1

6 files changed

Lines changed: 102 additions & 1 deletion

File tree

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ const sendMocks = vi.hoisted(() => ({
1616
(channelId: string, messageId: string, emoji: string, opts?: unknown) => Promise<void>
1717
>(async () => {}),
1818
}));
19+
const typingMocks = vi.hoisted(() => ({
20+
sendTyping: vi.fn<(params: { rest: unknown; channelId: string }) => Promise<void>>(
21+
async () => {},
22+
),
23+
}));
1924
function createMockDraftStream() {
2025
let messageId: string | undefined = "preview-1";
2126
return {
@@ -71,6 +76,10 @@ vi.mock("../send.js", () => ({
7176
},
7277
}));
7378

79+
vi.mock("./typing.js", () => ({
80+
sendTyping: (params: { rest: unknown; channelId: string }) => typingMocks.sendTyping(params),
81+
}));
82+
7483
const discordTargetMocks = vi.hoisted(() => ({
7584
resolveDiscordTargetChannelId: vi.fn(async (target: string, _opts?: unknown) => ({
7685
channelId: target === "user:u1" ? "dm-u1" : target,
@@ -139,6 +148,7 @@ type DispatchInboundParams = {
139148
}) => Promise<void> | void;
140149
onReplyStart?: () => Promise<void> | void;
141150
sourceReplyDeliveryMode?: "automatic" | "message_tool_only";
151+
typingKeepalive?: boolean;
142152
disableBlockStreaming?: boolean;
143153
suppressDefaultToolProgressMessages?: boolean;
144154
queuedDeliveryCorrelations?: Array<{ begin: () => () => void }>;
@@ -380,6 +390,7 @@ beforeEach(() => {
380390
vi.useRealTimers();
381391
sendMocks.reactMessageDiscord.mockClear();
382392
sendMocks.removeReactionDiscord.mockClear();
393+
typingMocks.sendTyping.mockClear();
383394
discordTargetMocks.resolveDiscordTargetChannelId.mockClear();
384395
editMessageDiscord.mockClear();
385396
deliverDiscordReply.mockClear();
@@ -650,6 +661,27 @@ function expectSinglePreviewEdit() {
650661
expect(deliverDiscordReply).not.toHaveBeenCalled();
651662
}
652663

664+
describe("processDiscordMessage typing", () => {
665+
it("does not start a nested Discord typing keepalive from channel callbacks", async () => {
666+
vi.useFakeTimers();
667+
try {
668+
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
669+
await params?.replyOptions?.onReplyStart?.();
670+
await vi.advanceTimersByTimeAsync(3_500);
671+
return createNoQueuedDispatchResult();
672+
});
673+
674+
const ctx = await createAutomaticSourceDeliveryContext();
675+
676+
await runProcessDiscordMessage(ctx);
677+
678+
expect(typingMocks.sendTyping).toHaveBeenCalledTimes(1);
679+
} finally {
680+
vi.useRealTimers();
681+
}
682+
});
683+
});
684+
653685
describe("processDiscordMessage ack reactions", () => {
654686
it("drops bot-loop-suppressed messages before Discord side effects", async () => {
655687
const botLoopProtection: ChannelBotLoopProtectionFacts = {
@@ -1275,11 +1307,34 @@ describe("processDiscordMessage session routing", () => {
12751307

12761308
expectRecordFields(requireRecord(getLastDispatchReplyOptions(), "dispatch reply options"), {
12771309
sourceReplyDeliveryMode: "message_tool_only",
1310+
typingKeepalive: false,
12781311
disableBlockStreaming: true,
12791312
});
12801313
expect(createDiscordDraftStream).not.toHaveBeenCalled();
12811314
});
12821315

1316+
it("preserves core typing keepalive when message-tool guild replies configure typing mode", async () => {
1317+
const ctx = await createBaseContext({
1318+
shouldRequireMention: false,
1319+
effectiveWasMentioned: false,
1320+
cfg: {
1321+
messages: {
1322+
groupChat: { visibleReplies: "message_tool" },
1323+
},
1324+
session: {
1325+
store: "/tmp/openclaw-discord-process-test-sessions.json",
1326+
typingMode: "message",
1327+
},
1328+
},
1329+
route: BASE_CHANNEL_ROUTE,
1330+
});
1331+
1332+
await runProcessDiscordMessage(ctx);
1333+
1334+
expect(getLastDispatchReplyOptions()?.sourceReplyDeliveryMode).toBe("message_tool_only");
1335+
expect(getLastDispatchReplyOptions()?.typingKeepalive).toBeUndefined();
1336+
});
1337+
12831338
it("sends the configured ack while suppressing automatic status reactions for always-on guild replies", async () => {
12841339
const ctx = await createBaseContext({
12851340
shouldRequireMention: false,

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ export async function processDiscordMessage(
220220
},
221221
});
222222
const sourceRepliesAreToolOnly = sourceReplyDeliveryMode === "message_tool_only";
223+
const configuredTypingMode = cfg.session?.typingMode ?? cfg.agents?.defaults?.typingMode;
224+
const shouldDisableCoreTypingKeepalive =
225+
sourceRepliesAreToolOnly && configuredTypingMode === undefined;
223226
const ackReaction = resolveAckReaction(cfg, route.agentId, {
224227
channel: "discord",
225228
accountId,
@@ -424,8 +427,9 @@ export async function processDiscordMessage(
424427
error: err,
425428
});
426429
},
427-
// Long tool-heavy runs are expected on Discord; keep heartbeats alive.
430+
// The core reply typing controller owns typing for this path.
428431
maxDurationMs: DISCORD_TYPING_MAX_DURATION_MS,
432+
keepaliveIntervalMs: 0,
429433
},
430434
});
431435
const tableMode = resolveMarkdownTableMode({
@@ -794,6 +798,7 @@ export async function processDiscordMessage(
794798
abortSignal,
795799
skillFilter: channelConfig?.skills,
796800
sourceReplyDeliveryMode,
801+
typingKeepalive: shouldDisableCoreTypingKeepalive ? false : undefined,
797802
queuedDeliveryCorrelations: isRoomEvent
798803
? [{ begin: beginDeliveryCorrelation }]
799804
: undefined,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export type GetReplyOptions = {
6060
/** Called when the typing controller cleans up (e.g., run ended with NO_REPLY). */
6161
onTypingCleanup?: () => void;
6262
onTypingController?: (typing: TypingController) => void;
63+
/** If false, send only the initial typing signal without periodic keepalive refreshes. */
64+
typingKeepalive?: boolean;
6365
isHeartbeat?: boolean;
6466
/** Policy-level typing control for run classes (user/system/internal/heartbeat). */
6567
typingPolicy?: TypingPolicy;

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,7 @@ export async function getReplyFromConfig(
309309
onReplyStart: opts?.onReplyStart,
310310
onCleanup: opts?.onTypingCleanup,
311311
typingIntervalSeconds,
312+
keepalive: opts?.typingKeepalive ?? true,
312313
silentToken: SILENT_REPLY_TOKEN,
313314
log: defaultRuntime.log,
314315
});

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,27 @@ describe("typing controller", () => {
386386
await vi.advanceTimersByTimeAsync(5_000);
387387
expect(onReplyStart).toHaveBeenCalledTimes(1);
388388
});
389+
390+
it("can send the first typing signal without periodic keepalive refreshes", async () => {
391+
vi.useFakeTimers();
392+
const onReplyStart = vi.fn();
393+
const typing = createTypingController({
394+
onReplyStart,
395+
typingIntervalSeconds: 1,
396+
typingTtlMs: 30_000,
397+
keepalive: false,
398+
});
399+
400+
await typing.startTypingLoop();
401+
expect(onReplyStart).toHaveBeenCalledTimes(1);
402+
403+
await vi.advanceTimersByTimeAsync(5_000);
404+
expect(onReplyStart).toHaveBeenCalledTimes(1);
405+
406+
await typing.startTypingLoop();
407+
await vi.advanceTimersByTimeAsync(5_000);
408+
expect(onReplyStart).toHaveBeenCalledTimes(1);
409+
});
389410
});
390411

391412
describe("resolveTypingMode", () => {
@@ -433,6 +454,17 @@ describe("resolveTypingMode", () => {
433454
},
434455
expected: "message",
435456
},
457+
{
458+
name: "configured instant typing mode wins over message-tool-only default",
459+
input: {
460+
configured: "instant" as const,
461+
isGroupChat: true,
462+
wasMentioned: false,
463+
isHeartbeat: false,
464+
sourceReplyDeliveryMode: "message_tool_only" as const,
465+
},
466+
expected: "instant",
467+
},
436468
{
437469
name: "default mentioned group chat",
438470
input: {

src/auto-reply/reply/typing.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export function createTypingController(params: {
1919
onCleanup?: () => void;
2020
typingIntervalSeconds?: number;
2121
typingTtlMs?: number;
22+
keepalive?: boolean;
2223
silentToken?: string;
2324
log?: (message: string) => void;
2425
}): TypingController {
@@ -27,6 +28,7 @@ export function createTypingController(params: {
2728
onCleanup,
2829
typingIntervalSeconds = 6,
2930
typingTtlMs = 2 * 60_000,
31+
keepalive = true,
3032
silentToken = SILENT_REPLY_TOKEN,
3133
log,
3234
} = params;
@@ -188,6 +190,10 @@ export function createTypingController(params: {
188190
if (!onReplyStart) {
189191
return;
190192
}
193+
if (!keepalive) {
194+
await ensureStart();
195+
return;
196+
}
191197
if (typingLoop.isRunning()) {
192198
return;
193199
}

0 commit comments

Comments
 (0)