diff --git a/extensions/discord/src/draft-chunking.test.ts b/extensions/discord/src/draft-chunking.test.ts new file mode 100644 index 00000000000..8141ea0d0b9 --- /dev/null +++ b/extensions/discord/src/draft-chunking.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { resolveDiscordDraftStreamingChunking } from "./draft-chunking.js"; + +describe("resolveDiscordDraftStreamingChunking", () => { + it("returns sane defaults when discord draft chunking is unset", () => { + expect(resolveDiscordDraftStreamingChunking(undefined)).toEqual({ + minChars: 200, + maxChars: 800, + breakPreference: "paragraph", + }); + }); + + it("clamps requested draft chunk sizes to the resolved text limit", () => { + const cfg = { + channels: { + discord: { + textChunkLimit: 500, + draftChunk: { + minChars: 900, + maxChars: 1200, + breakPreference: "sentence", + }, + }, + }, + } as OpenClawConfig; + + expect(resolveDiscordDraftStreamingChunking(cfg)).toEqual({ + minChars: 500, + maxChars: 500, + breakPreference: "sentence", + }); + }); + + it("prefers account draft chunking over channel defaults", () => { + const cfg = { + channels: { + discord: { + draftChunk: { + minChars: 200, + maxChars: 800, + breakPreference: "paragraph", + }, + accounts: { + ops: { + draftChunk: { + minChars: 25, + maxChars: 75, + breakPreference: "newline", + }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveDiscordDraftStreamingChunking(cfg, "ops")).toEqual({ + minChars: 25, + maxChars: 75, + breakPreference: "newline", + }); + }); +}); diff --git a/extensions/discord/src/monitor/inbound-job.test.ts b/extensions/discord/src/monitor/inbound-job.test.ts index 0fda69821eb..756602336d9 100644 --- a/extensions/discord/src/monitor/inbound-job.test.ts +++ b/extensions/discord/src/monitor/inbound-job.test.ts @@ -1,9 +1,35 @@ import { Message } from "@buape/carbon"; import { describe, expect, it } from "vitest"; -import { buildDiscordInboundJob, materializeDiscordInboundJob } from "./inbound-job.js"; +import { + buildDiscordInboundJob, + materializeDiscordInboundJob, + resolveDiscordInboundJobQueueKey, +} from "./inbound-job.js"; import { createBaseDiscordMessageContext } from "./message-handler.test-harness.js"; describe("buildDiscordInboundJob", () => { + it("prefers route session key, then base session key, then channel id for queueing", async () => { + const routed = await createBaseDiscordMessageContext({ + route: { sessionKey: "agent:main:discord:direct:routed" }, + baseSessionKey: "agent:main:discord:direct:base", + messageChannelId: "channel-routed", + }); + const baseOnly = await createBaseDiscordMessageContext({ + route: { sessionKey: "" }, + baseSessionKey: "agent:main:discord:direct:base-only", + messageChannelId: "channel-base", + }); + const channelFallback = await createBaseDiscordMessageContext({ + route: { sessionKey: " " }, + baseSessionKey: " ", + messageChannelId: "channel-fallback", + }); + + expect(resolveDiscordInboundJobQueueKey(routed)).toBe("agent:main:discord:direct:routed"); + expect(resolveDiscordInboundJobQueueKey(baseOnly)).toBe("agent:main:discord:direct:base-only"); + expect(resolveDiscordInboundJobQueueKey(channelFallback)).toBe("channel-fallback"); + }); + it("keeps live runtime references out of the payload", async () => { const ctx = await createBaseDiscordMessageContext({ message: { diff --git a/extensions/discord/src/send.typing.test.ts b/extensions/discord/src/send.typing.test.ts new file mode 100644 index 00000000000..b16858a0d6a --- /dev/null +++ b/extensions/discord/src/send.typing.test.ts @@ -0,0 +1,26 @@ +import type { RequestClient } from "@buape/carbon"; +import { Routes } from "discord-api-types/v10"; +import { describe, expect, it, vi } from "vitest"; + +const resolveDiscordRestMock = vi.hoisted(() => vi.fn()); + +vi.mock("./client.js", () => ({ + resolveDiscordRest: resolveDiscordRestMock, +})); + +import { sendTypingDiscord } from "./send.typing.js"; + +describe("sendTypingDiscord", () => { + it("sends a typing event to the resolved Discord channel route", async () => { + const post = vi.fn(async () => undefined); + resolveDiscordRestMock.mockReturnValue({ + post, + } as unknown as RequestClient); + + const result = await sendTypingDiscord("12345", { accountId: "ops" }); + + expect(resolveDiscordRestMock).toHaveBeenCalledWith({ accountId: "ops" }); + expect(post).toHaveBeenCalledWith(Routes.channelTyping("12345")); + expect(result).toEqual({ ok: true, channelId: "12345" }); + }); +}); diff --git a/extensions/telegram/src/polling-session.test.ts b/extensions/telegram/src/polling-session.test.ts index 3cfbf02d277..2b77f6c711e 100644 --- a/extensions/telegram/src/polling-session.test.ts +++ b/extensions/telegram/src/polling-session.test.ts @@ -98,4 +98,99 @@ describe("TelegramPollingSession", () => { expect(computeBackoffMock).toHaveBeenCalledTimes(1); expect(sleepWithAbortMock).toHaveBeenCalledTimes(1); }); + + it("forces a restart when polling stalls without getUpdates activity", async () => { + const abort = new AbortController(); + const botStop = vi.fn(async () => undefined); + const firstRunnerStop = vi.fn(async () => undefined); + const secondRunnerStop = vi.fn(async () => undefined); + const bot = { + api: { + deleteWebhook: vi.fn(async () => true), + getUpdates: vi.fn(async () => []), + config: { use: vi.fn() }, + }, + stop: botStop, + }; + createTelegramBotMock.mockReturnValue(bot); + + let firstTaskResolve: (() => void) | undefined; + const firstTask = new Promise((resolve) => { + firstTaskResolve = resolve; + }); + let cycle = 0; + runMock.mockImplementation(() => { + cycle += 1; + if (cycle === 1) { + return { + task: () => firstTask, + stop: async () => { + await firstRunnerStop(); + firstTaskResolve?.(); + }, + isRunning: () => true, + }; + } + return { + task: async () => { + abort.abort(); + }, + stop: secondRunnerStop, + isRunning: () => false, + }; + }); + + let watchdog: (() => void) | undefined; + const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn) => { + watchdog = fn as () => void; + return 1 as unknown as ReturnType; + }); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {}); + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((fn) => { + void Promise.resolve().then(() => (fn as () => void)()); + return 1 as unknown as ReturnType; + }); + const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => {}); + const dateNowSpy = vi + .spyOn(Date, "now") + .mockImplementationOnce(() => 0) + .mockImplementation(() => 120_001); + + const log = vi.fn(); + const session = new TelegramPollingSession({ + token: "tok", + config: {}, + accountId: "default", + runtime: undefined, + proxyFetch: undefined, + abortSignal: abort.signal, + runnerOptions: {}, + getLastUpdateId: () => null, + persistUpdateId: async () => undefined, + log, + telegramTransport: undefined, + }); + + try { + const runPromise = session.runUntilAbort(); + for (let attempt = 0; attempt < 20 && !watchdog; attempt += 1) { + await Promise.resolve(); + } + expect(watchdog).toBeTypeOf("function"); + watchdog?.(); + await runPromise; + + expect(runMock).toHaveBeenCalledTimes(2); + expect(firstRunnerStop).toHaveBeenCalledTimes(1); + expect(botStop).toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("Polling stall detected")); + expect(log).toHaveBeenCalledWith(expect.stringContaining("polling stall detected")); + } finally { + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + setTimeoutSpy.mockRestore(); + clearTimeoutSpy.mockRestore(); + dateNowSpy.mockRestore(); + } + }); });