From a57c590a713878ba5bf812c346a580d6de078d15 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:54:54 +0000 Subject: [PATCH 01/94] refactor: share telegram outbound send options --- extensions/telegram/src/channel.ts | 104 +++++++++++++++++++++-------- 1 file changed, 77 insertions(+), 27 deletions(-) diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 52ae2b15ea8..b5ae12fa06d 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -78,6 +78,61 @@ function formatDuplicateTelegramTokenReason(params: { ); } +type TelegramSendFn = ReturnType< + typeof getTelegramRuntime +>["channel"]["telegram"]["sendMessageTelegram"]; +type TelegramSendOptions = NonNullable[2]>; + +function buildTelegramSendOptions(params: { + cfg: OpenClawConfig; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + accountId?: string; + replyToId?: string; + threadId?: string; + silent?: boolean; +}): TelegramSendOptions { + return { + verbose: false, + cfg: params.cfg, + ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), + ...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}), + messageThreadId: parseTelegramThreadId(params.threadId), + replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), + accountId: params.accountId ?? undefined, + silent: params.silent ?? undefined, + }; +} + +async function sendTelegramOutbound(params: { + cfg: OpenClawConfig; + to: string; + text: string; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + accountId?: string; + deps?: { sendTelegram?: TelegramSendFn }; + replyToId?: string; + threadId?: string; + silent?: boolean; +}) { + const send = + params.deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; + return await send( + params.to, + params.text, + buildTelegramSendOptions({ + cfg: params.cfg, + mediaUrl: params.mediaUrl, + mediaLocalRoots: params.mediaLocalRoots, + accountId: params.accountId, + replyToId: params.replyToId, + threadId: params.threadId, + silent: params.silent, + }), + ); +} + const telegramMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getTelegramRuntime().channel.telegram.messageActions?.listActions?.(ctx) ?? [], @@ -327,35 +382,31 @@ export const telegramPlugin: ChannelPlugin { const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; - const replyToMessageId = parseTelegramReplyToMessageId(replyToId); - const messageThreadId = parseTelegramThreadId(threadId); const result = await sendTelegramPayloadMessages({ send, to, payload, - baseOpts: { - verbose: false, + baseOpts: buildTelegramSendOptions({ cfg, mediaLocalRoots, - messageThreadId, - replyToMessageId, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }, + accountId, + replyToId, + threadId, + silent, + }), }); return { channel: "telegram", ...result }; }, sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => { - const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; - const replyToMessageId = parseTelegramReplyToMessageId(replyToId); - const messageThreadId = parseTelegramThreadId(threadId); - const result = await send(to, text, { - verbose: false, + const result = await sendTelegramOutbound({ cfg, - messageThreadId, - replyToMessageId, - accountId: accountId ?? undefined, - silent: silent ?? undefined, + to, + text, + accountId, + deps, + replyToId, + threadId, + silent, }); return { channel: "telegram", ...result }; }, @@ -371,18 +422,17 @@ export const telegramPlugin: ChannelPlugin { - const send = deps?.sendTelegram ?? getTelegramRuntime().channel.telegram.sendMessageTelegram; - const replyToMessageId = parseTelegramReplyToMessageId(replyToId); - const messageThreadId = parseTelegramThreadId(threadId); - const result = await send(to, text, { - verbose: false, + const result = await sendTelegramOutbound({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - messageThreadId, - replyToMessageId, - accountId: accountId ?? undefined, - silent: silent ?? undefined, + accountId, + deps, + replyToId, + threadId, + silent, }); return { channel: "telegram", ...result }; }, From 407d0d296d6ed24ffdc0ff29b3e70400a6582e6c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:55:47 +0000 Subject: [PATCH 02/94] refactor: share tlon outbound send context --- extensions/tlon/src/channel.ts | 105 ++++++++++++++++----------------- 1 file changed, 50 insertions(+), 55 deletions(-) diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 3c5bedbf841..b84679e1f39 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -153,6 +153,48 @@ function applyTlonSetupConfig(params: { }; } +type ResolvedTlonAccount = ReturnType; + +function resolveOutboundContext(params: { cfg: OpenClawConfig; accountId?: string; to: string }) { + const account = resolveTlonAccount(params.cfg, params.accountId ?? undefined); + if (!account.configured || !account.ship || !account.url || !account.code) { + throw new Error("Tlon account not configured"); + } + + const parsed = parseTlonTarget(params.to); + if (!parsed) { + throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); + } + + return { account, parsed }; +} + +function resolveReplyId(replyToId?: string, threadId?: string) { + return (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; +} + +async function withHttpPokeAccountApi( + account: ResolvedTlonAccount & { ship: string; url: string; code: string }, + run: (api: Awaited>) => Promise, +) { + const api = await createHttpPokeApi({ + url: account.url, + ship: account.ship, + code: account.code, + allowPrivateNetwork: account.allowPrivateNetwork ?? undefined, + }); + + try { + return await run(api); + } finally { + try { + await api.delete(); + } catch { + // ignore cleanup errors + } + } +} + const tlonOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", textChunkLimit: 10000, @@ -170,25 +212,8 @@ const tlonOutbound: ChannelOutboundAdapter = { return { ok: true, to: parsed.nest }; }, sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { - const account = resolveTlonAccount(cfg, accountId ?? undefined); - if (!account.configured || !account.ship || !account.url || !account.code) { - throw new Error("Tlon account not configured"); - } - - const parsed = parseTlonTarget(to); - if (!parsed) { - throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); - } - - // Use HTTP-only poke (no EventSource) to avoid conflicts with monitor's SSE connection - const api = await createHttpPokeApi({ - url: account.url, - ship: account.ship, - code: account.code, - allowPrivateNetwork: account.allowPrivateNetwork ?? undefined, - }); - - try { + const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); + return withHttpPokeAccountApi(account, async (api) => { const fromShip = normalizeShip(account.ship); if (parsed.kind === "dm") { return await sendDm({ @@ -198,33 +223,18 @@ const tlonOutbound: ChannelOutboundAdapter = { text, }); } - const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; return await sendGroupMessage({ api, fromShip, hostShip: parsed.hostShip, channelName: parsed.channelName, text, - replyToId: replyId, + replyToId: resolveReplyId(replyToId, threadId), }); - } finally { - try { - await api.delete(); - } catch { - // ignore cleanup errors - } - } + }); }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => { - const account = resolveTlonAccount(cfg, accountId ?? undefined); - if (!account.configured || !account.ship || !account.url || !account.code) { - throw new Error("Tlon account not configured"); - } - - const parsed = parseTlonTarget(to); - if (!parsed) { - throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`); - } + const { account, parsed } = resolveOutboundContext({ cfg, accountId, to }); // Configure the API client for uploads configureClient({ @@ -235,15 +245,7 @@ const tlonOutbound: ChannelOutboundAdapter = { }); const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined; - - const api = await createHttpPokeApi({ - url: account.url, - ship: account.ship, - code: account.code, - allowPrivateNetwork: account.allowPrivateNetwork ?? undefined, - }); - - try { + return withHttpPokeAccountApi(account, async (api) => { const fromShip = normalizeShip(account.ship); const story = buildMediaStory(text, uploadedUrl); @@ -255,22 +257,15 @@ const tlonOutbound: ChannelOutboundAdapter = { story, }); } - const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined; return await sendGroupMessageWithStory({ api, fromShip, hostShip: parsed.hostShip, channelName: parsed.channelName, story, - replyToId: replyId, + replyToId: resolveReplyId(replyToId, threadId), }); - } finally { - try { - await api.delete(); - } catch { - // ignore cleanup errors - } - } + }); }, }; From 1dc8e17371569d79da8256a0f454c27247c461ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:56:25 +0000 Subject: [PATCH 03/94] refactor: share line outbound media loop --- extensions/line/src/channel.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index ddc612b8fa7..982d7670082 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -347,6 +347,16 @@ export const linePlugin: ChannelPlugin = { : []; const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies; + const sendMediaMessages = async () => { + for (const url of mediaUrls) { + lastResult = await runtime.channel.line.sendMessageLine(to, "", { + verbose: false, + mediaUrl: url, + cfg, + accountId: accountId ?? undefined, + }); + } + }; if (!shouldSendQuickRepliesInline) { if (lineData.flexMessage) { @@ -391,14 +401,7 @@ export const linePlugin: ChannelPlugin = { const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0); if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) { - for (const url of mediaUrls) { - lastResult = await runtime.channel.line.sendMessageLine(to, "", { - verbose: false, - mediaUrl: url, - cfg, - accountId: accountId ?? undefined, - }); - } + await sendMediaMessages(); } if (chunks.length > 0) { @@ -471,14 +474,7 @@ export const linePlugin: ChannelPlugin = { } if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) { - for (const url of mediaUrls) { - lastResult = await runtime.channel.line.sendMessageLine(to, "", { - verbose: false, - mediaUrl: url, - cfg, - accountId: accountId ?? undefined, - }); - } + await sendMediaMessages(); } if (lastResult) { From 2cf6e2e4f622749d6211d1736568312311685b1f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:56:57 +0000 Subject: [PATCH 04/94] test: dedupe matrix target resolution cases --- extensions/matrix/src/resolve-targets.test.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts index 10dff313a2e..02a5088e8ae 100644 --- a/extensions/matrix/src/resolve-targets.test.ts +++ b/extensions/matrix/src/resolve-targets.test.ts @@ -8,6 +8,15 @@ vi.mock("./directory-live.js", () => ({ listMatrixDirectoryGroupsLive: vi.fn(), })); +async function resolveUserTarget(input = "Alice") { + const [result] = await resolveMatrixTargets({ + cfg: {}, + inputs: [input], + kind: "user", + }); + return result; +} + describe("resolveMatrixTargets (users)", () => { beforeEach(() => { vi.mocked(listMatrixDirectoryPeersLive).mockReset(); @@ -20,11 +29,7 @@ describe("resolveMatrixTargets (users)", () => { ]; vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches); - const [result] = await resolveMatrixTargets({ - cfg: {}, - inputs: ["Alice"], - kind: "user", - }); + const result = await resolveUserTarget(); expect(result?.resolved).toBe(true); expect(result?.id).toBe("@alice:example.org"); @@ -37,11 +42,7 @@ describe("resolveMatrixTargets (users)", () => { ]; vi.mocked(listMatrixDirectoryPeersLive).mockResolvedValue(matches); - const [result] = await resolveMatrixTargets({ - cfg: {}, - inputs: ["Alice"], - kind: "user", - }); + const result = await resolveUserTarget(); expect(result?.resolved).toBe(false); expect(result?.note).toMatch(/use full Matrix ID/i); From b5eb329f94fb4cae1438ad1eae780509b752fc82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:57:05 +0000 Subject: [PATCH 05/94] test: dedupe feishu outbound setup --- extensions/feishu/src/outbound.test.ts | 27 +++++++++++--------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 11cfc957e80..39b7c1e4a63 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -29,12 +29,16 @@ vi.mock("./runtime.js", () => ({ import { feishuOutbound } from "./outbound.js"; const sendText = feishuOutbound.sendText!; +function resetOutboundMocks() { + vi.clearAllMocks(); + sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); + sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); + sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); +} + describe("feishuOutbound.sendText local-image auto-convert", () => { beforeEach(() => { - vi.clearAllMocks(); - sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); - sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); - sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + resetOutboundMocks(); }); async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> { @@ -181,10 +185,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { describe("feishuOutbound.sendText replyToId forwarding", () => { beforeEach(() => { - vi.clearAllMocks(); - sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); - sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); - sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + resetOutboundMocks(); }); it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => { @@ -249,10 +250,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => { describe("feishuOutbound.sendMedia replyToId forwarding", () => { beforeEach(() => { - vi.clearAllMocks(); - sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); - sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); - sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + resetOutboundMocks(); }); it("forwards replyToId to sendMediaFeishu", async () => { @@ -292,10 +290,7 @@ describe("feishuOutbound.sendMedia replyToId forwarding", () => { describe("feishuOutbound.sendMedia renderMode", () => { beforeEach(() => { - vi.clearAllMocks(); - sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); - sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); - sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); + resetOutboundMocks(); }); it("uses markdown cards for captions when renderMode=card", async () => { From 854df8352c719660b868981d53784930878c33a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:16 +0000 Subject: [PATCH 06/94] refactor: share net and slack input helpers --- src/shared/net/ip.ts | 14 +++++----- src/slack/monitor/message-handler.test.ts | 34 +++++++++++------------ 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/shared/net/ip.ts b/src/shared/net/ip.ts index c386c687898..57c8cccd697 100644 --- a/src/shared/net/ip.ts +++ b/src/shared/net/ip.ts @@ -128,12 +128,16 @@ function normalizeIpv4MappedAddress(address: ParsedIpAddress): ParsedIpAddress { return address.toIPv4Address(); } -export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined { +function normalizeIpParseInput(raw: string | undefined): string | undefined { const trimmed = raw?.trim(); if (!trimmed) { return undefined; } - const normalized = stripIpv6Brackets(trimmed); + return stripIpv6Brackets(trimmed); +} + +export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddress | undefined { + const normalized = normalizeIpParseInput(raw); if (!normalized) { return undefined; } @@ -150,11 +154,7 @@ export function parseCanonicalIpAddress(raw: string | undefined): ParsedIpAddres } export function parseLooseIpAddress(raw: string | undefined): ParsedIpAddress | undefined { - const trimmed = raw?.trim(); - if (!trimmed) { - return undefined; - } - const normalized = stripIpv6Brackets(trimmed); + const normalized = normalizeIpParseInput(raw); if (!normalized) { return undefined; } diff --git a/src/slack/monitor/message-handler.test.ts b/src/slack/monitor/message-handler.test.ts index 8453b9ce4b0..1417ca3e6ec 100644 --- a/src/slack/monitor/message-handler.test.ts +++ b/src/slack/monitor/message-handler.test.ts @@ -48,6 +48,20 @@ function createHandlerWithTracker(overrides?: { return { handler, trackEvent }; } +async function handleDirectMessage( + handler: ReturnType["handler"], +) { + await handler( + { + type: "message", + channel: "D1", + ts: "123.456", + text: "hello", + } as never, + { source: "message" }, + ); +} + describe("createSlackMessageHandler", () => { beforeEach(() => { enqueueMock.mockClear(); @@ -82,15 +96,7 @@ describe("createSlackMessageHandler", () => { it("does not track duplicate messages that are already seen", async () => { const { handler, trackEvent } = createHandlerWithTracker({ markMessageSeen: () => true }); - await handler( - { - type: "message", - channel: "D1", - ts: "123.456", - text: "hello", - } as never, - { source: "message" }, - ); + await handleDirectMessage(handler); expect(trackEvent).not.toHaveBeenCalled(); expect(resolveThreadTsMock).not.toHaveBeenCalled(); @@ -100,15 +106,7 @@ describe("createSlackMessageHandler", () => { it("tracks accepted non-duplicate messages", async () => { const { handler, trackEvent } = createHandlerWithTracker(); - await handler( - { - type: "message", - channel: "D1", - ts: "123.456", - text: "hello", - } as never, - { source: "message" }, - ); + await handleDirectMessage(handler); expect(trackEvent).toHaveBeenCalledTimes(1); expect(resolveThreadTsMock).toHaveBeenCalledTimes(1); From 9a14696f302de93232c6de76fd000a6463491790 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:29 +0000 Subject: [PATCH 07/94] test: dedupe feishu config schema checks --- extensions/feishu/src/config-schema.test.ts | 38 ++++++++------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/extensions/feishu/src/config-schema.test.ts b/extensions/feishu/src/config-schema.test.ts index 0e0881c849f..aacbac85062 100644 --- a/extensions/feishu/src/config-schema.test.ts +++ b/extensions/feishu/src/config-schema.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from "vitest"; import { FeishuConfigSchema, FeishuGroupSchema } from "./config-schema.js"; +function expectSchemaIssue( + result: ReturnType, + issuePath: string, +) { + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((issue) => issue.path.join(".") === issuePath)).toBe(true); + } +} + describe("FeishuConfigSchema webhook validation", () => { it("applies top-level defaults", () => { const result = FeishuConfigSchema.parse({}); @@ -39,12 +49,7 @@ describe("FeishuConfigSchema webhook validation", () => { appSecret: "secret_top", // pragma: allowlist secret }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some((issue) => issue.path.join(".") === "verificationToken"), - ).toBe(true); - } + expectSchemaIssue(result, "verificationToken"); }); it("rejects top-level webhook mode without encryptKey", () => { @@ -55,10 +60,7 @@ describe("FeishuConfigSchema webhook validation", () => { appSecret: "secret_top", // pragma: allowlist secret }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues.some((issue) => issue.path.join(".") === "encryptKey")).toBe(true); - } + expectSchemaIssue(result, "encryptKey"); }); it("accepts top-level webhook mode with verificationToken and encryptKey", () => { @@ -84,14 +86,7 @@ describe("FeishuConfigSchema webhook validation", () => { }, }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some( - (issue) => issue.path.join(".") === "accounts.main.verificationToken", - ), - ).toBe(true); - } + expectSchemaIssue(result, "accounts.main.verificationToken"); }); it("rejects account webhook mode without encryptKey", () => { @@ -106,12 +101,7 @@ describe("FeishuConfigSchema webhook validation", () => { }, }); - expect(result.success).toBe(false); - if (!result.success) { - expect( - result.error.issues.some((issue) => issue.path.join(".") === "accounts.main.encryptKey"), - ).toBe(true); - } + expectSchemaIssue(result, "accounts.main.encryptKey"); }); it("accepts account webhook mode inheriting top-level verificationToken and encryptKey", () => { From a7e5925ec135fb73a2a306f9218f51e53c03da51 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:34 +0000 Subject: [PATCH 08/94] test: dedupe feishu account resolution fixtures --- extensions/feishu/src/accounts.test.ts | 37 +++++++++++++++----------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/extensions/feishu/src/accounts.test.ts b/extensions/feishu/src/accounts.test.ts index 56783bbd29d..cfe8d0abcdc 100644 --- a/extensions/feishu/src/accounts.test.ts +++ b/extensions/feishu/src/accounts.test.ts @@ -9,6 +9,23 @@ import type { FeishuConfig } from "./types.js"; const asConfig = (value: Partial) => value as FeishuConfig; +function makeDefaultAndRouterAccounts() { + return { + default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret + "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret + }; +} + +function expectExplicitDefaultAccountSelection( + account: ReturnType, + appId: string, +) { + expect(account.accountId).toBe("router-d"); + expect(account.selectionSource).toBe("explicit-default"); + expect(account.configured).toBe(true); + expect(account.appId).toBe(appId); +} + function withEnvVar(key: string, value: string | undefined, run: () => void) { const prev = process.env[key]; if (value === undefined) { @@ -44,10 +61,7 @@ describe("resolveDefaultFeishuAccountId", () => { channels: { feishu: { defaultAccount: "router-d", - accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret - "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret - }, + accounts: makeDefaultAndRouterAccounts(), }, }, }; @@ -278,10 +292,7 @@ describe("resolveFeishuAccount", () => { }; const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined }); - expect(account.accountId).toBe("router-d"); - expect(account.selectionSource).toBe("explicit-default"); - expect(account.configured).toBe(true); - expect(account.appId).toBe("top_level_app"); + expectExplicitDefaultAccountSelection(account, "top_level_app"); }); it("uses configured default account when accountId is omitted", () => { @@ -298,10 +309,7 @@ describe("resolveFeishuAccount", () => { }; const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined }); - expect(account.accountId).toBe("router-d"); - expect(account.selectionSource).toBe("explicit-default"); - expect(account.configured).toBe(true); - expect(account.appId).toBe("cli_router"); + expectExplicitDefaultAccountSelection(account, "cli_router"); }); it("keeps explicit accountId selection", () => { @@ -309,10 +317,7 @@ describe("resolveFeishuAccount", () => { channels: { feishu: { defaultAccount: "router-d", - accounts: { - default: { appId: "cli_default", appSecret: "secret_default" }, // pragma: allowlist secret - "router-d": { appId: "cli_router", appSecret: "secret_router" }, // pragma: allowlist secret - }, + accounts: makeDefaultAndRouterAccounts(), }, }, }; From 7ca8804a3362e553dc2555f024880307b7298b5b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:41 +0000 Subject: [PATCH 09/94] test: share feishu schema and reaction assertions --- .../feishu/src/monitor.reaction.test.ts | 98 +++++++++---------- 1 file changed, 48 insertions(+), 50 deletions(-) diff --git a/extensions/feishu/src/monitor.reaction.test.ts b/extensions/feishu/src/monitor.reaction.test.ts index e17859d0531..6d3f64a32d0 100644 --- a/extensions/feishu/src/monitor.reaction.test.ts +++ b/extensions/feishu/src/monitor.reaction.test.ts @@ -78,6 +78,25 @@ async function resolveReactionWithLookup(params: { }); } +async function resolveNonBotReaction(params?: { cfg?: ClawdbotConfig; uuid?: () => string }) { + return await resolveReactionSyntheticEvent({ + cfg: params?.cfg ?? cfg, + accountId: "default", + event: makeReactionEvent(), + botOpenId: "ou_bot", + fetchMessage: async () => ({ + messageId: "om_msg1", + chatId: "oc_group", + chatType: "group", + senderOpenId: "ou_other", + senderType: "user", + content: "hello", + contentType: "text", + }), + ...(params?.uuid ? { uuid: params.uuid } : {}), + }); +} + type FeishuMention = NonNullable[number]; function buildDebounceConfig(): ClawdbotConfig { @@ -179,6 +198,19 @@ function getFirstDispatchedEvent(): FeishuMessageEvent { return firstParams.event; } +function expectSingleDispatchedEvent(): FeishuMessageEvent { + expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); + return getFirstDispatchedEvent(); +} + +function expectParsedFirstDispatchedEvent(botOpenId = "ou_bot") { + const dispatched = expectSingleDispatchedEvent(); + return { + dispatched, + parsed: parseFeishuMessageEvent(dispatched, botOpenId), + }; +} + function setDedupPassThroughMocks(): void { vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); @@ -203,6 +235,13 @@ async function enqueueDebouncedMessage( await Promise.resolve(); } +function setStaleRetryMocks(messageId = "om_old") { + vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(`:${messageId}`)); + vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation( + async (currentMessageId) => currentMessageId === messageId, + ); +} + describe("resolveReactionSyntheticEvent", () => { it("filters app self-reactions", async () => { const event = makeReactionEvent({ operator_type: "app" }); @@ -262,28 +301,12 @@ describe("resolveReactionSyntheticEvent", () => { }); it("filters reactions on non-bot messages", async () => { - const event = makeReactionEvent(); - const result = await resolveReactionSyntheticEvent({ - cfg, - accountId: "default", - event, - botOpenId: "ou_bot", - fetchMessage: async () => ({ - messageId: "om_msg1", - chatId: "oc_group", - chatType: "group", - senderOpenId: "ou_other", - senderType: "user", - content: "hello", - contentType: "text", - }), - }); + const result = await resolveNonBotReaction(); expect(result).toBeNull(); }); it("allows non-bot reactions when reactionNotifications is all", async () => { - const event = makeReactionEvent(); - const result = await resolveReactionSyntheticEvent({ + const result = await resolveNonBotReaction({ cfg: { channels: { feishu: { @@ -291,18 +314,6 @@ describe("resolveReactionSyntheticEvent", () => { }, }, } as ClawdbotConfig, - accountId: "default", - event, - botOpenId: "ou_bot", - fetchMessage: async () => ({ - messageId: "om_msg1", - chatId: "oc_group", - chatType: "group", - senderOpenId: "ou_other", - senderType: "user", - content: "hello", - contentType: "text", - }), uuid: () => "fixed-uuid", }); expect(result?.message.message_id).toBe("om_msg1:reaction:THUMBSUP:fixed-uuid"); @@ -457,8 +468,7 @@ describe("Feishu inbound debounce regressions", () => { ); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); + const dispatched = expectSingleDispatchedEvent(); const mergedMentions = dispatched.message.mentions ?? []; expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true); expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false); @@ -517,9 +527,7 @@ describe("Feishu inbound debounce regressions", () => { ); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); - const parsed = parseFeishuMessageEvent(dispatched, "ou_bot"); + const { dispatched, parsed } = expectParsedFirstDispatchedEvent(); expect(parsed.mentionedBot).toBe(true); expect(parsed.mentionTargets).toBeUndefined(); const mergedMentions = dispatched.message.mentions ?? []; @@ -547,19 +555,14 @@ describe("Feishu inbound debounce regressions", () => { ); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); - const parsed = parseFeishuMessageEvent(dispatched, "ou_bot"); + const { parsed } = expectParsedFirstDispatchedEvent(); expect(parsed.mentionedBot).toBe(true); }); it("excludes previously processed retries from combined debounce text", async () => { vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); - vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old")); - vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation( - async (messageId) => messageId === "om_old", - ); + setStaleRetryMocks(); const onMessage = await setupDebounceMonitor(); await onMessage(createTextEvent({ messageId: "om_old", text: "stale" })); @@ -576,8 +579,7 @@ describe("Feishu inbound debounce regressions", () => { await Promise.resolve(); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); + const dispatched = expectSingleDispatchedEvent(); expect(dispatched.message.message_id).toBe("om_new_2"); const combined = JSON.parse(dispatched.message.content) as { text?: string }; expect(combined.text).toBe("first\nsecond"); @@ -586,10 +588,7 @@ describe("Feishu inbound debounce regressions", () => { it("uses latest fresh message id when debounce batch ends with stale retry", async () => { const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true); vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true); - vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old")); - vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation( - async (messageId) => messageId === "om_old", - ); + setStaleRetryMocks(); const onMessage = await setupDebounceMonitor(); await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" })); @@ -600,8 +599,7 @@ describe("Feishu inbound debounce regressions", () => { await Promise.resolve(); await vi.advanceTimersByTimeAsync(25); - expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1); - const dispatched = getFirstDispatchedEvent(); + const dispatched = expectSingleDispatchedEvent(); expect(dispatched.message.message_id).toBe("om_new"); const combined = JSON.parse(dispatched.message.content) as { text?: string }; expect(combined.text).toBe("fresh"); From 5af8322ff58f4930e3c085139224f35c6c1cae2b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:58:56 +0000 Subject: [PATCH 10/94] refactor: share tlon channel put requests --- extensions/tlon/src/urbit/sse-client.ts | 68 ++++++++++--------------- 1 file changed, 26 insertions(+), 42 deletions(-) diff --git a/extensions/tlon/src/urbit/sse-client.ts b/extensions/tlon/src/urbit/sse-client.ts index ab12977d0e8..afa87502320 100644 --- a/extensions/tlon/src/urbit/sse-client.ts +++ b/extensions/tlon/src/urbit/sse-client.ts @@ -115,20 +115,7 @@ export class UrbitSSEClient { app: string; path: string; }) { - const { response, release } = await urbitFetch({ - baseUrl: this.url, - path: `/~/channel/${this.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([subscription]), - }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, + const { response, release } = await this.putChannelPayload([subscription], { timeoutMs: 30_000, auditContext: "tlon-urbit-subscribe", }); @@ -359,20 +346,7 @@ export class UrbitSSEClient { "event-id": eventId, }; - const { response, release } = await urbitFetch({ - baseUrl: this.url, - path: `/~/channel/${this.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify([ackData]), - }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, + const { response, release } = await this.putChannelPayload([ackData], { timeoutMs: 10_000, auditContext: "tlon-urbit-ack", }); @@ -445,20 +419,7 @@ export class UrbitSSEClient { })); { - const { response, release } = await urbitFetch({ - baseUrl: this.url, - path: `/~/channel/${this.channelId}`, - init: { - method: "PUT", - headers: { - "Content-Type": "application/json", - Cookie: this.cookie, - }, - body: JSON.stringify(unsubscribes), - }, - ssrfPolicy: this.ssrfPolicy, - lookupFn: this.lookupFn, - fetchImpl: this.fetchImpl, + const { response, release } = await this.putChannelPayload(unsubscribes, { timeoutMs: 30_000, auditContext: "tlon-urbit-unsubscribe", }); @@ -501,4 +462,27 @@ export class UrbitSSEClient { await release(); } } + + private async putChannelPayload( + payload: unknown, + params: { timeoutMs: number; auditContext: string }, + ) { + return await urbitFetch({ + baseUrl: this.url, + path: `/~/channel/${this.channelId}`, + init: { + method: "PUT", + headers: { + "Content-Type": "application/json", + Cookie: this.cookie, + }, + body: JSON.stringify(payload), + }, + ssrfPolicy: this.ssrfPolicy, + lookupFn: this.lookupFn, + fetchImpl: this.fetchImpl, + timeoutMs: params.timeoutMs, + auditContext: params.auditContext, + }); + } } From 1ea5bba848f18d0919aeaa22732379fa36f96cc1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 21:59:40 +0000 Subject: [PATCH 11/94] test: dedupe feishu startup preflight waits --- extensions/feishu/src/monitor.startup.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/extensions/feishu/src/monitor.startup.test.ts b/extensions/feishu/src/monitor.startup.test.ts index f5e19159f0a..b95e33d2526 100644 --- a/extensions/feishu/src/monitor.startup.test.ts +++ b/extensions/feishu/src/monitor.startup.test.ts @@ -52,6 +52,12 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig } as ClawdbotConfig; } +async function waitForStartedAccount(started: string[], accountId: string) { + for (let i = 0; i < 10 && !started.includes(accountId); i += 1) { + await Promise.resolve(); + } +} + afterEach(() => { stopFeishuMonitor(); }); @@ -116,10 +122,7 @@ describe("Feishu monitor startup preflight", () => { }); try { - for (let i = 0; i < 10 && !started.includes("beta"); i += 1) { - await Promise.resolve(); - } - + await waitForStartedAccount(started, "beta"); expect(started).toEqual(["alpha", "beta"]); expect(started.filter((accountId) => accountId === "alpha")).toHaveLength(1); } finally { @@ -153,10 +156,7 @@ describe("Feishu monitor startup preflight", () => { }); try { - for (let i = 0; i < 10 && !started.includes("beta"); i += 1) { - await Promise.resolve(); - } - + await waitForStartedAccount(started, "beta"); expect(started).toEqual(["alpha", "beta"]); expect(runtime.error).toHaveBeenCalledWith( expect.stringContaining("bot info probe timed out"), From 4d1fcc1df21c8eaec8a5e7ffe3bd1357b9c90daa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:00:08 +0000 Subject: [PATCH 12/94] test: share memory lancedb temp config harness --- extensions/memory-lancedb/index.test.ts | 83 +++++++++++-------------- 1 file changed, 35 insertions(+), 48 deletions(-) diff --git a/extensions/memory-lancedb/index.test.ts b/extensions/memory-lancedb/index.test.ts index 2d9a6db1063..a733c3dffb8 100644 --- a/extensions/memory-lancedb/index.test.ts +++ b/extensions/memory-lancedb/index.test.ts @@ -18,12 +18,12 @@ const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY); const liveEnabled = HAS_OPENAI_KEY && process.env.OPENCLAW_LIVE_TEST === "1"; const describeLive = liveEnabled ? describe : describe.skip; -describe("memory plugin e2e", () => { - let tmpDir: string; - let dbPath: string; +function installTmpDirHarness(params: { prefix: string }) { + let tmpDir = ""; + let dbPath = ""; beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-test-")); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), params.prefix)); dbPath = path.join(tmpDir, "lancedb"); }); @@ -33,6 +33,27 @@ describe("memory plugin e2e", () => { } }); + return { + getTmpDir: () => tmpDir, + getDbPath: () => dbPath, + }; +} + +describe("memory plugin e2e", () => { + const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-test-" }); + + async function parseConfig(overrides: Record = {}) { + const { default: memoryPlugin } = await import("./index.js"); + return memoryPlugin.configSchema?.parse?.({ + embedding: { + apiKey: OPENAI_API_KEY, + model: "text-embedding-3-small", + }, + dbPath: getDbPath(), + ...overrides, + }); + } + test("memory plugin registers and initializes correctly", async () => { // Dynamic import to avoid loading LanceDB when not testing const { default: memoryPlugin } = await import("./index.js"); @@ -46,21 +67,14 @@ describe("memory plugin e2e", () => { }); test("config schema parses valid config", async () => { - const { default: memoryPlugin } = await import("./index.js"); - - const config = memoryPlugin.configSchema?.parse?.({ - embedding: { - apiKey: OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath, + const config = await parseConfig({ autoCapture: true, autoRecall: true, }); expect(config).toBeDefined(); expect(config?.embedding?.apiKey).toBe(OPENAI_API_KEY); - expect(config?.dbPath).toBe(dbPath); + expect(config?.dbPath).toBe(getDbPath()); expect(config?.captureMaxChars).toBe(500); }); @@ -74,7 +88,7 @@ describe("memory plugin e2e", () => { embedding: { apiKey: "${TEST_MEMORY_API_KEY}", }, - dbPath, + dbPath: getDbPath(), }); expect(config?.embedding?.apiKey).toBe("test-key-123"); @@ -88,7 +102,7 @@ describe("memory plugin e2e", () => { expect(() => { memoryPlugin.configSchema?.parse?.({ embedding: {}, - dbPath, + dbPath: getDbPath(), }); }).toThrow("embedding.apiKey is required"); }); @@ -99,21 +113,14 @@ describe("memory plugin e2e", () => { expect(() => { memoryPlugin.configSchema?.parse?.({ embedding: { apiKey: OPENAI_API_KEY }, - dbPath, + dbPath: getDbPath(), captureMaxChars: 99, }); }).toThrow("captureMaxChars must be between 100 and 10000"); }); test("config schema accepts captureMaxChars override", async () => { - const { default: memoryPlugin } = await import("./index.js"); - - const config = memoryPlugin.configSchema?.parse?.({ - embedding: { - apiKey: OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath, + const config = await parseConfig({ captureMaxChars: 1800, }); @@ -121,15 +128,7 @@ describe("memory plugin e2e", () => { }); test("config schema keeps autoCapture disabled by default", async () => { - const { default: memoryPlugin } = await import("./index.js"); - - const config = memoryPlugin.configSchema?.parse?.({ - embedding: { - apiKey: OPENAI_API_KEY, - model: "text-embedding-3-small", - }, - dbPath, - }); + const config = await parseConfig(); expect(config?.autoCapture).toBe(false); expect(config?.autoRecall).toBe(true); @@ -176,7 +175,7 @@ describe("memory plugin e2e", () => { model: "text-embedding-3-small", dimensions: 1024, }, - dbPath, + dbPath: getDbPath(), autoCapture: false, autoRecall: false, }, @@ -279,19 +278,7 @@ describe("memory plugin e2e", () => { // Live tests that require OpenAI API key and actually use LanceDB describeLive("memory plugin live tests", () => { - let tmpDir: string; - let dbPath: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-memory-live-")); - dbPath = path.join(tmpDir, "lancedb"); - }); - - afterEach(async () => { - if (tmpDir) { - await fs.rm(tmpDir, { recursive: true, force: true }); - } - }); + const { getDbPath } = installTmpDirHarness({ prefix: "openclaw-memory-live-" }); test("memory tools work end-to-end", async () => { const { default: memoryPlugin } = await import("./index.js"); @@ -318,7 +305,7 @@ describeLive("memory plugin live tests", () => { apiKey: liveApiKey, model: "text-embedding-3-small", }, - dbPath, + dbPath: getDbPath(), autoCapture: false, autoRecall: false, }, From b2133486651ab18074b6cd83850dbf1cf9c4d71b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:00:10 +0000 Subject: [PATCH 13/94] test: dedupe feishu signed webhook posts --- .../feishu/src/monitor.webhook-e2e.test.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/extensions/feishu/src/monitor.webhook-e2e.test.ts b/extensions/feishu/src/monitor.webhook-e2e.test.ts index 451ebe0d2bf..a11957e3393 100644 --- a/extensions/feishu/src/monitor.webhook-e2e.test.ts +++ b/extensions/feishu/src/monitor.webhook-e2e.test.ts @@ -50,6 +50,14 @@ function encryptFeishuPayload(encryptKey: string, payload: Record) { + return await fetch(url, { + method: "POST", + headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), + body: JSON.stringify(payload), + }); +} + afterEach(() => { stopFeishuMonitor(); }); @@ -143,11 +151,7 @@ describe("Feishu webhook signed-request e2e", () => { monitorFeishuProvider, async (url) => { const payload = { type: "url_verification", challenge: "challenge-token" }; - const response = await fetch(url, { - method: "POST", - headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), - body: JSON.stringify(payload), - }); + const response = await postSignedPayload(url, payload); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ challenge: "challenge-token" }); @@ -172,11 +176,7 @@ describe("Feishu webhook signed-request e2e", () => { header: { event_type: "unknown.event" }, event: {}, }; - const response = await fetch(url, { - method: "POST", - headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), - body: JSON.stringify(payload), - }); + const response = await postSignedPayload(url, payload); expect(response.status).toBe(200); expect(await response.text()).toContain("no unknown.event event handle"); @@ -202,11 +202,7 @@ describe("Feishu webhook signed-request e2e", () => { challenge: "encrypted-challenge-token", }), }; - const response = await fetch(url, { - method: "POST", - headers: signFeishuPayload({ encryptKey: "encrypt_key", payload }), - body: JSON.stringify(payload), - }); + const response = await postSignedPayload(url, payload); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ From 8ca510a66960ad073da66b1aed23ea23cac88bae Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:00:45 +0000 Subject: [PATCH 14/94] test: dedupe feishu media account setup --- extensions/feishu/src/media.test.ts | 31 ++++++++++++----------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 813e5090292..b0226669df1 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -64,18 +64,21 @@ function expectMediaTimeoutClientConfigured(): void { ); } +function mockResolvedFeishuAccount() { + resolveFeishuAccountMock.mockReturnValue({ + configured: true, + accountId: "main", + config: {}, + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + }); +} + describe("sendMediaFeishu msg_type routing", () => { beforeEach(() => { vi.clearAllMocks(); - - resolveFeishuAccountMock.mockReturnValue({ - configured: true, - accountId: "main", - config: {}, - appId: "app_id", - appSecret: "app_secret", - domain: "feishu", - }); + mockResolvedFeishuAccount(); normalizeFeishuTargetMock.mockReturnValue("ou_target"); resolveReceiveIdTypeMock.mockReturnValue("open_id"); @@ -483,15 +486,7 @@ describe("sanitizeFileNameForUpload", () => { describe("downloadMessageResourceFeishu", () => { beforeEach(() => { vi.clearAllMocks(); - - resolveFeishuAccountMock.mockReturnValue({ - configured: true, - accountId: "main", - config: {}, - appId: "app_id", - appSecret: "app_secret", - domain: "feishu", - }); + mockResolvedFeishuAccount(); createFeishuClientMock.mockReturnValue({ im: { From 40b0cbd71306be91669edc73cd88f7b64164dab3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:00:59 +0000 Subject: [PATCH 15/94] test: dedupe thread ownership send checks --- extensions/thread-ownership/index.test.ts | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/extensions/thread-ownership/index.test.ts b/extensions/thread-ownership/index.test.ts index 825b4ca5bb5..3d98d8f9735 100644 --- a/extensions/thread-ownership/index.test.ts +++ b/extensions/thread-ownership/index.test.ts @@ -51,6 +51,13 @@ describe("thread-ownership plugin", () => { register(api as any); }); + async function sendSlackThreadMessage() { + return await hooks.message_sending( + { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, + { channelId: "slack", conversationId: "C123" }, + ); + } + it("allows non-slack channels", async () => { const result = await hooks.message_sending( { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, @@ -76,10 +83,7 @@ describe("thread-ownership plugin", () => { new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }), ); - const result = await hooks.message_sending( - { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); + const result = await sendSlackThreadMessage(); expect(result).toBeUndefined(); expect(globalThis.fetch).toHaveBeenCalledWith( @@ -96,10 +100,7 @@ describe("thread-ownership plugin", () => { new Response(JSON.stringify({ owner: "other-agent" }), { status: 409 }), ); - const result = await hooks.message_sending( - { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); + const result = await sendSlackThreadMessage(); expect(result).toEqual({ cancel: true }); expect(api.logger.info).toHaveBeenCalledWith(expect.stringContaining("cancelled send")); @@ -108,10 +109,7 @@ describe("thread-ownership plugin", () => { it("fails open on network error", async () => { vi.mocked(globalThis.fetch).mockRejectedValue(new Error("ECONNREFUSED")); - const result = await hooks.message_sending( - { content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" }, - { channelId: "slack", conversationId: "C123" }, - ); + const result = await sendSlackThreadMessage(); expect(result).toBeUndefined(); expect(api.logger.warn).toHaveBeenCalledWith( From 2ebc7e3ded2a3ba0e4b53c9f2abdf1df69952be4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:01:37 +0000 Subject: [PATCH 16/94] test: dedupe msteams revoked thread context --- extensions/msteams/src/messenger.test.ts | 40 +++++++++++------------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index aa0a92b5159..cc4cf2fb6f0 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -139,6 +139,22 @@ describe("msteams messenger", () => { }); describe("sendMSTeamsMessages", () => { + function createRevokedThreadContext(params?: { failAfterAttempt?: number; sent?: string[] }) { + let attempt = 0; + return { + sendActivity: async (activity: unknown) => { + const { text } = activity as { text?: string }; + const content = text ?? ""; + attempt += 1; + if (params?.failAfterAttempt && attempt < params.failAfterAttempt) { + params.sent?.push(content); + return { id: `id:${content}` }; + } + throw new TypeError(REVOCATION_ERROR); + }, + }; + } + const baseRef: StoredConversationReference = { activityId: "activity123", user: { id: "user123", name: "User" }, @@ -305,13 +321,7 @@ describe("msteams messenger", () => { it("falls back to proactive messaging when thread context is revoked", async () => { const proactiveSent: string[] = []; - - const ctx = { - sendActivity: async () => { - throw new TypeError(REVOCATION_ERROR); - }, - }; - + const ctx = createRevokedThreadContext(); const adapter = createFallbackAdapter(proactiveSent); const ids = await sendMSTeamsMessages({ @@ -331,21 +341,7 @@ describe("msteams messenger", () => { it("falls back only for remaining thread messages after context revocation", async () => { const threadSent: string[] = []; const proactiveSent: string[] = []; - let attempt = 0; - - const ctx = { - sendActivity: async (activity: unknown) => { - const { text } = activity as { text?: string }; - const content = text ?? ""; - attempt += 1; - if (attempt === 1) { - threadSent.push(content); - return { id: `id:${content}` }; - } - throw new TypeError(REVOCATION_ERROR); - }, - }; - + const ctx = createRevokedThreadContext({ failAfterAttempt: 2, sent: threadSent }); const adapter = createFallbackAdapter(proactiveSent); const ids = await sendMSTeamsMessages({ From a4a7958678d77682fb4a6ed1981d0b8ae6ccd145 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:01:52 +0000 Subject: [PATCH 17/94] refactor: share outbound base session setup --- src/infra/outbound/outbound-session.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index d4a8a3466c6..afd3b1966c3 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -532,6 +532,21 @@ function resolveMatrixSession( }; } +function buildSimpleBaseSession(params: { + route: ResolveOutboundSessionRouteParams; + channel: string; + peer: RoutePeer; +}) { + const baseSessionKey = buildBaseSessionKey({ + cfg: params.route.cfg, + agentId: params.route.agentId, + channel: params.channel, + accountId: params.route.accountId, + peer: params.peer, + }); + return { baseSessionKey, peer: params.peer }; +} + function resolveMSTeamsSession( params: ResolveOutboundSessionRouteParams, ): OutboundSessionRoute | null { @@ -596,13 +611,10 @@ function resolveMattermostSession( if (!rawId) { return null; } - const peer: RoutePeer = { kind: isUser ? "direct" : "channel", id: rawId }; - const baseSessionKey = buildBaseSessionKey({ - cfg: params.cfg, - agentId: params.agentId, + const { baseSessionKey, peer } = buildSimpleBaseSession({ + route: params, channel: "mattermost", - accountId: params.accountId, - peer, + peer: { kind: isUser ? "direct" : "channel", id: rawId }, }); const threadId = normalizeThreadId(params.replyToId ?? params.threadId); const threadKeys = resolveThreadSessionKeys({ From fd58268f04ce132e64d36f50d4749dfa366c71c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:02:09 +0000 Subject: [PATCH 18/94] test: dedupe bluebubbles normalize fixtures --- .../bluebubbles/src/monitor-normalize.test.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/extensions/bluebubbles/src/monitor-normalize.test.ts b/extensions/bluebubbles/src/monitor-normalize.test.ts index 3e06302593c..62651279237 100644 --- a/extensions/bluebubbles/src/monitor-normalize.test.ts +++ b/extensions/bluebubbles/src/monitor-normalize.test.ts @@ -1,18 +1,24 @@ import { describe, expect, it } from "vitest"; import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js"; +function createFallbackDmPayload(overrides: Record = {}) { + return { + guid: "msg-1", + isGroup: false, + isFromMe: false, + handle: null, + chatGuid: "iMessage;-;+15551234567", + ...overrides, + }; +} + describe("normalizeWebhookMessage", () => { it("falls back to DM chatGuid handle when sender handle is missing", () => { const result = normalizeWebhookMessage({ type: "new-message", - data: { - guid: "msg-1", + data: createFallbackDmPayload({ text: "hello", - isGroup: false, - isFromMe: false, - handle: null, - chatGuid: "iMessage;-;+15551234567", - }, + }), }); expect(result).not.toBeNull(); @@ -78,15 +84,11 @@ describe("normalizeWebhookReaction", () => { it("falls back to DM chatGuid handle when reaction sender handle is missing", () => { const result = normalizeWebhookReaction({ type: "updated-message", - data: { + data: createFallbackDmPayload({ guid: "msg-2", associatedMessageGuid: "p:0/msg-1", associatedMessageType: 2000, - isGroup: false, - isFromMe: false, - handle: null, - chatGuid: "iMessage;-;+15551234567", - }, + }), }); expect(result).not.toBeNull(); From 0229246f3b30525cdd32aa4bf46ea2d773d6cccf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:02:19 +0000 Subject: [PATCH 19/94] test: share wake failure assertions --- .../server-methods/nodes.invoke-wake.test.ts | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index 58596d582f8..fc01f718bbb 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -52,6 +52,24 @@ type RespondCall = [ }?, ]; +function expectNodeNotConnected(respond: ReturnType) { + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.message).toBe("node not connected"); +} + +async function invokeDisconnectedNode(nodeId: string, idempotencyKey: string) { + const nodeRegistry = { + get: vi.fn(() => undefined), + invoke: vi.fn().mockResolvedValue({ ok: true }), + }; + + return await invokeNode({ + nodeRegistry, + requestParams: { nodeId, idempotencyKey }, + }); +} + type TestNodeSession = { nodeId: string; commands: string[]; @@ -357,20 +375,9 @@ describe("node.invoke APNs wake path", () => { reason: "BadDeviceToken", }); mocks.shouldClearStoredApnsRegistration.mockReturnValue(true); + const respond = await invokeDisconnectedNode("ios-node-stale", "idem-stale"); - const nodeRegistry = { - get: vi.fn(() => undefined), - invoke: vi.fn().mockResolvedValue({ ok: true }), - }; - - const respond = await invokeNode({ - nodeRegistry, - requestParams: { nodeId: "ios-node-stale", idempotencyKey: "idem-stale" }, - }); - - const call = respond.mock.calls[0] as RespondCall | undefined; - expect(call?.[0]).toBe(false); - expect(call?.[2]?.message).toBe("node not connected"); + expectNodeNotConnected(respond); expect(mocks.clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({ nodeId: "ios-node-stale", registration, @@ -385,20 +392,9 @@ describe("node.invoke APNs wake path", () => { reason: "Unregistered", }); mocks.shouldClearStoredApnsRegistration.mockReturnValue(false); + const respond = await invokeDisconnectedNode("ios-node-relay", "idem-relay"); - const nodeRegistry = { - get: vi.fn(() => undefined), - invoke: vi.fn().mockResolvedValue({ ok: true }), - }; - - const respond = await invokeNode({ - nodeRegistry, - requestParams: { nodeId: "ios-node-relay", idempotencyKey: "idem-relay" }, - }); - - const call = respond.mock.calls[0] as RespondCall | undefined; - expect(call?.[0]).toBe(false); - expect(call?.[2]?.message).toBe("node not connected"); + expectNodeNotConnected(respond); expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, { push: { apns: { From 017c0dce323f0902e3f117c8750be164c46a8885 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:03:34 +0000 Subject: [PATCH 20/94] test: dedupe msteams attachment redirects --- extensions/msteams/src/attachments.test.ts | 32 ++++++++-------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/extensions/msteams/src/attachments.test.ts b/extensions/msteams/src/attachments.test.ts index 6887fad7fcb..790dc8bd33f 100644 --- a/extensions/msteams/src/attachments.test.ts +++ b/extensions/msteams/src/attachments.test.ts @@ -88,14 +88,17 @@ function isUrlAllowedBySsrfPolicy(url: string, policy?: SsrFPolicy): boolean { ); } -const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { +async function fetchRemoteMediaWithRedirects( + params: RemoteMediaFetchParams, + requestInit?: RequestInit, +) { const fetchFn = params.fetchImpl ?? fetch; let currentUrl = params.url; for (let i = 0; i <= MAX_REDIRECT_HOPS; i += 1) { if (!isUrlAllowedBySsrfPolicy(currentUrl, params.ssrfPolicy)) { throw new Error(`Blocked hostname (not in allowlist): ${currentUrl}`); } - const res = await fetchFn(currentUrl, { redirect: "manual" }); + const res = await fetchFn(currentUrl, { redirect: "manual", ...requestInit }); if (REDIRECT_STATUS_CODES.includes(res.status)) { const location = res.headers.get("location"); if (!location) { @@ -107,6 +110,10 @@ const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { return readRemoteMediaResponse(res, params); } throw new Error("too many redirects"); +} + +const fetchRemoteMediaMock = vi.fn(async (params: RemoteMediaFetchParams) => { + return await fetchRemoteMediaWithRedirects(params); }); const runtimeStub: PluginRuntime = createPluginRuntimeMock({ @@ -720,24 +727,9 @@ describe("msteams attachments", () => { }); fetchRemoteMediaMock.mockImplementationOnce(async (params) => { - const fetchFn = params.fetchImpl ?? fetch; - let currentUrl = params.url; - for (let i = 0; i < MAX_REDIRECT_HOPS; i += 1) { - const res = await fetchFn(currentUrl, { - redirect: "manual", - dispatcher: {}, - } as RequestInit); - if (REDIRECT_STATUS_CODES.includes(res.status)) { - const location = res.headers.get("location"); - if (!location) { - throw new Error("redirect missing location"); - } - currentUrl = new URL(location, currentUrl).toString(); - continue; - } - return readRemoteMediaResponse(res, params); - } - throw new Error("too many redirects"); + return await fetchRemoteMediaWithRedirects(params, { + dispatcher: {}, + } as RequestInit); }); const media = await downloadAttachmentsWithFetch( From c5dc61e795037f213ef0c1ffeecdf4384922b0f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:03:57 +0000 Subject: [PATCH 21/94] test: share session target and outbound mirror helpers --- src/config/sessions/targets.test.ts | 23 ++-- .../outbound/outbound-send-service.test.ts | 101 ++++++++++-------- 2 files changed, 71 insertions(+), 53 deletions(-) diff --git a/src/config/sessions/targets.test.ts b/src/config/sessions/targets.test.ts index 720cc3e892e..43674233a3a 100644 --- a/src/config/sessions/targets.test.ts +++ b/src/config/sessions/targets.test.ts @@ -40,6 +40,14 @@ function createCustomRootCfg(customRoot: string, defaultAgentId = "ops"): OpenCl }; } +async function resolveTargetsForCustomRoot(home: string, agentIds: string[]) { + const customRoot = path.join(home, "custom-state"); + const storePaths = await createAgentSessionStores(customRoot, agentIds); + const cfg = createCustomRootCfg(customRoot); + const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + return { storePaths, targets }; +} + function expectTargetsToContainStores( targets: Array<{ agentId: string; storePath: string }>, stores: Record, @@ -152,11 +160,7 @@ describe("resolveAllAgentSessionStoreTargets", () => { it("discovers retired agent stores under a configured custom session root", async () => { await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - const storePaths = await createAgentSessionStores(customRoot, ["ops", "retired"]); - const cfg = createCustomRootCfg(customRoot); - - const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + const { storePaths, targets } = await resolveTargetsForCustomRoot(home, ["ops", "retired"]); expectTargetsToContainStores(targets, storePaths); expect(targets.filter((target) => target.storePath === storePaths.ops)).toHaveLength(1); @@ -165,11 +169,10 @@ describe("resolveAllAgentSessionStoreTargets", () => { it("keeps the actual on-disk store path for discovered retired agents", async () => { await withTempHome(async (home) => { - const customRoot = path.join(home, "custom-state"); - const storePaths = await createAgentSessionStores(customRoot, ["ops", "Retired Agent"]); - const cfg = createCustomRootCfg(customRoot); - - const targets = await resolveAllAgentSessionStoreTargets(cfg, { env: process.env }); + const { storePaths, targets } = await resolveTargetsForCustomRoot(home, [ + "ops", + "Retired Agent", + ]); expect(targets).toEqual( expect.arrayContaining([ diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index ac144265753..d4a481a8693 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -47,6 +47,47 @@ describe("executeSendAction", () => { }; } + function expectMirrorWrite( + expected: Partial<{ + agentId: string; + sessionKey: string; + text: string; + idempotencyKey: string; + mediaUrls: string[]; + }>, + ) { + expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( + expect.objectContaining(expected), + ); + } + + async function executePluginMirroredSend(params: { + mirror?: Partial<{ + sessionKey: string; + agentId?: string; + idempotencyKey?: string; + }>; + mediaUrls?: string[]; + }) { + mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "discord", + params: { to: "channel:123", message: "hello" }, + dryRun: false, + mirror: { + sessionKey: "agent:main:discord:channel:123", + ...params.mirror, + }, + }, + to: "channel:123", + message: "hello", + mediaUrls: params.mediaUrls, + }); + } + beforeEach(() => { mocks.dispatchChannelMessageAction.mockClear(); mocks.sendMessage.mockClear(); @@ -131,59 +172,33 @@ describe("executeSendAction", () => { }); it("passes mirror idempotency keys through plugin-handled sends", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); - - await executeSendAction({ - ctx: { - cfg: {}, - channel: "discord", - params: { to: "channel:123", message: "hello" }, - dryRun: false, - mirror: { - sessionKey: "agent:main:discord:channel:123", - idempotencyKey: "idem-plugin-send-1", - }, + await executePluginMirroredSend({ + mirror: { + idempotencyKey: "idem-plugin-send-1", }, - to: "channel:123", - message: "hello", }); - expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( - expect.objectContaining({ - sessionKey: "agent:main:discord:channel:123", - text: "hello", - idempotencyKey: "idem-plugin-send-1", - }), - ); + expectMirrorWrite({ + sessionKey: "agent:main:discord:channel:123", + text: "hello", + idempotencyKey: "idem-plugin-send-1", + }); }); it("falls back to message and media params for plugin-handled mirror writes", async () => { - mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin")); - - await executeSendAction({ - ctx: { - cfg: {}, - channel: "discord", - params: { to: "channel:123", message: "hello" }, - dryRun: false, - mirror: { - sessionKey: "agent:main:discord:channel:123", - agentId: "agent-9", - }, + await executePluginMirroredSend({ + mirror: { + agentId: "agent-9", }, - to: "channel:123", - message: "hello", mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], }); - expect(mocks.appendAssistantMessageToSessionTranscript).toHaveBeenCalledWith( - expect.objectContaining({ - agentId: "agent-9", - sessionKey: "agent:main:discord:channel:123", - text: "hello", - mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], - }), - ); + expectMirrorWrite({ + agentId: "agent-9", + sessionKey: "agent:main:discord:channel:123", + text: "hello", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + }); }); it("skips plugin dispatch during dry-run sends and forwards gateway + silent to sendMessage", async () => { From 9b24f890b25abdc29e89b18daa36f809a10e93da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:04:19 +0000 Subject: [PATCH 22/94] refactor: share voice call message actions --- extensions/voice-call/index.ts | 64 ++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 8e2fba9898f..e61b5142ef1 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -227,6 +227,35 @@ const voiceCallPlugin = { params.respond(true, { callId: result.callId, initiated: true }); }; + const respondToCallMessageAction = async (params: { + requestParams: GatewayRequestHandlerOptions["params"]; + respond: GatewayRequestHandlerOptions["respond"]; + action: (request: Awaited>) => Promise<{ + success: boolean; + error?: string; + transcript?: string; + }>; + failure: string; + includeTranscript?: boolean; + }) => { + const request = await resolveCallMessageRequest(params.requestParams); + if ("error" in request) { + params.respond(false, { error: request.error }); + return; + } + const result = await params.action(request); + if (!result.success) { + params.respond(false, { error: result.error || params.failure }); + return; + } + params.respond( + true, + params.includeTranscript + ? { success: true, transcript: result.transcript } + : { success: true }, + ); + }; + api.registerGatewayMethod( "voicecall.initiate", async ({ params, respond }: GatewayRequestHandlerOptions) => { @@ -264,17 +293,13 @@ const voiceCallPlugin = { "voicecall.continue", async ({ params, respond }: GatewayRequestHandlerOptions) => { try { - const request = await resolveCallMessageRequest(params); - if ("error" in request) { - respond(false, { error: request.error }); - return; - } - const result = await request.rt.manager.continueCall(request.callId, request.message); - if (!result.success) { - respond(false, { error: result.error || "continue failed" }); - return; - } - respond(true, { success: true, transcript: result.transcript }); + await respondToCallMessageAction({ + requestParams: params, + respond, + action: (request) => request.rt.manager.continueCall(request.callId, request.message), + failure: "continue failed", + includeTranscript: true, + }); } catch (err) { sendError(respond, err); } @@ -285,17 +310,12 @@ const voiceCallPlugin = { "voicecall.speak", async ({ params, respond }: GatewayRequestHandlerOptions) => { try { - const request = await resolveCallMessageRequest(params); - if ("error" in request) { - respond(false, { error: request.error }); - return; - } - const result = await request.rt.manager.speak(request.callId, request.message); - if (!result.success) { - respond(false, { error: result.error || "speak failed" }); - return; - } - respond(true, { success: true }); + await respondToCallMessageAction({ + requestParams: params, + respond, + action: (request) => request.rt.manager.speak(request.callId, request.message), + failure: "speak failed", + }); } catch (err) { sendError(respond, err); } From 86caf454f435e3b9d740acc837d376f22a61760d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:05:12 +0000 Subject: [PATCH 23/94] refactor: share device pair ipv4 parsing --- extensions/device-pair/index.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/extensions/device-pair/index.ts b/extensions/device-pair/index.ts index 825d1668ac0..7ba88842a7a 100644 --- a/extensions/device-pair/index.ts +++ b/extensions/device-pair/index.ts @@ -108,13 +108,21 @@ function resolveScheme( return cfg.gateway?.tls?.enabled === true ? "wss" : "ws"; } -function isPrivateIPv4(address: string): boolean { +function parseIPv4Octets(address: string): [number, number, number, number] | null { const parts = address.split("."); - if (parts.length != 4) { - return false; + if (parts.length !== 4) { + return null; } const octets = parts.map((part) => Number.parseInt(part, 10)); if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { + return null; + } + return octets as [number, number, number, number]; +} + +function isPrivateIPv4(address: string): boolean { + const octets = parseIPv4Octets(address); + if (!octets) { return false; } const [a, b] = octets; @@ -131,12 +139,8 @@ function isPrivateIPv4(address: string): boolean { } function isTailnetIPv4(address: string): boolean { - const parts = address.split("."); - if (parts.length !== 4) { - return false; - } - const octets = parts.map((part) => Number.parseInt(part, 10)); - if (octets.some((value) => !Number.isFinite(value) || value < 0 || value > 255)) { + const octets = parseIPv4Octets(address); + if (!octets) { return false; } const [a, b] = octets; From 07900303f4a1a9ae4da92ef3ba446f3c98782264 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:06:32 +0000 Subject: [PATCH 24/94] refactor: share outbound poll and signal route helpers --- src/infra/outbound/message.ts | 48 +++++++++++++++++++---------- src/signal/monitor/event-handler.ts | 48 ++++++++++++++++++----------- 2 files changed, 62 insertions(+), 34 deletions(-) diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 8bfd6b104b5..3596bef59c9 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -100,6 +100,32 @@ export type MessagePollResult = { dryRun?: boolean; }; +function buildMessagePollResult(params: { + channel: string; + to: string; + normalized: { + question: string; + options: string[]; + maxSelections: number; + durationSeconds?: number | null; + durationHours?: number | null; + }; + result?: MessagePollResult["result"]; + dryRun?: boolean; +}): MessagePollResult { + return { + channel: params.channel, + to: params.to, + question: params.normalized.question, + options: params.normalized.options, + maxSelections: params.normalized.maxSelections, + durationSeconds: params.normalized.durationSeconds ?? null, + durationHours: params.normalized.durationHours ?? null, + via: "gateway", + ...(params.dryRun ? { dryRun: true } : { result: params.result }), + }; +} + async function resolveRequiredChannel(params: { cfg: OpenClawConfig; channel?: string; @@ -291,17 +317,12 @@ export async function sendPoll(params: MessagePollParams): Promise Date: Fri, 13 Mar 2026 22:06:49 +0000 Subject: [PATCH 25/94] test: dedupe diffs http local get setup --- extensions/diffs/src/http.test.ts | 35 ++++++++++++++----------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/extensions/diffs/src/http.test.ts b/extensions/diffs/src/http.test.ts index 43216580379..a1caef018e4 100644 --- a/extensions/diffs/src/http.test.ts +++ b/extensions/diffs/src/http.test.ts @@ -9,6 +9,19 @@ describe("createDiffsHttpHandler", () => { let store: DiffArtifactStore; let cleanupRootDir: () => Promise; + async function handleLocalGet(url: string) { + const handler = createDiffsHttpHandler({ store }); + const res = createMockServerResponse(); + const handled = await handler( + localReq({ + method: "GET", + url, + }), + res, + ); + return { handled, res }; + } + beforeEach(async () => { ({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-")); }); @@ -19,16 +32,7 @@ describe("createDiffsHttpHandler", () => { it("serves a stored diff document", async () => { const artifact = await createViewerArtifact(store); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath, - }), - res, - ); + const { handled, res } = await handleLocalGet(artifact.viewerPath); expect(handled).toBe(true); expect(res.statusCode).toBe(200); @@ -38,15 +42,8 @@ describe("createDiffsHttpHandler", () => { it("rejects invalid tokens", async () => { const artifact = await createViewerArtifact(store); - - const handler = createDiffsHttpHandler({ store }); - const res = createMockServerResponse(); - const handled = await handler( - localReq({ - method: "GET", - url: artifact.viewerPath.replace(artifact.token, "bad-token"), - }), - res, + const { handled, res } = await handleLocalGet( + artifact.viewerPath.replace(artifact.token, "bad-token"), ); expect(handled).toBe(true); From 088d6432a4e820fc892508ba72548b9a80aaaac2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:06:55 +0000 Subject: [PATCH 26/94] test: dedupe diffs file artifact assertions --- extensions/diffs/src/tool.test.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/extensions/diffs/src/tool.test.ts b/extensions/diffs/src/tool.test.ts index 416bdf8dc14..1e939c60390 100644 --- a/extensions/diffs/src/tool.test.ts +++ b/extensions/diffs/src/tool.test.ts @@ -135,9 +135,7 @@ describe("diffs tool", () => { mode: "file", }); - expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).mode).toBe("file"); - expect((result?.details as Record).viewerUrl).toBeUndefined(); + expectArtifactOnlyFileResult(screenshotter, result); }); it("honors ttlSeconds for artifact-only file output", async () => { @@ -227,9 +225,7 @@ describe("diffs tool", () => { after: "two\n", }); - expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); - expect((result?.details as Record).mode).toBe("file"); - expect((result?.details as Record).viewerUrl).toBeUndefined(); + expectArtifactOnlyFileResult(screenshotter, result); }); it("falls back to view output when both mode cannot render an image", async () => { @@ -434,6 +430,15 @@ function createToolWithScreenshotter( }); } +function expectArtifactOnlyFileResult( + screenshotter: DiffScreenshotter, + result: { details?: Record } | null | undefined, +) { + expect(screenshotter.screenshotHtml).toHaveBeenCalledTimes(1); + expect((result?.details as Record).mode).toBe("file"); + expect((result?.details as Record).viewerUrl).toBeUndefined(); +} + function createPngScreenshotter( params: { assertHtml?: (html: string) => void; From 41fa63a49ef69058e225b8dcdcfb0626a0b5d74a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:07:06 +0000 Subject: [PATCH 27/94] refactor: share anthropic compat flag checks --- .../anthropic-stream-wrappers.ts | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts index 19b5701eaaa..e04de8a5d6b 100644 --- a/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/anthropic-stream-wrappers.ts @@ -75,6 +75,17 @@ function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier return enabled ? "auto" : "standard_only"; } +function hasOpenAiAnthropicToolPayloadCompatFlag(model: { compat?: unknown }): boolean { + if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { + return false; + } + + return ( + (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) + .requiresOpenAiAnthropicToolPayload === true + ); +} + function requiresAnthropicToolPayloadCompatibilityForModel(model: { api?: unknown; provider?: unknown; @@ -90,15 +101,7 @@ function requiresAnthropicToolPayloadCompatibilityForModel(model: { ) { return true; } - - if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { - return false; - } - - return ( - (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) - .requiresOpenAiAnthropicToolPayload === true - ); + return hasOpenAiAnthropicToolPayloadCompatFlag(model); } function usesOpenAiFunctionAnthropicToolSchemaForModel(model: { @@ -108,13 +111,7 @@ function usesOpenAiFunctionAnthropicToolSchemaForModel(model: { if (typeof model.provider === "string" && usesOpenAiFunctionAnthropicToolSchema(model.provider)) { return true; } - if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { - return false; - } - return ( - (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) - .requiresOpenAiAnthropicToolPayload === true - ); + return hasOpenAiAnthropicToolPayloadCompatFlag(model); } function usesOpenAiStringModeAnthropicToolChoiceForModel(model: { @@ -127,13 +124,7 @@ function usesOpenAiStringModeAnthropicToolChoiceForModel(model: { ) { return true; } - if (!model.compat || typeof model.compat !== "object" || Array.isArray(model.compat)) { - return false; - } - return ( - (model.compat as { requiresOpenAiAnthropicToolPayload?: unknown }) - .requiresOpenAiAnthropicToolPayload === true - ); + return hasOpenAiAnthropicToolPayloadCompatFlag(model); } function normalizeOpenAiFunctionAnthropicToolDefinition( From 1d99401b8bc571fca51a33567a4748343ffaf2bf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:07:51 +0000 Subject: [PATCH 28/94] refactor: share telegram voice send path --- src/telegram/bot/delivery.replies.ts | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/telegram/bot/delivery.replies.ts b/src/telegram/bot/delivery.replies.ts index 5f5edd3b837..6fa6dcca9b0 100644 --- a/src/telegram/bot/delivery.replies.ts +++ b/src/telegram/bot/delivery.replies.ts @@ -345,14 +345,16 @@ async function deliverMediaReply(params: { logFallback: logVerbose, }); if (useVoice) { - await params.onVoiceRecording?.(); - try { + const sendVoiceMedia = async ( + requestParams: typeof mediaParams, + shouldLog?: (err: unknown) => boolean, + ) => { const result = await sendTelegramWithThreadFallback({ operation: "sendVoice", runtime: params.runtime, thread: params.thread, - requestParams: mediaParams, - shouldLog: (err) => !isVoiceMessagesForbidden(err), + requestParams, + shouldLog, send: (effectiveParams) => params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }), }); @@ -360,6 +362,10 @@ async function deliverMediaReply(params: { firstDeliveredMessageId = result.message_id; } markDelivered(params.progress); + }; + await params.onVoiceRecording?.(); + try { + await sendVoiceMedia(mediaParams, (err) => !isVoiceMessagesForbidden(err)); } catch (voiceErr) { if (isVoiceMessagesForbidden(voiceErr)) { const fallbackText = params.reply.text; @@ -400,18 +406,7 @@ async function deliverMediaReply(params: { const noCaptionParams = { ...mediaParams }; delete noCaptionParams.caption; delete noCaptionParams.parse_mode; - const result = await sendTelegramWithThreadFallback({ - operation: "sendVoice", - runtime: params.runtime, - thread: params.thread, - requestParams: noCaptionParams, - send: (effectiveParams) => - params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }), - }); - if (firstDeliveredMessageId == null) { - firstDeliveredMessageId = result.message_id; - } - markDelivered(params.progress); + await sendVoiceMedia(noCaptionParams); const fallbackText = params.reply.text; if (fallbackText?.trim()) { await sendTelegramVoiceFallbackText({ From b4719455bc9b7036e72e5b38dc36d38bbde52bde Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:09:33 +0000 Subject: [PATCH 29/94] test: dedupe gemini oauth project assertions --- .../google-gemini-cli-auth/oauth.test.ts | 40 ++++++++++++------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 1471f804771..68e9cebdd37 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -144,6 +144,13 @@ describe("extractGeminiCliCredentials", () => { } } + function expectFakeCliCredentials(result: unknown) { + expect(result).toEqual({ + clientId: FAKE_CLIENT_ID, + clientSecret: FAKE_CLIENT_SECRET, + }); + } + beforeEach(async () => { vi.clearAllMocks(); originalPath = process.env.PATH; @@ -169,10 +176,7 @@ describe("extractGeminiCliCredentials", () => { clearCredentialsCache(); const result = extractGeminiCliCredentials(); - expect(result).toEqual({ - clientId: FAKE_CLIENT_ID, - clientSecret: FAKE_CLIENT_SECRET, - }); + expectFakeCliCredentials(result); }); it("extracts credentials when PATH entry is an npm global shim", async () => { @@ -182,10 +186,7 @@ describe("extractGeminiCliCredentials", () => { clearCredentialsCache(); const result = extractGeminiCliCredentials(); - expect(result).toEqual({ - clientId: FAKE_CLIENT_ID, - clientSecret: FAKE_CLIENT_SECRET, - }); + expectFakeCliCredentials(result); }); it("returns null when oauth2.js cannot be found", async () => { @@ -304,6 +305,21 @@ describe("loginGeminiCliOAuth", () => { return { result, authUrl }; } + async function runRemoteLoginExpectingProjectId( + loginGeminiCliOAuth: (options: { + isRemote: boolean; + openUrl: () => Promise; + log: (msg: string) => void; + note: () => Promise; + prompt: () => Promise; + progress: { update: () => void; stop: () => void }; + }) => Promise<{ projectId: string }>, + projectId: string, + ) { + const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); + expect(result.projectId).toBe(projectId); + } + let envSnapshot: Partial>; beforeEach(() => { envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); @@ -357,9 +373,7 @@ describe("loginGeminiCliOAuth", () => { vi.stubGlobal("fetch", fetchMock); const { loginGeminiCliOAuth } = await import("./oauth.js"); - const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); - - expect(result.projectId).toBe("daily-project"); + await runRemoteLoginExpectingProjectId(loginGeminiCliOAuth, "daily-project"); const loadRequests = requests.filter((request) => request.url.includes("v1internal:loadCodeAssist"), ); @@ -414,9 +428,7 @@ describe("loginGeminiCliOAuth", () => { vi.stubGlobal("fetch", fetchMock); const { loginGeminiCliOAuth } = await import("./oauth.js"); - const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); - - expect(result.projectId).toBe("env-project"); + await runRemoteLoginExpectingProjectId(loginGeminiCliOAuth, "env-project"); expect(requests.filter((url) => url.includes("v1internal:loadCodeAssist"))).toHaveLength(3); expect(requests.some((url) => url.includes("v1internal:onboardUser"))).toBe(false); }); From ccd763aef7c5ac3dccf4a41b516ff672f291af8d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:10:10 +0000 Subject: [PATCH 30/94] test: dedupe gemini oauth fallback checks --- .../google-gemini-cli-auth/oauth.test.ts | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/extensions/google-gemini-cli-auth/oauth.test.ts b/extensions/google-gemini-cli-auth/oauth.test.ts index 68e9cebdd37..02100b73b1f 100644 --- a/extensions/google-gemini-cli-auth/oauth.test.ts +++ b/extensions/google-gemini-cli-auth/oauth.test.ts @@ -275,16 +275,16 @@ describe("loginGeminiCliOAuth", () => { }); } - async function runRemoteLoginWithCapturedAuthUrl( - loginGeminiCliOAuth: (options: { - isRemote: boolean; - openUrl: () => Promise; - log: (msg: string) => void; - note: () => Promise; - prompt: () => Promise; - progress: { update: () => void; stop: () => void }; - }) => Promise<{ projectId: string }>, - ) { + type LoginGeminiCliOAuthFn = (options: { + isRemote: boolean; + openUrl: () => Promise; + log: (msg: string) => void; + note: () => Promise; + prompt: () => Promise; + progress: { update: () => void; stop: () => void }; + }) => Promise<{ projectId: string }>; + + async function runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth: LoginGeminiCliOAuthFn) { let authUrl = ""; const result = await loginGeminiCliOAuth({ isRemote: true, @@ -306,14 +306,7 @@ describe("loginGeminiCliOAuth", () => { } async function runRemoteLoginExpectingProjectId( - loginGeminiCliOAuth: (options: { - isRemote: boolean; - openUrl: () => Promise; - log: (msg: string) => void; - note: () => Promise; - prompt: () => Promise; - progress: { update: () => void; stop: () => void }; - }) => Promise<{ projectId: string }>, + loginGeminiCliOAuth: LoginGeminiCliOAuthFn, projectId: string, ) { const { result } = await runRemoteLoginWithCapturedAuthUrl(loginGeminiCliOAuth); From 22e976574c5961dbb775268a7e227ac1fb17a579 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:10:48 +0000 Subject: [PATCH 31/94] test: dedupe inbound main scope fixtures --- .../process-message.inbound-contract.test.ts | 56 ++++++++----------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts index ce3c9700d7b..1a02f2d5f93 100644 --- a/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts +++ b/src/web/auto-reply/monitor/process-message.inbound-contract.test.ts @@ -362,13 +362,14 @@ describe("web processMessage inbound contract", () => { expect(updateLastRouteMock).not.toHaveBeenCalled(); }); - it("does not update main last route for non-owner sender when main DM scope is pinned", async () => { - const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); - updateLastRouteMock.mockClear(); - + function makePinnedMainScopeArgs(params: { + groupHistoryKey: string; + messageId: string; + from: string; + }) { const args = makeProcessMessageArgs({ routeSessionKey: "agent:main:main", - groupHistoryKey: "+3000", + groupHistoryKey: params.groupHistoryKey, cfg: { channels: { whatsapp: { @@ -379,12 +380,12 @@ describe("web processMessage inbound contract", () => { session: { store: sessionStorePath, dmScope: "main" }, } as unknown as ReturnType, msg: { - id: "msg-last-route-3", - from: "+3000", + id: params.messageId, + from: params.from, to: "+2000", chatType: "direct", body: "hello", - senderE164: "+3000", + senderE164: params.from, }, }); args.route = { @@ -392,6 +393,18 @@ describe("web processMessage inbound contract", () => { sessionKey: "agent:main:main", mainSessionKey: "agent:main:main", }; + return args; + } + + it("does not update main last route for non-owner sender when main DM scope is pinned", async () => { + const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); + updateLastRouteMock.mockClear(); + + const args = makePinnedMainScopeArgs({ + groupHistoryKey: "+3000", + messageId: "msg-last-route-3", + from: "+3000", + }); await processMessage(args); @@ -402,32 +415,11 @@ describe("web processMessage inbound contract", () => { const updateLastRouteMock = vi.mocked(updateLastRouteInBackground); updateLastRouteMock.mockClear(); - const args = makeProcessMessageArgs({ - routeSessionKey: "agent:main:main", + const args = makePinnedMainScopeArgs({ groupHistoryKey: "+1000", - cfg: { - channels: { - whatsapp: { - allowFrom: ["+1000"], - }, - }, - messages: {}, - session: { store: sessionStorePath, dmScope: "main" }, - } as unknown as ReturnType, - msg: { - id: "msg-last-route-4", - from: "+1000", - to: "+2000", - chatType: "direct", - body: "hello", - senderE164: "+1000", - }, + messageId: "msg-last-route-4", + from: "+1000", }); - args.route = { - ...args.route, - sessionKey: "agent:main:main", - mainSessionKey: "agent:main:main", - }; await processMessage(args); From a5671ea3d85bb6920d92266488c4989a87f5fb78 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:11:44 +0000 Subject: [PATCH 32/94] test: dedupe discord delivery target setup --- extensions/discord/src/subagent-hooks.test.ts | 69 +++++++++---------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts index d58f07c1314..6d5824f69ae 100644 --- a/extensions/discord/src/subagent-hooks.test.ts +++ b/extensions/discord/src/subagent-hooks.test.ts @@ -75,6 +75,27 @@ function getRequiredHandler( return handler; } +function resolveSubagentDeliveryTargetForTest(requesterOrigin: { + channel: string; + accountId: string; + to: string; + threadId?: string; +}) { + const handlers = registerHandlersForTest(); + const handler = getRequiredHandler(handlers, "subagent_delivery_target"); + return handler( + { + childSessionKey: "agent:main:subagent:child", + requesterSessionKey: "agent:main:main", + requesterOrigin, + childRunId: "run-1", + spawnMode: "session", + expectsCompletionMessage: true, + }, + {}, + ); +} + function createSpawnEvent(overrides?: { childSessionKey?: string; agentId?: string; @@ -324,25 +345,12 @@ describe("discord subagent hook handlers", () => { hookMocks.listThreadBindingsBySessionKey.mockReturnValueOnce([ { accountId: "work", threadId: "777" }, ]); - const handlers = registerHandlersForTest(); - const handler = getRequiredHandler(handlers, "subagent_delivery_target"); - - const result = handler( - { - childSessionKey: "agent:main:subagent:child", - requesterSessionKey: "agent:main:main", - requesterOrigin: { - channel: "discord", - accountId: "work", - to: "channel:123", - threadId: "777", - }, - childRunId: "run-1", - spawnMode: "session", - expectsCompletionMessage: true, - }, - {}, - ); + const result = resolveSubagentDeliveryTargetForTest({ + channel: "discord", + accountId: "work", + to: "channel:123", + threadId: "777", + }); expect(hookMocks.listThreadBindingsBySessionKey).toHaveBeenCalledWith({ targetSessionKey: "agent:main:subagent:child", @@ -364,24 +372,11 @@ describe("discord subagent hook handlers", () => { { accountId: "work", threadId: "777" }, { accountId: "work", threadId: "888" }, ]); - const handlers = registerHandlersForTest(); - const handler = getRequiredHandler(handlers, "subagent_delivery_target"); - - const result = handler( - { - childSessionKey: "agent:main:subagent:child", - requesterSessionKey: "agent:main:main", - requesterOrigin: { - channel: "discord", - accountId: "work", - to: "channel:123", - }, - childRunId: "run-1", - spawnMode: "session", - expectsCompletionMessage: true, - }, - {}, - ); + const result = resolveSubagentDeliveryTargetForTest({ + channel: "discord", + accountId: "work", + to: "channel:123", + }); expect(result).toBeUndefined(); }); From bbb52087ede249fa8ead847cced97714e96790a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:14:35 +0000 Subject: [PATCH 33/94] test: dedupe llm task embedded run setup --- extensions/llm-task/src/llm-task-tool.test.ts | 78 +++++++------------ 1 file changed, 29 insertions(+), 49 deletions(-) diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts index fc9f0e07215..2bf0cb655aa 100644 --- a/extensions/llm-task/src/llm-task-tool.test.ts +++ b/extensions/llm-task/src/llm-task-tool.test.ts @@ -29,6 +29,21 @@ function fakeApi(overrides: any = {}) { }; } +function mockEmbeddedRunJson(payload: unknown) { + // oxlint-disable-next-line typescript/no-explicit-any + (runEmbeddedPiAgent as any).mockResolvedValueOnce({ + meta: {}, + payloads: [{ text: JSON.stringify(payload) }], + }); +} + +async function executeEmbeddedRun(input: Record) { + const tool = createLlmTaskTool(fakeApi()); + await tool.execute("id", input); + // oxlint-disable-next-line typescript/no-explicit-any + return (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; +} + describe("llm-task tool (json-only)", () => { beforeEach(() => vi.clearAllMocks()); @@ -96,42 +111,25 @@ describe("llm-task tool (json-only)", () => { }); it("passes provider/model overrides to embedded runner", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ + prompt: "x", + provider: "anthropic", + model: "claude-4-sonnet", }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x", provider: "anthropic", model: "claude-4-sonnet" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; expect(call.provider).toBe("anthropic"); expect(call.model).toBe("claude-4-sonnet"); }); it("passes thinking override to embedded runner", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x", thinking: "high" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ prompt: "x", thinking: "high" }); expect(call.thinkLevel).toBe("high"); }); it("normalizes thinking aliases", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x", thinking: "on" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ prompt: "x", thinking: "on" }); expect(call.thinkLevel).toBe("low"); }); @@ -150,24 +148,13 @@ describe("llm-task tool (json-only)", () => { }); it("does not pass thinkLevel when thinking is omitted", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ prompt: "x" }); expect(call.thinkLevel).toBeUndefined(); }); it("enforces allowedModels", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); + mockEmbeddedRunJson({ ok: true }); const tool = createLlmTaskTool( fakeApi({ pluginConfig: { allowedModels: ["openai-codex/gpt-5.2"] } }), ); @@ -177,15 +164,8 @@ describe("llm-task tool (json-only)", () => { }); it("disables tools for embedded run", async () => { - // oxlint-disable-next-line typescript/no-explicit-any - (runEmbeddedPiAgent as any).mockResolvedValueOnce({ - meta: {}, - payloads: [{ text: JSON.stringify({ ok: true }) }], - }); - const tool = createLlmTaskTool(fakeApi()); - await tool.execute("id", { prompt: "x" }); - // oxlint-disable-next-line typescript/no-explicit-any - const call = (runEmbeddedPiAgent as any).mock.calls[0]?.[0]; + mockEmbeddedRunJson({ ok: true }); + const call = await executeEmbeddedRun({ prompt: "x" }); expect(call.disableTools).toBe(true); }); }); From 7b70fa26e685d249354eae04b66c338169f1d083 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:15:07 +0000 Subject: [PATCH 34/94] test: dedupe discord thread starter setup --- src/discord/monitor/threading.starter.test.ts | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/discord/monitor/threading.starter.test.ts b/src/discord/monitor/threading.starter.test.ts index 07268d7fae9..65e556a55dc 100644 --- a/src/discord/monitor/threading.starter.test.ts +++ b/src/discord/monitor/threading.starter.test.ts @@ -5,27 +5,37 @@ import { resolveDiscordThreadStarter, } from "./threading.js"; +async function resolveStarter( + message: Partial>>, + resolveTimestampMs: () => number | undefined, +) { + const get = vi.fn().mockResolvedValue(message); + const client = { rest: { get } } as unknown as Client; + + return resolveDiscordThreadStarter({ + channel: { id: "thread-1" }, + client, + parentId: "parent-1", + parentType: ChannelType.GuildText, + resolveTimestampMs, + }); +} + describe("resolveDiscordThreadStarter", () => { beforeEach(() => { __resetDiscordThreadStarterCacheForTest(); }); it("falls back to joined embed title and description when content is empty", async () => { - const get = vi.fn().mockResolvedValue({ - content: " ", - embeds: [{ title: "Alert", description: "Details" }], - author: { username: "Alice", discriminator: "0" }, - timestamp: "2026-02-24T12:00:00.000Z", - }); - const client = { rest: { get } } as unknown as Client; - - const result = await resolveDiscordThreadStarter({ - channel: { id: "thread-1" }, - client, - parentId: "parent-1", - parentType: ChannelType.GuildText, - resolveTimestampMs: () => 123, - }); + const result = await resolveStarter( + { + content: " ", + embeds: [{ title: "Alert", description: "Details" }], + author: { username: "Alice", discriminator: "0" }, + timestamp: "2026-02-24T12:00:00.000Z", + }, + () => 123, + ); expect(result).toEqual({ text: "Alert\nDetails", @@ -35,20 +45,14 @@ describe("resolveDiscordThreadStarter", () => { }); it("prefers starter content over embed fallback text", async () => { - const get = vi.fn().mockResolvedValue({ - content: "starter content", - embeds: [{ title: "Alert", description: "Details" }], - author: { username: "Alice", discriminator: "0" }, - }); - const client = { rest: { get } } as unknown as Client; - - const result = await resolveDiscordThreadStarter({ - channel: { id: "thread-1" }, - client, - parentId: "parent-1", - parentType: ChannelType.GuildText, - resolveTimestampMs: () => undefined, - }); + const result = await resolveStarter( + { + content: "starter content", + embeds: [{ title: "Alert", description: "Details" }], + author: { username: "Alice", discriminator: "0" }, + }, + () => undefined, + ); expect(result?.text).toBe("starter content"); }); From 36e9a811cc94e235b8eb6a543e8aad090ee3db56 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:15:54 +0000 Subject: [PATCH 35/94] test: dedupe discord auto thread harness --- .../monitor/threading.auto-thread.test.ts | 145 ++++++------------ 1 file changed, 50 insertions(+), 95 deletions(-) diff --git a/src/discord/monitor/threading.auto-thread.test.ts b/src/discord/monitor/threading.auto-thread.test.ts index 2affabcae44..4759de9df28 100644 --- a/src/discord/monitor/threading.auto-thread.test.ts +++ b/src/discord/monitor/threading.auto-thread.test.ts @@ -2,72 +2,78 @@ import { ChannelType } from "@buape/carbon"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { maybeCreateDiscordAutoThread } from "./threading.js"; +const postMock = vi.fn(); +const getMock = vi.fn(); +const mockClient = { + rest: { post: postMock, get: getMock }, +} as unknown as Parameters[0]["client"]; +const mockMessage = { + id: "msg1", + timestamp: "123", +} as unknown as Parameters[0]["message"]; + +async function runAutoThread( + overrides: Partial[0]> = {}, +) { + return maybeCreateDiscordAutoThread({ + client: mockClient, + message: mockMessage, + messageChannelId: "text1", + isGuildMessage: true, + channelConfig: { allowed: true, autoThread: true }, + channelType: ChannelType.GuildText, + baseText: "test", + combinedBody: "test", + ...overrides, + }); +} + +function expectAutoArchiveDuration(autoArchiveDuration: number) { + expect(postMock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: expect.objectContaining({ auto_archive_duration: autoArchiveDuration }), + }), + ); +} + describe("maybeCreateDiscordAutoThread", () => { - const postMock = vi.fn(); - const getMock = vi.fn(); - const mockClient = { - rest: { post: postMock, get: getMock }, - } as unknown as Parameters[0]["client"]; - const mockMessage = { - id: "msg1", - timestamp: "123", - } as unknown as Parameters[0]["message"]; + beforeEach(() => { + postMock.mockReset(); + getMock.mockReset(); + }); it("skips auto-thread if channelType is GuildForum", async () => { - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, + const result = await runAutoThread({ messageChannelId: "forum1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, channelType: ChannelType.GuildForum, - baseText: "test", - combinedBody: "test", }); expect(result).toBeUndefined(); expect(postMock).not.toHaveBeenCalled(); }); it("skips auto-thread if channelType is GuildMedia", async () => { - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, + const result = await runAutoThread({ messageChannelId: "media1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, channelType: ChannelType.GuildMedia, - baseText: "test", - combinedBody: "test", }); expect(result).toBeUndefined(); expect(postMock).not.toHaveBeenCalled(); }); it("skips auto-thread if channelType is GuildVoice", async () => { - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, + const result = await runAutoThread({ messageChannelId: "voice1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, channelType: ChannelType.GuildVoice, - baseText: "test", - combinedBody: "test", }); expect(result).toBeUndefined(); expect(postMock).not.toHaveBeenCalled(); }); it("skips auto-thread if channelType is GuildStageVoice", async () => { - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, + const result = await runAutoThread({ messageChannelId: "stage1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, channelType: ChannelType.GuildStageVoice, - baseText: "test", - combinedBody: "test", }); expect(result).toBeUndefined(); expect(postMock).not.toHaveBeenCalled(); @@ -75,32 +81,13 @@ describe("maybeCreateDiscordAutoThread", () => { it("creates auto-thread if channelType is GuildText", async () => { postMock.mockResolvedValueOnce({ id: "thread1" }); - const result = await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, - messageChannelId: "text1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, - channelType: ChannelType.GuildText, - baseText: "test", - combinedBody: "test", - }); + const result = await runAutoThread(); expect(result).toBe("thread1"); expect(postMock).toHaveBeenCalled(); }); }); describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => { - const postMock = vi.fn(); - const getMock = vi.fn(); - const mockClient = { - rest: { post: postMock, get: getMock }, - } as unknown as Parameters[0]["client"]; - const mockMessage = { - id: "msg1", - timestamp: "123", - } as unknown as Parameters[0]["message"]; - beforeEach(() => { postMock.mockReset(); getMock.mockReset(); @@ -108,55 +95,23 @@ describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => { it("uses configured autoArchiveDuration", async () => { postMock.mockResolvedValueOnce({ id: "thread1" }); - await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, - messageChannelId: "text1", - isGuildMessage: true, + await runAutoThread({ channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: "10080" }, - channelType: ChannelType.GuildText, - baseText: "test", - combinedBody: "test", }); - expect(postMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ body: expect.objectContaining({ auto_archive_duration: 10080 }) }), - ); + expectAutoArchiveDuration(10080); }); it("accepts numeric autoArchiveDuration", async () => { postMock.mockResolvedValueOnce({ id: "thread1" }); - await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, - messageChannelId: "text1", - isGuildMessage: true, + await runAutoThread({ channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: 4320 }, - channelType: ChannelType.GuildText, - baseText: "test", - combinedBody: "test", }); - expect(postMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ body: expect.objectContaining({ auto_archive_duration: 4320 }) }), - ); + expectAutoArchiveDuration(4320); }); it("defaults to 60 when autoArchiveDuration not set", async () => { postMock.mockResolvedValueOnce({ id: "thread1" }); - await maybeCreateDiscordAutoThread({ - client: mockClient, - message: mockMessage, - messageChannelId: "text1", - isGuildMessage: true, - channelConfig: { allowed: true, autoThread: true }, - channelType: ChannelType.GuildText, - baseText: "test", - combinedBody: "test", - }); - expect(postMock).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ body: expect.objectContaining({ auto_archive_duration: 60 }) }), - ); + await runAutoThread(); + expectAutoArchiveDuration(60); }); }); From b9e5f23914f88c0cc4a10d7a1f87be0cf68761f3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:16:31 +0000 Subject: [PATCH 36/94] test: dedupe route reply slack no-op cases --- src/auto-reply/reply/route-reply.test.ts | 66 +++++++++--------------- 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/src/auto-reply/reply/route-reply.test.ts b/src/auto-reply/reply/route-reply.test.ts index 5a0405da22b..62f91097223 100644 --- a/src/auto-reply/reply/route-reply.test.ts +++ b/src/auto-reply/reply/route-reply.test.ts @@ -105,6 +105,23 @@ const createMSTeamsPlugin = (params: { outbound: ChannelOutboundAdapter }): Chan outbound: params.outbound, }); +async function expectSlackNoSend( + payload: Parameters[0]["payload"], + overrides: Partial[0]> = {}, +) { + mocks.sendMessageSlack.mockClear(); + const res = await routeReply({ + payload, + channel: "slack", + to: "channel:C123", + cfg: {} as never, + ...overrides, + }); + expect(res.ok).toBe(true); + expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + return res; +} + describe("routeReply", () => { beforeEach(() => { setActivePluginRegistry(defaultRegistry); @@ -132,39 +149,15 @@ describe("routeReply", () => { }); it("no-ops on empty payload", async () => { - mocks.sendMessageSlack.mockClear(); - const res = await routeReply({ - payload: {}, - channel: "slack", - to: "channel:C123", - cfg: {} as never, - }); - expect(res.ok).toBe(true); - expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + await expectSlackNoSend({}); }); it("suppresses reasoning payloads", async () => { - mocks.sendMessageSlack.mockClear(); - const res = await routeReply({ - payload: { text: "Reasoning:\n_step_", isReasoning: true }, - channel: "slack", - to: "channel:C123", - cfg: {} as never, - }); - expect(res.ok).toBe(true); - expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + await expectSlackNoSend({ text: "Reasoning:\n_step_", isReasoning: true }); }); it("drops silent token payloads", async () => { - mocks.sendMessageSlack.mockClear(); - const res = await routeReply({ - payload: { text: SILENT_REPLY_TOKEN }, - channel: "slack", - to: "channel:C123", - cfg: {} as never, - }); - expect(res.ok).toBe(true); - expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); + await expectSlackNoSend({ text: SILENT_REPLY_TOKEN }); }); it("does not drop payloads that merely start with the silent token", async () => { @@ -231,23 +224,14 @@ describe("routeReply", () => { }); it("does not bypass the empty-reply guard for invalid Slack blocks", async () => { - mocks.sendMessageSlack.mockClear(); - const res = await routeReply({ - payload: { - text: " ", - channelData: { - slack: { - blocks: " ", - }, + await expectSlackNoSend({ + text: " ", + channelData: { + slack: { + blocks: " ", }, }, - channel: "slack", - to: "channel:C123", - cfg: {} as never, }); - - expect(res.ok).toBe(true); - expect(mocks.sendMessageSlack).not.toHaveBeenCalled(); }); it("does not derive responsePrefix from agent identity when routing", async () => { From 91c94c8b950cbaec5546e5262afbb6312272282a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:17:11 +0000 Subject: [PATCH 37/94] test: dedupe elevated permission assertions --- src/auto-reply/reply/reply-elevated.test.ts | 85 ++++++++++----------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/src/auto-reply/reply/reply-elevated.test.ts b/src/auto-reply/reply/reply-elevated.test.ts index 74fba60acf7..28259c34638 100644 --- a/src/auto-reply/reply/reply-elevated.test.ts +++ b/src/auto-reply/reply/reply-elevated.test.ts @@ -27,68 +27,65 @@ function buildContext(overrides?: Partial): MsgContext { } as MsgContext; } +function expectAllowFromDecision(params: { + allowFrom: string[]; + ctx?: Partial; + allowed: boolean; +}) { + const result = resolveElevatedPermissions({ + cfg: buildConfig(params.allowFrom), + agentId: "main", + provider: "whatsapp", + ctx: buildContext(params.ctx), + }); + + expect(result.enabled).toBe(true); + expect(result.allowed).toBe(params.allowed); + if (params.allowed) { + expect(result.failures).toHaveLength(0); + return; + } + + expect(result.failures).toContainEqual({ + gate: "allowFrom", + key: "tools.elevated.allowFrom.whatsapp", + }); +} + describe("resolveElevatedPermissions", () => { it("authorizes when sender matches allowFrom", () => { - const result = resolveElevatedPermissions({ - cfg: buildConfig(["+15550001111"]), - agentId: "main", - provider: "whatsapp", - ctx: buildContext(), + expectAllowFromDecision({ + allowFrom: ["+15550001111"], + allowed: true, }); - - expect(result.enabled).toBe(true); - expect(result.allowed).toBe(true); - expect(result.failures).toHaveLength(0); }); it("does not authorize when only recipient matches allowFrom", () => { - const result = resolveElevatedPermissions({ - cfg: buildConfig(["+15559990000"]), - agentId: "main", - provider: "whatsapp", - ctx: buildContext(), - }); - - expect(result.enabled).toBe(true); - expect(result.allowed).toBe(false); - expect(result.failures).toContainEqual({ - gate: "allowFrom", - key: "tools.elevated.allowFrom.whatsapp", + expectAllowFromDecision({ + allowFrom: ["+15559990000"], + allowed: false, }); }); it("does not authorize untyped mutable sender fields", () => { - const result = resolveElevatedPermissions({ - cfg: buildConfig(["owner-display-name"]), - agentId: "main", - provider: "whatsapp", - ctx: buildContext({ + expectAllowFromDecision({ + allowFrom: ["owner-display-name"], + allowed: false, + ctx: { SenderName: "owner-display-name", SenderUsername: "owner-display-name", SenderTag: "owner-display-name", - }), - }); - - expect(result.enabled).toBe(true); - expect(result.allowed).toBe(false); - expect(result.failures).toContainEqual({ - gate: "allowFrom", - key: "tools.elevated.allowFrom.whatsapp", + }, }); }); it("authorizes mutable sender fields only with explicit prefix", () => { - const result = resolveElevatedPermissions({ - cfg: buildConfig(["username:owner_username"]), - agentId: "main", - provider: "whatsapp", - ctx: buildContext({ + expectAllowFromDecision({ + allowFrom: ["username:owner_username"], + allowed: true, + ctx: { SenderUsername: "owner_username", - }), + }, }); - - expect(result.enabled).toBe(true); - expect(result.allowed).toBe(true); - expect(result.failures).toHaveLength(0); }); }); From 07b3f5233ea0830820e3c8a8c67df6af1ad28b0e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:17:42 +0000 Subject: [PATCH 38/94] test: dedupe post compaction legacy fallback checks --- .../reply/post-compaction-context.test.ts | 51 +++++++++---------- 1 file changed, 24 insertions(+), 27 deletions(-) diff --git a/src/auto-reply/reply/post-compaction-context.test.ts b/src/auto-reply/reply/post-compaction-context.test.ts index 02a4a27e6de..3af8bceab00 100644 --- a/src/auto-reply/reply/post-compaction-context.test.ts +++ b/src/auto-reply/reply/post-compaction-context.test.ts @@ -15,6 +15,28 @@ describe("readPostCompactionContext", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); + async function expectLegacySectionFallback( + postCompactionSections: string[], + expectDefaultProse = false, + ) { + const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`; + fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); + const cfg = { + agents: { + defaults: { + compaction: { postCompactionSections }, + }, + }, + } as OpenClawConfig; + const result = await readPostCompactionContext(tmpDir, cfg); + expect(result).not.toBeNull(); + expect(result).toContain("Do startup things"); + expect(result).toContain("Be safe"); + if (expectDefaultProse) { + expect(result).toContain("Run your Session Startup sequence"); + } + } + it("returns null when no AGENTS.md exists", async () => { const result = await readPostCompactionContext(tmpDir); expect(result).toBeNull(); @@ -339,36 +361,11 @@ Read WORKFLOW.md on startup. // Older AGENTS.md templates use "Every Session" / "Safety" instead of // "Session Startup" / "Red Lines". Explicitly setting the defaults should // still trigger the legacy fallback — same behavior as leaving the field unset. - const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const cfg = { - agents: { - defaults: { - compaction: { postCompactionSections: ["Session Startup", "Red Lines"] }, - }, - }, - } as OpenClawConfig; - const result = await readPostCompactionContext(tmpDir, cfg); - expect(result).not.toBeNull(); - expect(result).toContain("Do startup things"); - expect(result).toContain("Be safe"); + await expectLegacySectionFallback(["Session Startup", "Red Lines"]); }); it("falls back to legacy sections when default sections are configured in a different order", async () => { - const content = `## Every Session\n\nDo startup things.\n\n## Safety\n\nBe safe.\n`; - fs.writeFileSync(path.join(tmpDir, "AGENTS.md"), content); - const cfg = { - agents: { - defaults: { - compaction: { postCompactionSections: ["Red Lines", "Session Startup"] }, - }, - }, - } as OpenClawConfig; - const result = await readPostCompactionContext(tmpDir, cfg); - expect(result).not.toBeNull(); - expect(result).toContain("Do startup things"); - expect(result).toContain("Be safe"); - expect(result).toContain("Run your Session Startup sequence"); + await expectLegacySectionFallback(["Red Lines", "Session Startup"], true); }); it("custom section names are matched case-insensitively", async () => { From aaea0b2f28c2cbefc655c35a7047acf3a190bc04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:18:35 +0000 Subject: [PATCH 39/94] test: dedupe directive auth ref label setup --- .../reply/directive-handling.auth.test.ts | 89 ++++++++++--------- 1 file changed, 45 insertions(+), 44 deletions(-) diff --git a/src/auto-reply/reply/directive-handling.auth.test.ts b/src/auto-reply/reply/directive-handling.auth.test.ts index 4faad0c3ee6..5e1248c8a61 100644 --- a/src/auto-reply/reply/directive-handling.auth.test.ts +++ b/src/auto-reply/reply/directive-handling.auth.test.ts @@ -4,6 +4,11 @@ import type { OpenClawConfig } from "../../config/config.js"; let mockStore: AuthProfileStore; let mockOrder: string[]; +const githubCopilotTokenRefProfile: AuthProfileStore["profiles"][string] = { + type: "token", + provider: "github-copilot", + tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, +}; vi.mock("../../agents/auth-health.js", () => ({ formatRemainingShort: () => "1h", @@ -39,6 +44,28 @@ vi.mock("../../agents/model-auth.js", () => ({ const { resolveAuthLabel } = await import("./directive-handling.auth.js"); +async function resolveRefOnlyAuthLabel(params: { + provider: string; + profileId: string; + profile: + | (AuthProfileStore["profiles"][string] & { type: "api_key" }) + | (AuthProfileStore["profiles"][string] & { type: "token" }); + mode: "compact" | "verbose"; +}) { + mockStore.profiles = { + [params.profileId]: params.profile, + }; + mockOrder = [params.profileId]; + + return resolveAuthLabel( + params.provider, + {} as OpenClawConfig, + "/tmp/models.json", + undefined, + params.mode, + ); +} + describe("resolveAuthLabel ref-aware labels", () => { beforeEach(() => { mockStore = { @@ -49,64 +76,38 @@ describe("resolveAuthLabel ref-aware labels", () => { }); it("shows api-key (ref) for keyRef-only profiles in compact mode", async () => { - mockStore.profiles = { - "openai:default": { + const result = await resolveRefOnlyAuthLabel({ + provider: "openai", + profileId: "openai:default", + profile: { type: "api_key", provider: "openai", keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, }, - }; - mockOrder = ["openai:default"]; - - const result = await resolveAuthLabel( - "openai", - {} as OpenClawConfig, - "/tmp/models.json", - undefined, - "compact", - ); + mode: "compact", + }); expect(result.label).toBe("openai:default api-key (ref)"); }); it("shows token (ref) for tokenRef-only profiles in compact mode", async () => { - mockStore.profiles = { - "github-copilot:default": { - type: "token", - provider: "github-copilot", - tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, - }, - }; - mockOrder = ["github-copilot:default"]; - - const result = await resolveAuthLabel( - "github-copilot", - {} as OpenClawConfig, - "/tmp/models.json", - undefined, - "compact", - ); + const result = await resolveRefOnlyAuthLabel({ + provider: "github-copilot", + profileId: "github-copilot:default", + profile: githubCopilotTokenRefProfile, + mode: "compact", + }); expect(result.label).toBe("github-copilot:default token (ref)"); }); it("uses token:ref instead of token:missing in verbose mode", async () => { - mockStore.profiles = { - "github-copilot:default": { - type: "token", - provider: "github-copilot", - tokenRef: { source: "env", provider: "default", id: "GITHUB_TOKEN" }, - }, - }; - mockOrder = ["github-copilot:default"]; - - const result = await resolveAuthLabel( - "github-copilot", - {} as OpenClawConfig, - "/tmp/models.json", - undefined, - "verbose", - ); + const result = await resolveRefOnlyAuthLabel({ + provider: "github-copilot", + profileId: "github-copilot:default", + profile: githubCopilotTokenRefProfile, + mode: "verbose", + }); expect(result.label).toContain("github-copilot:default=token:ref"); expect(result.label).not.toContain("token:missing"); From bd758bb43842153f58d47b537633430929be2a54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:19:25 +0000 Subject: [PATCH 40/94] refactor: share abort target apply params --- .../reply/commands-session-abort.ts | 41 +++++++++---------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/auto-reply/reply/commands-session-abort.ts b/src/auto-reply/reply/commands-session-abort.ts index e8abdb845d6..2991ede75cd 100644 --- a/src/auto-reply/reply/commands-session-abort.ts +++ b/src/auto-reply/reply/commands-session-abort.ts @@ -86,6 +86,23 @@ async function applyAbortTarget(params: { } } +function buildAbortTargetApplyParams( + params: Parameters[0], + abortTarget: AbortTarget, +) { + return { + abortTarget, + sessionStore: params.sessionStore, + storePath: params.storePath, + abortKey: params.command.abortKey, + abortCutoff: resolveAbortCutoffForTarget({ + ctx: params.ctx, + commandSessionKey: params.sessionKey, + targetSessionKey: abortTarget.key, + }), + }; +} + export const handleStopCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; @@ -109,17 +126,7 @@ export const handleStopCommand: CommandHandler = async (params, allowTextCommand `stop: cleared followups=${cleared.followupCleared} lane=${cleared.laneCleared} keys=${cleared.keys.join(",")}`, ); } - await applyAbortTarget({ - abortTarget, - sessionStore: params.sessionStore, - storePath: params.storePath, - abortKey: params.command.abortKey, - abortCutoff: resolveAbortCutoffForTarget({ - ctx: params.ctx, - commandSessionKey: params.sessionKey, - targetSessionKey: abortTarget.key, - }), - }); + await applyAbortTarget(buildAbortTargetApplyParams(params, abortTarget)); // Trigger internal hook for stop command const hookEvent = createInternalHookEvent( @@ -160,16 +167,6 @@ export const handleAbortTrigger: CommandHandler = async (params, allowTextComman sessionEntry: params.sessionEntry, sessionStore: params.sessionStore, }); - await applyAbortTarget({ - abortTarget, - sessionStore: params.sessionStore, - storePath: params.storePath, - abortKey: params.command.abortKey, - abortCutoff: resolveAbortCutoffForTarget({ - ctx: params.ctx, - commandSessionKey: params.sessionKey, - targetSessionKey: abortTarget.key, - }), - }); + await applyAbortTarget(buildAbortTargetApplyParams(params, abortTarget)); return { shouldContinue: false, reply: { text: "⚙️ Agent was aborted." } }; }; From da51e40638f266a376227a1a8986272bca3b83b7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:19:57 +0000 Subject: [PATCH 41/94] refactor: share auth label suffix formatting --- .../reply/directive-handling.auth.ts | 60 +++++++++---------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/src/auto-reply/reply/directive-handling.auth.ts b/src/auto-reply/reply/directive-handling.auth.ts index 26647d77c68..604e7473ae8 100644 --- a/src/auto-reply/reply/directive-handling.auth.ts +++ b/src/auto-reply/reply/directive-handling.auth.ts @@ -33,6 +33,22 @@ function resolveStoredCredentialLabel(params: { return "missing"; } +function formatExpirationLabel( + expires: unknown, + now: number, + formatUntil: (timestampMs: number) => string, + compactExpiredPrefix = " expired", +) { + if (typeof expires !== "number" || !Number.isFinite(expires) || expires <= 0) { + return ""; + } + return expires <= now ? compactExpiredPrefix : ` exp ${formatUntil(expires)}`; +} + +function formatFlagsSuffix(flags: string[]) { + return flags.length > 0 ? ` (${flags.join(", ")})` : ""; +} + export const resolveAuthLabel = async ( provider: string, cfg: OpenClawConfig, @@ -89,14 +105,7 @@ export const resolveAuthLabel = async ( refValue: profile.tokenRef, mode, }); - const exp = - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ? profile.expires <= now - ? " expired" - : ` exp ${formatUntil(profile.expires)}` - : ""; + const exp = formatExpirationLabel(profile.expires, now, formatUntil); return { label: `${profileId} token ${tokenLabel}${exp}${more}`, source: "", @@ -104,14 +113,7 @@ export const resolveAuthLabel = async ( } const display = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); const label = display === profileId ? profileId : display; - const exp = - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ? profile.expires <= now - ? " expired" - : ` exp ${formatUntil(profile.expires)}` - : ""; + const exp = formatExpirationLabel(profile.expires, now, formatUntil); return { label: `${label} oauth${exp}${more}`, source: "" }; } @@ -140,7 +142,7 @@ export const resolveAuthLabel = async ( configProfile.mode !== profile.type && !(configProfile.mode === "oauth" && profile.type === "token")) ) { - const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + const suffix = formatFlagsSuffix(flags); return `${profileId}=missing${suffix}`; } if (profile.type === "api_key") { @@ -149,7 +151,7 @@ export const resolveAuthLabel = async ( refValue: profile.keyRef, mode, }); - const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + const suffix = formatFlagsSuffix(flags); return `${profileId}=${keyLabel}${suffix}`; } if (profile.type === "token") { @@ -158,14 +160,11 @@ export const resolveAuthLabel = async ( refValue: profile.tokenRef, mode, }); - if ( - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ) { - flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`); + const expirationFlag = formatExpirationLabel(profile.expires, now, formatUntil, "expired"); + if (expirationFlag) { + flags.push(expirationFlag); } - const suffix = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + const suffix = formatFlagsSuffix(flags); return `${profileId}=token:${tokenLabel}${suffix}`; } const display = resolveAuthProfileDisplayLabel({ @@ -179,15 +178,12 @@ export const resolveAuthLabel = async ( : display.startsWith(profileId) ? display.slice(profileId.length).trim() : `(${display})`; - if ( - typeof profile.expires === "number" && - Number.isFinite(profile.expires) && - profile.expires > 0 - ) { - flags.push(profile.expires <= now ? "expired" : `exp ${formatUntil(profile.expires)}`); + const expirationFlag = formatExpirationLabel(profile.expires, now, formatUntil, "expired"); + if (expirationFlag) { + flags.push(expirationFlag); } const suffixLabel = suffix ? ` ${suffix}` : ""; - const suffixFlags = flags.length > 0 ? ` (${flags.join(", ")})` : ""; + const suffixFlags = formatFlagsSuffix(flags); return `${profileId}=OAuth${suffixLabel}${suffixFlags}`; }); return { From 0f9e16ca463b6efaf89a73279d1a83e05b4f7c9d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:20:56 +0000 Subject: [PATCH 42/94] refactor: share provider chunk context resolution --- src/auto-reply/reply/block-streaming.ts | 54 ++++++++++++------------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 6d306b166c1..b24ee8cac1a 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -26,6 +26,22 @@ function normalizeChunkProvider(provider?: string): TextChunkProvider | undefine : undefined; } +function resolveProviderChunkContext( + cfg: OpenClawConfig | undefined, + provider?: string, + accountId?: string | null, +) { + const providerKey = normalizeChunkProvider(provider); + const providerId = providerKey ? normalizeChannelId(providerKey) : null; + const providerChunkLimit = providerId + ? getChannelDock(providerId)?.outbound?.textChunkLimit + : undefined; + const textLimit = resolveTextChunkLimit(cfg, providerKey, accountId, { + fallbackLimit: providerChunkLimit, + }); + return { providerKey, providerId, textLimit }; +} + type ProviderBlockStreamingConfig = { blockStreamingCoalesce?: BlockStreamingCoalesceConfig; accounts?: Record; @@ -97,14 +113,7 @@ export function resolveEffectiveBlockStreamingConfig(params: { chunking: BlockStreamingChunking; coalescing: BlockStreamingCoalescing; } { - const providerKey = normalizeChunkProvider(params.provider); - const providerId = providerKey ? normalizeChannelId(providerKey) : null; - const providerChunkLimit = providerId - ? getChannelDock(providerId)?.outbound?.textChunkLimit - : undefined; - const textLimit = resolveTextChunkLimit(params.cfg, providerKey, params.accountId, { - fallbackLimit: providerChunkLimit, - }); + const { textLimit } = resolveProviderChunkContext(params.cfg, params.provider, params.accountId); const chunkingDefaults = params.chunking ?? resolveBlockStreamingChunking(params.cfg, params.provider, params.accountId); const chunkingMax = clampPositiveInteger(params.maxChunkChars, chunkingDefaults.maxChars, { @@ -154,21 +163,13 @@ export function resolveBlockStreamingChunking( provider?: string, accountId?: string | null, ): BlockStreamingChunking { - const providerKey = normalizeChunkProvider(provider); - const providerConfigKey = providerKey; - const providerId = providerKey ? normalizeChannelId(providerKey) : null; - const providerChunkLimit = providerId - ? getChannelDock(providerId)?.outbound?.textChunkLimit - : undefined; - const textLimit = resolveTextChunkLimit(cfg, providerConfigKey, accountId, { - fallbackLimit: providerChunkLimit, - }); + const { providerKey, textLimit } = resolveProviderChunkContext(cfg, provider, accountId); const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk; // When chunkMode="newline", the outbound delivery splits on paragraph boundaries. // The block chunker should flush eagerly on \n\n boundaries during streaming, // regardless of minChars, so each paragraph is sent as its own message. - const chunkMode = resolveChunkMode(cfg, providerConfigKey, accountId); + const chunkMode = resolveChunkMode(cfg, providerKey, accountId); const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX)); const maxChars = Math.max(1, Math.min(maxRequested, textLimit)); @@ -198,20 +199,15 @@ export function resolveBlockStreamingCoalescing( }, opts?: { chunkMode?: "length" | "newline" }, ): BlockStreamingCoalescing | undefined { - const providerKey = normalizeChunkProvider(provider); - const providerConfigKey = providerKey; + const { providerKey, providerId, textLimit } = resolveProviderChunkContext( + cfg, + provider, + accountId, + ); // Resolve the outbound chunkMode so the coalescer can flush on paragraph boundaries // when chunkMode="newline", matching the delivery-time splitting behavior. - const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerConfigKey, accountId); - - const providerId = providerKey ? normalizeChannelId(providerKey) : null; - const providerChunkLimit = providerId - ? getChannelDock(providerId)?.outbound?.textChunkLimit - : undefined; - const textLimit = resolveTextChunkLimit(cfg, providerConfigKey, accountId, { - fallbackLimit: providerChunkLimit, - }); + const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerKey, accountId); const providerDefaults = providerId ? getChannelDock(providerId)?.streaming?.blockStreamingCoalesceDefaults : undefined; From 301594b448df8dc0aee1b6a0bf301a2c5a2d7639 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:21:43 +0000 Subject: [PATCH 43/94] refactor: share discord auto thread params --- src/discord/monitor/threading.ts | 55 ++++++++++++++++---------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index 28897e9b7aa..7fc96225330 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -89,6 +89,18 @@ function isDiscordThreadType(type: ChannelType | undefined): boolean { ); } +function resolveTrimmedDiscordMessageChannelId(params: { + message: DiscordMessageEvent["message"]; + messageChannelId?: string; +}) { + return ( + params.messageChannelId || + resolveDiscordMessageChannelId({ + message: params.message, + }) + ).trim(); +} + export function resolveDiscordThreadChannel(params: { isGuildMessage: boolean; message: DiscordMessageEvent["message"]; @@ -301,7 +313,7 @@ export type DiscordAutoThreadReplyPlan = DiscordReplyDeliveryPlan & { autoThreadContext: DiscordAutoThreadContext | null; }; -export async function resolveDiscordAutoThreadReplyPlan(params: { +type MaybeCreateDiscordAutoThreadParams = { client: Client; message: DiscordMessageEvent["message"]; messageChannelId?: string; @@ -311,16 +323,16 @@ export async function resolveDiscordAutoThreadReplyPlan(params: { channelType?: ChannelType; baseText: string; combinedBody: string; - replyToMode: ReplyToMode; - agentId: string; - channel: string; -}): Promise { - const messageChannelId = ( - params.messageChannelId || - resolveDiscordMessageChannelId({ - message: params.message, - }) - ).trim(); +}; + +export async function resolveDiscordAutoThreadReplyPlan( + params: MaybeCreateDiscordAutoThreadParams & { + replyToMode: ReplyToMode; + agentId: string; + channel: string; + }, +): Promise { + const messageChannelId = resolveTrimmedDiscordMessageChannelId(params); // Prefer the resolved thread channel ID when available so replies stay in-thread. const targetChannelId = params.threadChannel?.id ?? (messageChannelId || "unknown"); const originalReplyTarget = `channel:${targetChannelId}`; @@ -353,17 +365,9 @@ export async function resolveDiscordAutoThreadReplyPlan(params: { return { ...deliveryPlan, createdThreadId, autoThreadContext }; } -export async function maybeCreateDiscordAutoThread(params: { - client: Client; - message: DiscordMessageEvent["message"]; - messageChannelId?: string; - isGuildMessage: boolean; - channelConfig?: DiscordChannelConfigResolved | null; - threadChannel?: DiscordThreadChannel | null; - channelType?: ChannelType; - baseText: string; - combinedBody: string; -}): Promise { +export async function maybeCreateDiscordAutoThread( + params: MaybeCreateDiscordAutoThreadParams, +): Promise { if (!params.isGuildMessage) { return undefined; } @@ -383,12 +387,7 @@ export async function maybeCreateDiscordAutoThread(params: { return undefined; } - const messageChannelId = ( - params.messageChannelId || - resolveDiscordMessageChannelId({ - message: params.message, - }) - ).trim(); + const messageChannelId = resolveTrimmedDiscordMessageChannelId(params); if (!messageChannelId) { return undefined; } From 97ce1503fd7ce34bd88e9174f65f01a6f49b576a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:22:14 +0000 Subject: [PATCH 44/94] refactor: share discord binding update loop --- .../monitor/thread-bindings.lifecycle.ts | 80 ++++++++----------- 1 file changed, 35 insertions(+), 45 deletions(-) diff --git a/src/discord/monitor/thread-bindings.lifecycle.ts b/src/discord/monitor/thread-bindings.lifecycle.ts index 256ab5e249c..faf5603c48d 100644 --- a/src/discord/monitor/thread-bindings.lifecycle.ts +++ b/src/discord/monitor/thread-bindings.lifecycle.ts @@ -98,6 +98,30 @@ function resolveBindingIdsForTargetSession(params: { }); } +function updateBindingsForTargetSession( + ids: string[], + update: (existing: ThreadBindingRecord, now: number) => ThreadBindingRecord, +) { + if (ids.length === 0) { + return []; + } + const now = Date.now(); + const updated: ThreadBindingRecord[] = []; + for (const bindingKey of ids) { + const existing = BINDINGS_BY_THREAD_ID.get(bindingKey); + if (!existing) { + continue; + } + const nextRecord = update(existing, now); + setBindingRecord(nextRecord); + updated.push(nextRecord); + } + if (updated.length > 0 && shouldPersistBindingMutations()) { + saveBindingsToDisk({ force: true }); + } + return updated; +} + export function listThreadBindingsForAccount(accountId?: string): ThreadBindingRecord[] { const manager = getThreadBindingManager(accountId); if (!manager) { @@ -249,29 +273,12 @@ export function setThreadBindingIdleTimeoutBySessionKey(params: { idleTimeoutMs: number; }): ThreadBindingRecord[] { const ids = resolveBindingIdsForTargetSession(params); - if (ids.length === 0) { - return []; - } const idleTimeoutMs = normalizeNonNegativeMs(params.idleTimeoutMs); - const now = Date.now(); - const updated: ThreadBindingRecord[] = []; - for (const bindingKey of ids) { - const existing = BINDINGS_BY_THREAD_ID.get(bindingKey); - if (!existing) { - continue; - } - const nextRecord: ThreadBindingRecord = { - ...existing, - idleTimeoutMs, - lastActivityAt: now, - }; - setBindingRecord(nextRecord); - updated.push(nextRecord); - } - if (updated.length > 0 && shouldPersistBindingMutations()) { - saveBindingsToDisk({ force: true }); - } - return updated; + return updateBindingsForTargetSession(ids, (existing, now) => ({ + ...existing, + idleTimeoutMs, + lastActivityAt: now, + })); } export function setThreadBindingMaxAgeBySessionKey(params: { @@ -280,30 +287,13 @@ export function setThreadBindingMaxAgeBySessionKey(params: { maxAgeMs: number; }): ThreadBindingRecord[] { const ids = resolveBindingIdsForTargetSession(params); - if (ids.length === 0) { - return []; - } const maxAgeMs = normalizeNonNegativeMs(params.maxAgeMs); - const now = Date.now(); - const updated: ThreadBindingRecord[] = []; - for (const bindingKey of ids) { - const existing = BINDINGS_BY_THREAD_ID.get(bindingKey); - if (!existing) { - continue; - } - const nextRecord: ThreadBindingRecord = { - ...existing, - maxAgeMs, - boundAt: now, - lastActivityAt: now, - }; - setBindingRecord(nextRecord); - updated.push(nextRecord); - } - if (updated.length > 0 && shouldPersistBindingMutations()) { - saveBindingsToDisk({ force: true }); - } - return updated; + return updateBindingsForTargetSession(ids, (existing, now) => ({ + ...existing, + maxAgeMs, + boundAt: now, + lastActivityAt: now, + })); } function resolveStoredAcpBindingHealth(params: { From 1b91fa9358115eec19aeb5c8f191a11b6cfa8ac7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:23:02 +0000 Subject: [PATCH 45/94] test: dedupe discord route fixture setup --- src/discord/monitor/route-resolution.test.ts | 55 +++++++++----------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/src/discord/monitor/route-resolution.test.ts b/src/discord/monitor/route-resolution.test.ts index d9ec90177bd..3518355165b 100644 --- a/src/discord/monitor/route-resolution.test.ts +++ b/src/discord/monitor/route-resolution.test.ts @@ -2,12 +2,33 @@ import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; import { - resolveDiscordBoundConversationRoute, buildDiscordRoutePeer, + resolveDiscordBoundConversationRoute, resolveDiscordConversationRoute, resolveDiscordEffectiveRoute, } from "./route-resolution.js"; +function buildWorkerBindingConfig(peer: { + kind: "channel" | "direct"; + id: string; +}): OpenClawConfig { + return { + agents: { + list: [{ id: "worker" }], + }, + bindings: [ + { + agentId: "worker", + match: { + channel: "discord", + accountId: "default", + peer, + }, + }, + ], + }; +} + describe("discord route resolution helpers", () => { it("builds a direct peer from DM metadata", () => { expect( @@ -78,21 +99,7 @@ describe("discord route resolution helpers", () => { }); it("resolves the same route shape as the inline Discord route inputs", () => { - const cfg: OpenClawConfig = { - agents: { - list: [{ id: "worker" }], - }, - bindings: [ - { - agentId: "worker", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "channel", id: "c1" }, - }, - }, - ], - }; + const cfg = buildWorkerBindingConfig({ kind: "channel", id: "c1" }); expect( resolveDiscordConversationRoute({ @@ -110,21 +117,7 @@ describe("discord route resolution helpers", () => { }); it("composes route building with effective-route overrides", () => { - const cfg: OpenClawConfig = { - agents: { - list: [{ id: "worker" }], - }, - bindings: [ - { - agentId: "worker", - match: { - channel: "discord", - accountId: "default", - peer: { kind: "direct", id: "user-1" }, - }, - }, - ], - }; + const cfg = buildWorkerBindingConfig({ kind: "direct", id: "user-1" }); expect( resolveDiscordBoundConversationRoute({ From c59ae1527cae86dfc0aa36f7305e5bbb025edb4b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:24:03 +0000 Subject: [PATCH 46/94] refactor: share discord trailing media delivery --- src/discord/monitor/reply-delivery.ts | 36 +++++++++++---------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index fb235ca65d0..d34381454e9 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -336,6 +336,18 @@ export async function deliverDiscordReply(params: { if (!firstMedia) { continue; } + const sendRemainingMedia = () => + sendAdditionalDiscordMedia({ + cfg: params.cfg, + target: params.target, + token: params.token, + rest: params.rest, + accountId: params.accountId, + mediaUrls: mediaList.slice(1), + mediaLocalRoots: params.mediaLocalRoots, + resolveReplyTo, + retryConfig, + }); // Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord. if (payload.audioAsVoice) { @@ -367,17 +379,7 @@ export async function deliverDiscordReply(params: { retryConfig, }); // Additional media items are sent as regular attachments (voice is single-file only). - await sendAdditionalDiscordMedia({ - cfg: params.cfg, - target: params.target, - token: params.token, - rest: params.rest, - accountId: params.accountId, - mediaUrls: mediaList.slice(1), - mediaLocalRoots: params.mediaLocalRoots, - resolveReplyTo, - retryConfig, - }); + await sendRemainingMedia(); continue; } @@ -392,17 +394,7 @@ export async function deliverDiscordReply(params: { replyTo, }); deliveredAny = true; - await sendAdditionalDiscordMedia({ - cfg: params.cfg, - target: params.target, - token: params.token, - rest: params.rest, - accountId: params.accountId, - mediaUrls: mediaList.slice(1), - mediaLocalRoots: params.mediaLocalRoots, - resolveReplyTo, - retryConfig, - }); + await sendRemainingMedia(); } if (binding && deliveredAny) { From 8cd48c289660e801488c42f84766e08b92cc502a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:24:54 +0000 Subject: [PATCH 47/94] test: dedupe model info reply setup --- .../reply/directive-handling.model.test.ts | 48 ++++++++----------- 1 file changed, 21 insertions(+), 27 deletions(-) diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index 5d4a23f3efb..b815ecfc9b9 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -57,24 +57,28 @@ function resolveModelSelectionForCommand(params: { }); } +async function resolveModelInfoReply( + overrides: Partial[0]> = {}, +) { + return maybeHandleModelDirectiveInfo({ + directives: parseInlineDirectives("/model"), + cfg: baseConfig(), + agentDir: "/tmp/agent", + activeAgentId: "main", + provider: "anthropic", + model: "claude-opus-4-5", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-5", + aliasIndex: baseAliasIndex(), + allowedModelCatalog: [], + resetModelOverride: false, + ...overrides, + }); +} + describe("/model chat UX", () => { it("shows summary for /model with no args", async () => { - const directives = parseInlineDirectives("/model"); - const cfg = { commands: { text: true } } as unknown as OpenClawConfig; - - const reply = await maybeHandleModelDirectiveInfo({ - directives, - cfg, - agentDir: "/tmp/agent", - activeAgentId: "main", - provider: "anthropic", - model: "claude-opus-4-5", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-5", - aliasIndex: baseAliasIndex(), - allowedModelCatalog: [], - resetModelOverride: false, - }); + const reply = await resolveModelInfoReply(); expect(reply?.text).toContain("Current:"); expect(reply?.text).toContain("Browse: /models"); @@ -82,21 +86,11 @@ describe("/model chat UX", () => { }); it("shows active runtime model when different from selected model", async () => { - const directives = parseInlineDirectives("/model"); - const cfg = { commands: { text: true } } as unknown as OpenClawConfig; - - const reply = await maybeHandleModelDirectiveInfo({ - directives, - cfg, - agentDir: "/tmp/agent", - activeAgentId: "main", + const reply = await resolveModelInfoReply({ provider: "fireworks", model: "fireworks/minimax-m2p5", defaultProvider: "fireworks", defaultModel: "fireworks/minimax-m2p5", - aliasIndex: baseAliasIndex(), - allowedModelCatalog: [], - resetModelOverride: false, sessionEntry: { modelProvider: "deepinfra", model: "moonshotai/Kimi-K2.5", From cad1c95405c47a98a5d55cdca1229e1f78308fe6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:26:03 +0000 Subject: [PATCH 48/94] test: dedupe inline action skip assertions --- ...ine-actions.skip-when-config-empty.test.ts | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index 51351f05de8..36b5910ecae 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -84,6 +84,19 @@ const createHandleInlineActionsInput = (params: { }; }; +async function expectInlineActionSkipped(params: { + ctx: ReturnType; + typing: TypingController; + cleanedBody: string; + command?: Partial; + overrides?: Partial>; +}) { + const result = await handleInlineActions(createHandleInlineActionsInput(params)); + expect(result).toEqual({ kind: "reply", reply: undefined }); + expect(params.typing.cleanup).toHaveBeenCalled(); + expect(handleCommandsMock).not.toHaveBeenCalled(); +} + describe("handleInlineActions", () => { beforeEach(() => { handleCommandsMock.mockReset(); @@ -97,18 +110,12 @@ describe("handleInlineActions", () => { To: "whatsapp:+123", Body: "hi", }); - const result = await handleInlineActions( - createHandleInlineActionsInput({ - ctx, - typing, - cleanedBody: "hi", - command: { to: "whatsapp:+123" }, - }), - ); - - expect(result).toEqual({ kind: "reply", reply: undefined }); - expect(typing.cleanup).toHaveBeenCalled(); - expect(handleCommandsMock).not.toHaveBeenCalled(); + await expectInlineActionSkipped({ + ctx, + typing, + cleanedBody: "hi", + command: { to: "whatsapp:+123" }, + }); }); it("forwards agentDir into handleCommands", async () => { @@ -163,25 +170,19 @@ describe("handleInlineActions", () => { MessageSid: "41", }); - const result = await handleInlineActions( - createHandleInlineActionsInput({ - ctx, - typing, - cleanedBody: "old queued message", - command: { - rawBodyNormalized: "old queued message", - commandBodyNormalized: "old queued message", - }, - overrides: { - sessionEntry, - sessionStore, - }, - }), - ); - - expect(result).toEqual({ kind: "reply", reply: undefined }); - expect(typing.cleanup).toHaveBeenCalled(); - expect(handleCommandsMock).not.toHaveBeenCalled(); + await expectInlineActionSkipped({ + ctx, + typing, + cleanedBody: "old queued message", + command: { + rawBodyNormalized: "old queued message", + commandBodyNormalized: "old queued message", + }, + overrides: { + sessionEntry, + sessionStore, + }, + }); }); it("clears /stop cutoff when a newer message arrives", async () => { From 3eb039c554c0ba7057be243a907d1951af0a794c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:26:24 +0000 Subject: [PATCH 49/94] test: dedupe discord forwarded media assertions --- src/discord/monitor/message-utils.test.ts | 27 +++++------------------ 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/src/discord/monitor/message-utils.test.ts b/src/discord/monitor/message-utils.test.ts index f4c5be256c1..acb9708ae21 100644 --- a/src/discord/monitor/message-utils.test.ts +++ b/src/discord/monitor/message-utils.test.ts @@ -173,30 +173,13 @@ describe("resolveForwardedMediaList", () => { 512, ); - expect(fetchRemoteMedia).toHaveBeenCalledTimes(1); - const call = fetchRemoteMedia.mock.calls[0]?.[0] as { - url?: string; - filePathHint?: string; - maxBytes?: number; - fetchImpl?: unknown; - ssrfPolicy?: unknown; - }; - expect(call).toMatchObject({ - url: attachment.url, + expectSinglePngDownload({ + result, + expectedUrl: attachment.url, filePathHint: attachment.filename, - maxBytes: 512, - fetchImpl: undefined, + expectedPath: "/tmp/image.png", + placeholder: "", }); - expectDiscordCdnSsrFPolicy(call.ssrfPolicy); - expect(saveMediaBuffer).toHaveBeenCalledTimes(1); - expect(saveMediaBuffer).toHaveBeenCalledWith(expect.any(Buffer), "image/png", "inbound", 512); - expect(result).toEqual([ - { - path: "/tmp/image.png", - contentType: "image/png", - placeholder: "", - }, - ]); }); it("forwards fetchImpl to forwarded attachment downloads", async () => { From ee80b4be69e122a787e736c5dc0748694e813f71 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:27:33 +0000 Subject: [PATCH 50/94] test: dedupe discord retry delivery setup --- src/discord/monitor/reply-delivery.test.ts | 48 ++++++++-------------- 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/src/discord/monitor/reply-delivery.test.ts b/src/discord/monitor/reply-delivery.test.ts index 1e0bdc00942..6f6b7fcaaaf 100644 --- a/src/discord/monitor/reply-delivery.test.ts +++ b/src/discord/monitor/reply-delivery.test.ts @@ -27,6 +27,22 @@ describe("deliverDiscordReply", () => { const cfg = { channels: { discord: { token: "test-token" } }, } as OpenClawConfig; + const expectBotSendRetrySuccess = async (status: number, message: string) => { + sendMessageDiscordMock + .mockRejectedValueOnce(Object.assign(new Error(message), { status })) + .mockResolvedValueOnce({ messageId: "msg-1", channelId: "channel-1" }); + + await deliverDiscordReply({ + replies: [{ text: "retry me" }], + target: "channel:123", + token: "token", + runtime, + cfg, + textLimit: 2000, + }); + + expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2); + }; const createBoundThreadBindings = async ( overrides: Partial<{ threadId: string; @@ -319,39 +335,11 @@ describe("deliverDiscordReply", () => { }); it("retries bot send on 429 rate limit then succeeds", async () => { - const rateLimitErr = Object.assign(new Error("rate limited"), { status: 429 }); - sendMessageDiscordMock - .mockRejectedValueOnce(rateLimitErr) - .mockResolvedValueOnce({ messageId: "msg-1", channelId: "channel-1" }); - - await deliverDiscordReply({ - replies: [{ text: "retry me" }], - target: "channel:123", - token: "token", - runtime, - cfg, - textLimit: 2000, - }); - - expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2); + await expectBotSendRetrySuccess(429, "rate limited"); }); it("retries bot send on 500 server error then succeeds", async () => { - const serverErr = Object.assign(new Error("internal"), { status: 500 }); - sendMessageDiscordMock - .mockRejectedValueOnce(serverErr) - .mockResolvedValueOnce({ messageId: "msg-1", channelId: "channel-1" }); - - await deliverDiscordReply({ - replies: [{ text: "retry me" }], - target: "channel:123", - token: "token", - runtime, - cfg, - textLimit: 2000, - }); - - expect(sendMessageDiscordMock).toHaveBeenCalledTimes(2); + await expectBotSendRetrySuccess(500, "internal"); }); it("does not retry on 4xx client errors", async () => { From aed626ed96473f02de386e7fc8b16a2280c0ddc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:27:43 +0000 Subject: [PATCH 51/94] test: dedupe discord gateway proxy register flow --- src/discord/monitor/provider.proxy.test.ts | 38 ++++++++-------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/discord/monitor/provider.proxy.test.ts b/src/discord/monitor/provider.proxy.test.ts index 9a15dcef94b..72da5136c7a 100644 --- a/src/discord/monitor/provider.proxy.test.ts +++ b/src/discord/monitor/provider.proxy.test.ts @@ -147,6 +147,18 @@ describe("createDiscordGatewayPlugin", () => { expect(baseRegisterClientSpy).not.toHaveBeenCalled(); } + async function registerGatewayClientWithMetadata(params: { + plugin: unknown; + fetchMock: typeof globalFetchMock; + }) { + params.fetchMock.mockResolvedValue({ + ok: true, + status: 200, + text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), + } as Response); + await registerGatewayClient(params.plugin); + } + beforeEach(() => { vi.stubGlobal("fetch", globalFetchMock); baseRegisterClientSpy.mockClear(); @@ -161,23 +173,12 @@ describe("createDiscordGatewayPlugin", () => { it("uses safe gateway metadata lookup without proxy", async () => { const runtime = createRuntime(); - globalFetchMock.mockResolvedValue({ - ok: true, - status: 200, - text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), - } as Response); const plugin = createDiscordGatewayPlugin({ discordConfig: {}, runtime, }); - await ( - plugin as unknown as { - registerClient: (client: { options: { token: string } }) => Promise; - } - ).registerClient({ - options: { token: "token-123" }, - }); + await registerGatewayClientWithMetadata({ plugin, fetchMock: globalFetchMock }); expect(globalFetchMock).toHaveBeenCalledWith( "https://discord.com/api/v10/gateway/bot", @@ -235,23 +236,12 @@ describe("createDiscordGatewayPlugin", () => { it("uses proxy fetch for gateway metadata lookup before registering", async () => { const runtime = createRuntime(); - undiciFetchMock.mockResolvedValue({ - ok: true, - status: 200, - text: async () => JSON.stringify({ url: "wss://gateway.discord.gg" }), - } as Response); const plugin = createDiscordGatewayPlugin({ discordConfig: { proxy: "http://proxy.test:8080" }, runtime, }); - await ( - plugin as unknown as { - registerClient: (client: { options: { token: string } }) => Promise; - } - ).registerClient({ - options: { token: "token-123" }, - }); + await registerGatewayClientWithMetadata({ plugin, fetchMock: undiciFetchMock }); expect(restProxyAgentSpy).toHaveBeenCalledWith("http://proxy.test:8080"); expect(undiciFetchMock).toHaveBeenCalledWith( From 809785dcd79d712e1847d3e8e7879a587b9b8c4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:28:23 +0000 Subject: [PATCH 52/94] test: dedupe discord provider account config harness --- src/discord/monitor/provider.test.ts | 92 +++++++++++++--------------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/src/discord/monitor/provider.test.ts b/src/discord/monitor/provider.test.ts index 91f61a7ce1f..8fdab085f53 100644 --- a/src/discord/monitor/provider.test.ts +++ b/src/discord/monitor/provider.test.ts @@ -16,6 +16,15 @@ type PluginCommandSpecMock = { acceptsArgs: boolean; }; +function baseDiscordAccountConfig() { + return { + commands: { native: true, nativeSkills: false }, + voice: { enabled: false }, + agentComponents: { enabled: false }, + execApprovals: { enabled: false }, + }; +} + const { clientFetchUserMock, clientGetPluginMock, @@ -91,12 +100,7 @@ const { resolveDiscordAccountMock: vi.fn(() => ({ accountId: "default", token: "cfg-token", - config: { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - }, + config: baseDiscordAccountConfig(), })), resolveDiscordAllowlistConfigMock: vi.fn(async () => ({ guildEntries: undefined, @@ -108,6 +112,23 @@ const { }; }); +function mockResolvedDiscordAccountConfig(overrides: Record) { + resolveDiscordAccountMock.mockImplementation(() => ({ + accountId: "default", + token: "cfg-token", + config: { + ...baseDiscordAccountConfig(), + ...overrides, + }, + })); +} + +function getFirstDiscordMessageHandlerParams() { + expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); + const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as [T] | undefined; + return firstCall?.[0]; +} + vi.mock("@buape/carbon", () => { class ReadyListener {} class Client { @@ -663,17 +684,9 @@ describe("monitorDiscordProvider", () => { it("forwards custom eventQueue config from discord config to Carbon Client", async () => { const { monitorDiscordProvider } = await import("./provider.js"); - resolveDiscordAccountMock.mockImplementation(() => ({ - accountId: "default", - token: "cfg-token", - config: { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - eventQueue: { listenerTimeout: 300_000 }, - }, - })); + mockResolvedDiscordAccountConfig({ + eventQueue: { listenerTimeout: 300_000 }, + }); await monitorDiscordProvider({ config: baseConfig(), @@ -687,28 +700,19 @@ describe("monitorDiscordProvider", () => { it("does not reuse eventQueue.listenerTimeout as the queued inbound worker timeout", async () => { const { monitorDiscordProvider } = await import("./provider.js"); - resolveDiscordAccountMock.mockImplementation(() => ({ - accountId: "default", - token: "cfg-token", - config: { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - eventQueue: { listenerTimeout: 50_000 }, - }, - })); + mockResolvedDiscordAccountConfig({ + eventQueue: { listenerTimeout: 50_000 }, + }); await monitorDiscordProvider({ config: baseConfig(), runtime: baseRuntime(), }); - expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); - const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as - | [{ workerRunTimeoutMs?: number; listenerTimeoutMs?: number }] - | undefined; - const params = firstCall?.[0]; + const params = getFirstDiscordMessageHandlerParams<{ + workerRunTimeoutMs?: number; + listenerTimeoutMs?: number; + }>(); expect(params?.workerRunTimeoutMs).toBeUndefined(); expect("listenerTimeoutMs" in (params ?? {})).toBe(false); }); @@ -716,28 +720,18 @@ describe("monitorDiscordProvider", () => { it("forwards inbound worker timeout config to the Discord message handler", async () => { const { monitorDiscordProvider } = await import("./provider.js"); - resolveDiscordAccountMock.mockImplementation(() => ({ - accountId: "default", - token: "cfg-token", - config: { - commands: { native: true, nativeSkills: false }, - voice: { enabled: false }, - agentComponents: { enabled: false }, - execApprovals: { enabled: false }, - inboundWorker: { runTimeoutMs: 300_000 }, - }, - })); + mockResolvedDiscordAccountConfig({ + inboundWorker: { runTimeoutMs: 300_000 }, + }); await monitorDiscordProvider({ config: baseConfig(), runtime: baseRuntime(), }); - expect(createDiscordMessageHandlerMock).toHaveBeenCalledTimes(1); - const firstCall = createDiscordMessageHandlerMock.mock.calls.at(0) as - | [{ workerRunTimeoutMs?: number }] - | undefined; - const params = firstCall?.[0]; + const params = getFirstDiscordMessageHandlerParams<{ + workerRunTimeoutMs?: number; + }>(); expect(params?.workerRunTimeoutMs).toBe(300_000); }); From f8ee528174351058d99db609ecdddfe9a833420f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:29:10 +0000 Subject: [PATCH 53/94] refactor: share discord channel override config type --- src/discord/monitor/allow-list.ts | 44 +++++++++++-------------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 583d4fa7cd2..ef29f1fc706 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -19,34 +19,7 @@ export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | const DISCORD_OWNER_ALLOWLIST_PREFIXES = ["discord:", "user:", "pk:"]; -export type DiscordGuildEntryResolved = { - id?: string; - slug?: string; - requireMention?: boolean; - ignoreOtherMentions?: boolean; - reactionNotifications?: "off" | "own" | "all" | "allowlist"; - users?: string[]; - roles?: string[]; - channels?: Record< - string, - { - allow?: boolean; - requireMention?: boolean; - ignoreOtherMentions?: boolean; - skills?: string[]; - enabled?: boolean; - users?: string[]; - roles?: string[]; - systemPrompt?: string; - includeThreadStarter?: boolean; - autoThread?: boolean; - autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; - } - >; -}; - -export type DiscordChannelConfigResolved = { - allowed: boolean; +type DiscordChannelOverrideConfig = { requireMention?: boolean; ignoreOtherMentions?: boolean; skills?: string[]; @@ -57,6 +30,21 @@ export type DiscordChannelConfigResolved = { includeThreadStarter?: boolean; autoThread?: boolean; autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080; +}; + +export type DiscordGuildEntryResolved = { + id?: string; + slug?: string; + requireMention?: boolean; + ignoreOtherMentions?: boolean; + reactionNotifications?: "off" | "own" | "all" | "allowlist"; + users?: string[]; + roles?: string[]; + channels?: Record; +}; + +export type DiscordChannelConfigResolved = DiscordChannelOverrideConfig & { + allowed: boolean; matchKey?: string; matchSource?: ChannelMatchSource; }; From 801113b46aed291cb2c15c4bb387962997a5bc4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:29:45 +0000 Subject: [PATCH 54/94] refactor: share session entry persistence update --- src/auto-reply/reply/session-updates.ts | 35 ++++++++++++++++--------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 96243e919bb..55b4d4eb15b 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -117,6 +117,27 @@ export async function drainFormattedSystemEvents(params: { .join("\n"); } +async function persistSessionEntryUpdate(params: { + sessionStore?: Record; + sessionKey?: string; + storePath?: string; + nextEntry: SessionEntry; +}) { + if (!params.sessionStore || !params.sessionKey) { + return; + } + params.sessionStore[params.sessionKey] = { + ...params.sessionStore[params.sessionKey], + ...params.nextEntry, + }; + if (!params.storePath) { + return; + } + await updateSessionStore(params.storePath, (store) => { + store[params.sessionKey!] = { ...store[params.sessionKey!], ...params.nextEntry }; + }); +} + export async function ensureSkillSnapshot(params: { sessionEntry?: SessionEntry; sessionStore?: Record; @@ -185,12 +206,7 @@ export async function ensureSkillSnapshot(params: { systemSent: true, skillsSnapshot: skillSnapshot, }; - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; - if (storePath) { - await updateSessionStore(storePath, (store) => { - store[sessionKey] = { ...store[sessionKey], ...nextEntry }; - }); - } + await persistSessionEntryUpdate({ sessionStore, sessionKey, storePath, nextEntry }); systemSent = true; } @@ -227,12 +243,7 @@ export async function ensureSkillSnapshot(params: { updatedAt: Date.now(), skillsSnapshot, }; - sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry }; - if (storePath) { - await updateSessionStore(storePath, (store) => { - store[sessionKey] = { ...store[sessionKey], ...nextEntry }; - }); - } + await persistSessionEntryUpdate({ sessionStore, sessionKey, storePath, nextEntry }); } return { sessionEntry: nextEntry, skillsSnapshot, systemSent }; From 58a51e2746e85e14e0aeb035897aaaa1cbad10a8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:30:01 +0000 Subject: [PATCH 55/94] refactor: share discord preflight shared fields --- .../monitor/message-handler.preflight.types.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/discord/monitor/message-handler.preflight.types.ts b/src/discord/monitor/message-handler.preflight.types.ts index a2b3c210a1c..015a695229a 100644 --- a/src/discord/monitor/message-handler.preflight.types.ts +++ b/src/discord/monitor/message-handler.preflight.types.ts @@ -16,7 +16,7 @@ export type RuntimeEnv = import("../../runtime.js").RuntimeEnv; export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; -export type DiscordMessagePreflightContext = { +type DiscordMessagePreflightSharedFields = { cfg: LoadedConfig; discordConfig: NonNullable< import("../../config/config.js").OpenClawConfig["channels"] @@ -33,7 +33,9 @@ export type DiscordMessagePreflightContext = { replyToMode: ReplyToMode; ackReactionScope: "all" | "direct" | "group-all" | "group-mentions" | "off" | "none"; groupPolicy: "open" | "disabled" | "allowlist"; +}; +export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields & { data: DiscordMessageEvent; client: Client; message: DiscordMessageEvent["message"]; @@ -89,19 +91,7 @@ export type DiscordMessagePreflightContext = { discordRestFetch?: typeof fetch; }; -export type DiscordMessagePreflightParams = { - cfg: LoadedConfig; - discordConfig: DiscordMessagePreflightContext["discordConfig"]; - accountId: string; - token: string; - runtime: RuntimeEnv; - botUserId?: string; - abortSignal?: AbortSignal; - guildHistories: Map; - historyLimit: number; - mediaMaxBytes: number; - textLimit: number; - replyToMode: ReplyToMode; +export type DiscordMessagePreflightParams = DiscordMessagePreflightSharedFields & { dmEnabled: boolean; groupDmEnabled: boolean; groupDmChannels?: string[]; From f15abb657a0d2402d39728301e000f0aef0105d1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:30:51 +0000 Subject: [PATCH 56/94] test: dedupe discord listener deferred setup --- src/discord/monitor/listeners.test.ts | 44 ++++++++++++--------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/src/discord/monitor/listeners.test.ts b/src/discord/monitor/listeners.test.ts index 71145396a82..d8158320e44 100644 --- a/src/discord/monitor/listeners.test.ts +++ b/src/discord/monitor/listeners.test.ts @@ -12,6 +12,14 @@ function fakeEvent(channelId: string) { return { channel_id: channelId } as never; } +function createDeferred() { + let resolve: (() => void) | undefined; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + describe("DiscordMessageListener", () => { it("returns immediately without awaiting handler completion", async () => { let resolveHandler: (() => void) | undefined; @@ -38,23 +46,17 @@ describe("DiscordMessageListener", () => { it("runs handlers for the same channel concurrently (no per-channel serialization)", async () => { const order: string[] = []; - let resolveA: (() => void) | undefined; - let resolveB: (() => void) | undefined; - const doneA = new Promise((r) => { - resolveA = r; - }); - const doneB = new Promise((r) => { - resolveB = r; - }); + const deferredA = createDeferred(); + const deferredB = createDeferred(); let callCount = 0; const handler = vi.fn(async () => { callCount += 1; const id = callCount; order.push(`start:${id}`); if (id === 1) { - await doneA; + await deferredA.promise; } else { - await doneB; + await deferredB.promise; } order.push(`end:${id}`); }); @@ -71,35 +73,29 @@ describe("DiscordMessageListener", () => { expect(order).toContain("start:1"); expect(order).toContain("start:2"); - resolveB?.(); + deferredB.resolve?.(); await vi.waitFor(() => { expect(order).toContain("end:2"); }); // First handler is still running — no serialization. expect(order).not.toContain("end:1"); - resolveA?.(); + deferredA.resolve?.(); await vi.waitFor(() => { expect(order).toContain("end:1"); }); }); it("runs handlers for different channels in parallel", async () => { - let resolveA: (() => void) | undefined; - let resolveB: (() => void) | undefined; - const doneA = new Promise((r) => { - resolveA = r; - }); - const doneB = new Promise((r) => { - resolveB = r; - }); + const deferredA = createDeferred(); + const deferredB = createDeferred(); const order: string[] = []; const handler = vi.fn(async (data: { channel_id: string }) => { order.push(`start:${data.channel_id}`); if (data.channel_id === "ch-a") { - await doneA; + await deferredA.promise; } else { - await doneB; + await deferredB.promise; } order.push(`end:${data.channel_id}`); }); @@ -114,13 +110,13 @@ describe("DiscordMessageListener", () => { expect(order).toContain("start:ch-a"); expect(order).toContain("start:ch-b"); - resolveB?.(); + deferredB.resolve?.(); await vi.waitFor(() => { expect(order).toContain("end:ch-b"); }); expect(order).not.toContain("end:ch-a"); - resolveA?.(); + deferredA.resolve?.(); await vi.waitFor(() => { expect(order).toContain("end:ch-a"); }); From 6cabcf3fd2b545e025f2df00b4a60ce5133b5875 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:31:32 +0000 Subject: [PATCH 57/94] test: dedupe session idle timeout assertions --- .../reply/commands-session-lifecycle.test.ts | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 79882f13921..baf5addc60e 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -139,6 +139,21 @@ function createTelegramBinding(overrides?: Partial): Sessi }; } +function expectIdleTimeoutSetReply( + mock: ReturnType, + text: string, + idleTimeoutMs: number, + idleTimeoutLabel: string, +) { + expect(mock).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "default", + idleTimeoutMs, + }); + expect(text).toContain(`Idle timeout set to ${idleTimeoutLabel}`); + expect(text).toContain("2026-02-20T02:00:00.000Z"); +} + function createFakeThreadBindingManager(binding: FakeBinding | null) { return { getByThreadId: vi.fn((_threadId: string) => binding), @@ -175,13 +190,12 @@ describe("/session idle and /session max-age", () => { const result = await handleSessionCommand(createDiscordCommandParams("/session idle 2h"), true); const text = result?.reply?.text ?? ""; - expect(hoisted.setThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({ - targetSessionKey: "agent:main:subagent:child", - accountId: "default", - idleTimeoutMs: 2 * 60 * 60 * 1000, - }); - expect(text).toContain("Idle timeout set to 2h"); - expect(text).toContain("2026-02-20T02:00:00.000Z"); + expectIdleTimeoutSetReply( + hoisted.setThreadBindingIdleTimeoutBySessionKeyMock, + text, + 2 * 60 * 60 * 1000, + "2h", + ); }); it("shows active idle timeout when no value is provided", async () => { @@ -248,13 +262,12 @@ describe("/session idle and /session max-age", () => { ); const text = result?.reply?.text ?? ""; - expect(hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock).toHaveBeenCalledWith({ - targetSessionKey: "agent:main:subagent:child", - accountId: "default", - idleTimeoutMs: 2 * 60 * 60 * 1000, - }); - expect(text).toContain("Idle timeout set to 2h"); - expect(text).toContain("2026-02-20T02:00:00.000Z"); + expectIdleTimeoutSetReply( + hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock, + text, + 2 * 60 * 60 * 1000, + "2h", + ); }); it("reports Telegram max-age expiry from the original bind time", async () => { From a7c293b8ef64ec022bbe69874b5d28da428807f4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:32:23 +0000 Subject: [PATCH 58/94] test: dedupe discord bound slash dispatch setup --- .../native-command.plugin-dispatch.test.ts | 49 ++++++++++++------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/src/discord/monitor/native-command.plugin-dispatch.test.ts index bcb6be36c21..c35dbceb466 100644 --- a/src/discord/monitor/native-command.plugin-dispatch.test.ts +++ b/src/discord/monitor/native-command.plugin-dispatch.test.ts @@ -130,6 +130,25 @@ function expectBoundSessionDispatch( expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1); } +async function expectBoundStatusCommandDispatch(params: { + cfg: OpenClawConfig; + interaction: MockCommandInteraction; + channelId: string; + boundSessionKey: string; +}) { + const command = createStatusCommand(params.cfg); + setConfiguredBinding(params.channelId, params.boundSessionKey); + + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = createDispatchSpy(); + + await (command as { run: (interaction: unknown) => Promise }).run( + params.interaction as unknown, + ); + + expectBoundSessionDispatch(dispatchSpy, params.boundSessionKey); +} + describe("Discord native plugin command dispatch", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -212,7 +231,6 @@ describe("Discord native plugin command dispatch", () => { }, ], } as OpenClawConfig; - const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.GuildText, channelId, @@ -220,14 +238,12 @@ describe("Discord native plugin command dispatch", () => { guildName: "Ops", }); - setConfiguredBinding(channelId, boundSessionKey); - - vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = createDispatchSpy(); - - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - - expectBoundSessionDispatch(dispatchSpy, boundSessionKey); + await expectBoundStatusCommandDispatch({ + cfg, + interaction, + channelId, + boundSessionKey, + }); }); it("falls back to the routed slash and channel session keys when no bound session exists", async () => { @@ -312,19 +328,16 @@ describe("Discord native plugin command dispatch", () => { }, }, } as OpenClawConfig; - const command = createStatusCommand(cfg); const interaction = createInteraction({ channelType: ChannelType.DM, channelId, }); - setConfiguredBinding(channelId, boundSessionKey); - - vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = createDispatchSpy(); - - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - - expectBoundSessionDispatch(dispatchSpy, boundSessionKey); + await expectBoundStatusCommandDispatch({ + cfg, + interaction, + channelId, + boundSessionKey, + }); }); }); From 6a44ca9f7668c6e5753c43351eb64c7aa5847359 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:34:42 +0000 Subject: [PATCH 59/94] test: dedupe discord queue preflight setup --- .../monitor/message-handler.queue.test.ts | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/discord/monitor/message-handler.queue.test.ts b/src/discord/monitor/message-handler.queue.test.ts index 122ce852333..92cf0e31183 100644 --- a/src/discord/monitor/message-handler.queue.test.ts +++ b/src/discord/monitor/message-handler.queue.test.ts @@ -45,20 +45,30 @@ function createPreflightContext(channelId = "ch-1") { return createDiscordPreflightContext(channelId); } +function createHandlerWithDefaultPreflight(overrides?: { + setStatus?: SetStatusFn; + workerRunTimeoutMs?: number; +}) { + preflightDiscordMessageMock.mockImplementation(async (params: { data: { channel_id: string } }) => + createPreflightContext(params.data.channel_id), + ); + return createDiscordMessageHandler(createDiscordHandlerParams(overrides)); +} + async function createLifecycleStopScenario(params: { createHandler: (status: SetStatusFn) => { handler: (data: never, opts: never) => Promise; stop: () => void; }; }) { + preflightDiscordMessageMock.mockImplementation( + async (preflightParams: { data: { channel_id: string } }) => + createPreflightContext(preflightParams.data.channel_id), + ); const runInFlight = createDeferred(); processDiscordMessageMock.mockImplementation(async () => { await runInFlight.promise; }); - preflightDiscordMessageMock.mockImplementation( - async (contextParams: { data: { channel_id: string } }) => - createPreflightContext(contextParams.data.channel_id), - ); const setStatus = vi.fn(); const { handler, stop } = params.createHandler(setStatus); @@ -111,13 +121,8 @@ describe("createDiscordMessageHandler queue behavior", () => { .mockImplementationOnce(async () => { await secondRun.promise; }); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); + const handler = createHandlerWithDefaultPreflight({ setStatus }); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); @@ -175,12 +180,11 @@ describe("createDiscordMessageHandler queue behavior", () => { }); }) .mockImplementationOnce(async () => undefined); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - const params = createDiscordHandlerParams({ workerRunTimeoutMs: 50 }); + preflightDiscordMessageMock.mockImplementation( + async (preflightParams: { data: { channel_id: string } }) => + createPreflightContext(preflightParams.data.channel_id), + ); const handler = createDiscordMessageHandler(params); await expect( @@ -226,13 +230,8 @@ describe("createDiscordMessageHandler queue behavior", () => { }); }, ); - preflightDiscordMessageMock.mockImplementation( - async (params: { data: { channel_id: string } }) => - createPreflightContext(params.data.channel_id), - ); - const params = createDiscordHandlerParams({ workerRunTimeoutMs: 0 }); - const handler = createDiscordMessageHandler(params); + const handler = createHandlerWithDefaultPreflight({ workerRunTimeoutMs: 0 }); await expect( handler(createMessageData("m-1") as never, {} as never), @@ -442,7 +441,7 @@ describe("createDiscordMessageHandler queue behavior", () => { ); const setStatus = vi.fn(); - const handler = createDiscordMessageHandler(createDiscordHandlerParams({ setStatus })); + const handler = createHandlerWithDefaultPreflight({ setStatus }); await expect(handler(createMessageData("m-1") as never, {} as never)).resolves.toBeUndefined(); await expect(handler(createMessageData("m-2") as never, {} as never)).resolves.toBeUndefined(); From fd340a88d665bd42a9254ff7d2e10800498a4120 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:37:57 +0000 Subject: [PATCH 60/94] test: dedupe discord preflight helpers --- ...age-handler.preflight.acp-bindings.test.ts | 77 ++----- .../message-handler.preflight.test-helpers.ts | 103 +++++++++ .../monitor/message-handler.preflight.test.ts | 210 ++++++------------ 3 files changed, 190 insertions(+), 200 deletions(-) create mode 100644 src/discord/monitor/message-handler.preflight.test-helpers.ts diff --git a/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts b/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts index 1d7344ca15f..984c9e4cb20 100644 --- a/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts +++ b/src/discord/monitor/message-handler.preflight.acp-bindings.test.ts @@ -1,4 +1,3 @@ -import { ChannelType } from "@buape/carbon"; import { beforeEach, describe, expect, it, vi } from "vitest"; const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn()); @@ -13,7 +12,13 @@ vi.mock("../../acp/persistent-bindings.js", () => ({ import { __testing as sessionBindingTesting } from "../../infra/outbound/session-binding-service.js"; import { preflightDiscordMessage } from "./message-handler.preflight.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { + createDiscordMessage, + createDiscordPreflightArgs, + createGuildEvent, + createGuildTextClient, + DEFAULT_PREFLIGHT_CFG, +} from "./message-handler.preflight.test-helpers.js"; const GUILD_ID = "guild-1"; const CHANNEL_ID = "channel-1"; @@ -48,70 +53,36 @@ function createConfiguredDiscordBinding() { } function createBasePreflightParams(overrides?: Record) { - const message = { + const message = createDiscordMessage({ id: "m-1", - content: "<@bot-1> hello", - timestamp: new Date().toISOString(), channelId: CHANNEL_ID, - attachments: [], + content: "<@bot-1> hello", mentionedUsers: [{ id: "bot-1" }], - mentionedRoles: [], - mentionedEveryone: false, author: { id: "user-1", bot: false, username: "alice", }, - } as unknown as import("@buape/carbon").Message; - - const client = { - fetchChannel: async (channelId: string) => { - if (channelId === CHANNEL_ID) { - return { - id: CHANNEL_ID, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as import("@buape/carbon").Client; + }); return { - cfg: { - session: { - mainKey: "main", - scope: "per-sender", - }, - } as import("../../config/config.js").OpenClawConfig, + ...createDiscordPreflightArgs({ + cfg: DEFAULT_PREFLIGHT_CFG, + discordConfig: { + allowBots: true, + } as NonNullable["discord"], + data: createGuildEvent({ + channelId: CHANNEL_ID, + guildId: GUILD_ID, + author: message.author, + message, + }), + client: createGuildTextClient(CHANNEL_ID), + botUserId: "bot-1", + }), discordConfig: { allowBots: true, } as NonNullable["discord"], - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "bot-1", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: { - channel_id: CHANNEL_ID, - guild_id: GUILD_ID, - guild: { - id: GUILD_ID, - name: "Guild One", - }, - author: message.author, - message, - } as unknown as import("./listeners.js").DiscordMessageEvent, - client, ...overrides, } satisfies Parameters[0]; } diff --git a/src/discord/monitor/message-handler.preflight.test-helpers.ts b/src/discord/monitor/message-handler.preflight.test-helpers.ts new file mode 100644 index 00000000000..712aec7e187 --- /dev/null +++ b/src/discord/monitor/message-handler.preflight.test-helpers.ts @@ -0,0 +1,103 @@ +import { ChannelType } from "@buape/carbon"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { preflightDiscordMessage } from "./message-handler.preflight.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +export type DiscordConfig = NonNullable["discord"]; +export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; +export type DiscordClient = import("@buape/carbon").Client; + +export const DEFAULT_PREFLIGHT_CFG = { + session: { + mainKey: "main", + scope: "per-sender", + }, +} as OpenClawConfig; + +export function createGuildTextClient(channelId: string): DiscordClient { + return { + fetchChannel: async (id: string) => { + if (id === channelId) { + return { + id: channelId, + type: ChannelType.GuildText, + name: "general", + }; + } + return null; + }, + } as unknown as DiscordClient; +} + +export function createGuildEvent(params: { + channelId: string; + guildId: string; + author: import("@buape/carbon").Message["author"]; + message: import("@buape/carbon").Message; +}): DiscordMessageEvent { + return { + channel_id: params.channelId, + guild_id: params.guildId, + guild: { + id: params.guildId, + name: "Guild One", + }, + author: params.author, + message: params.message, + } as unknown as DiscordMessageEvent; +} + +export function createDiscordMessage(params: { + id: string; + channelId: string; + content: string; + author: { + id: string; + bot: boolean; + username?: string; + }; + mentionedUsers?: Array<{ id: string }>; + mentionedEveryone?: boolean; + attachments?: Array>; +}): import("@buape/carbon").Message { + return { + id: params.id, + content: params.content, + timestamp: new Date().toISOString(), + channelId: params.channelId, + attachments: params.attachments ?? [], + mentionedUsers: params.mentionedUsers ?? [], + mentionedRoles: [], + mentionedEveryone: params.mentionedEveryone ?? false, + author: params.author, + } as unknown as import("@buape/carbon").Message; +} + +export function createDiscordPreflightArgs(params: { + cfg: OpenClawConfig; + discordConfig: DiscordConfig; + data: DiscordMessageEvent; + client: DiscordClient; + botUserId?: string; +}): Parameters[0] { + return { + cfg: params.cfg, + discordConfig: params.discordConfig, + accountId: "default", + token: "token", + runtime: {} as import("../../runtime.js").RuntimeEnv, + botUserId: params.botUserId ?? "openclaw-bot", + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1_000_000, + textLimit: 2_000, + replyToMode: "all", + dmEnabled: true, + groupDmEnabled: true, + ackReactionScope: "direct", + groupPolicy: "open", + threadBindings: createNoopThreadBindingManager("default"), + data: params.data, + client: params.client, + }; +} diff --git a/src/discord/monitor/message-handler.preflight.test.ts b/src/discord/monitor/message-handler.preflight.test.ts index 1e4d9c5dddb..c90c608e93b 100644 --- a/src/discord/monitor/message-handler.preflight.test.ts +++ b/src/discord/monitor/message-handler.preflight.test.ts @@ -15,25 +15,21 @@ import { resolvePreflightMentionRequirement, shouldIgnoreBoundThreadWebhookMessage, } from "./message-handler.preflight.js"; +import { + createDiscordMessage, + createDiscordPreflightArgs, + createGuildEvent, + createGuildTextClient, + DEFAULT_PREFLIGHT_CFG, + type DiscordClient, + type DiscordConfig, + type DiscordMessageEvent, +} from "./message-handler.preflight.test-helpers.js"; import { __testing as threadBindingTesting, - createNoopThreadBindingManager, createThreadBindingManager, } from "./thread-bindings.js"; -type DiscordConfig = NonNullable< - import("../../config/config.js").OpenClawConfig["channels"] ->["discord"]; -type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent; -type DiscordClient = import("@buape/carbon").Client; - -const DEFAULT_CFG = { - session: { - mainKey: "main", - scope: "per-sender", - }, -} as import("../../config/config.js").OpenClawConfig; - function createThreadBinding( overrides?: Partial< import("../../infra/outbound/session-binding-service.js").SessionBindingRecord @@ -67,41 +63,7 @@ function createPreflightArgs(params: { data: DiscordMessageEvent; client: DiscordClient; }): Parameters[0] { - return { - cfg: params.cfg, - discordConfig: params.discordConfig, - accountId: "default", - token: "token", - runtime: {} as import("../../runtime.js").RuntimeEnv, - botUserId: "openclaw-bot", - guildHistories: new Map(), - historyLimit: 0, - mediaMaxBytes: 1_000_000, - textLimit: 2_000, - replyToMode: "all", - dmEnabled: true, - groupDmEnabled: true, - ackReactionScope: "direct", - groupPolicy: "open", - threadBindings: createNoopThreadBindingManager("default"), - data: params.data, - client: params.client, - }; -} - -function createGuildTextClient(channelId: string): DiscordClient { - return { - fetchChannel: async (id: string) => { - if (id === channelId) { - return { - id: channelId, - type: ChannelType.GuildText, - name: "general", - }; - } - return null; - }, - } as unknown as DiscordClient; + return createDiscordPreflightArgs(params); } function createThreadClient(params: { threadId: string; parentId: string }): DiscordClient { @@ -128,50 +90,6 @@ function createThreadClient(params: { threadId: string; parentId: string }): Dis } as unknown as DiscordClient; } -function createGuildEvent(params: { - channelId: string; - guildId: string; - author: import("@buape/carbon").Message["author"]; - message: import("@buape/carbon").Message; -}): DiscordMessageEvent { - return { - channel_id: params.channelId, - guild_id: params.guildId, - guild: { - id: params.guildId, - name: "Guild One", - }, - author: params.author, - message: params.message, - } as unknown as DiscordMessageEvent; -} - -function createMessage(params: { - id: string; - channelId: string; - content: string; - author: { - id: string; - bot: boolean; - username?: string; - }; - mentionedUsers?: Array<{ id: string }>; - mentionedEveryone?: boolean; - attachments?: Array>; -}): import("@buape/carbon").Message { - return { - id: params.id, - content: params.content, - timestamp: new Date().toISOString(), - channelId: params.channelId, - attachments: params.attachments ?? [], - mentionedUsers: params.mentionedUsers ?? [], - mentionedRoles: [], - mentionedEveryone: params.mentionedEveryone ?? false, - author: params.author, - } as unknown as import("@buape/carbon").Message; -} - async function runThreadBoundPreflight(params: { threadId: string; parentId: string; @@ -197,7 +115,7 @@ async function runThreadBoundPreflight(params: { return preflightDiscordMessage({ ...createPreflightArgs({ - cfg: DEFAULT_CFG, + cfg: DEFAULT_PREFLIGHT_CFG, discordConfig: params.discordConfig, data: createGuildEvent({ channelId: params.threadId, @@ -223,7 +141,7 @@ async function runGuildPreflight(params: { }) { return preflightDiscordMessage({ ...createPreflightArgs({ - cfg: params.cfg ?? DEFAULT_CFG, + cfg: params.cfg ?? DEFAULT_PREFLIGHT_CFG, discordConfig: params.discordConfig, data: createGuildEvent({ channelId: params.channelId, @@ -237,6 +155,40 @@ async function runGuildPreflight(params: { }); } +async function runMentionOnlyBotPreflight(params: { + channelId: string; + guildId: string; + message: import("@buape/carbon").Message; +}) { + return runGuildPreflight({ + channelId: params.channelId, + guildId: params.guildId, + message: params.message, + discordConfig: { + allowBots: "mentions", + } as DiscordConfig, + }); +} + +async function runIgnoreOtherMentionsPreflight(params: { + channelId: string; + guildId: string; + message: import("@buape/carbon").Message; +}) { + return runGuildPreflight({ + channelId: params.channelId, + guildId: params.guildId, + message: params.message, + discordConfig: {} as DiscordConfig, + guildEntries: { + [params.guildId]: { + requireMention: false, + ignoreOtherMentions: true, + }, + }, + }); +} + describe("resolvePreflightMentionRequirement", () => { it("requires mention when config requires mention and thread is not bound", () => { expect( @@ -279,7 +231,7 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-system-1"; const parentId = "channel-parent-1"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-system-1", channelId: threadId, content: @@ -311,7 +263,7 @@ describe("preflightDiscordMessage", () => { }); const threadId = "thread-bot-regular-1"; const parentId = "channel-parent-regular-1"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-bot-regular-1", channelId: threadId, content: "here is tool output chunk", @@ -342,7 +294,7 @@ describe("preflightDiscordMessage", () => { const threadId = "thread-bot-focus"; const parentId = "channel-parent-focus"; const client = createThreadClient({ threadId, parentId }); - const message = createMessage({ + const message = createDiscordMessage({ id: "m-bot-1", channelId: threadId, content: "relay message without mention", @@ -363,7 +315,7 @@ describe("preflightDiscordMessage", () => { const result = await preflightDiscordMessage( createPreflightArgs({ cfg: { - ...DEFAULT_CFG, + ...DEFAULT_PREFLIGHT_CFG, } as import("../../config/config.js").OpenClawConfig, discordConfig: { allowBots: true, @@ -386,7 +338,7 @@ describe("preflightDiscordMessage", () => { it("drops bot messages without mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-off"; const guildId = "guild-bot-mentions-off"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-bot-mentions-off", channelId, content: "relay chatter", @@ -397,14 +349,7 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await runGuildPreflight({ - channelId, - guildId, - message, - discordConfig: { - allowBots: "mentions", - } as DiscordConfig, - }); + const result = await runMentionOnlyBotPreflight({ channelId, guildId, message }); expect(result).toBeNull(); }); @@ -412,7 +357,7 @@ describe("preflightDiscordMessage", () => { it("allows bot messages with explicit mention when allowBots=mentions", async () => { const channelId = "channel-bot-mentions-on"; const guildId = "guild-bot-mentions-on"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-bot-mentions-on", channelId, content: "hi <@openclaw-bot>", @@ -424,14 +369,7 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await runGuildPreflight({ - channelId, - guildId, - message, - discordConfig: { - allowBots: "mentions", - } as DiscordConfig, - }); + const result = await runMentionOnlyBotPreflight({ channelId, guildId, message }); expect(result).not.toBeNull(); }); @@ -439,7 +377,7 @@ describe("preflightDiscordMessage", () => { it("drops guild messages that mention another user when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-1"; const guildId = "guild-other-mention-1"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-other-mention-1", channelId, content: "hello <@999>", @@ -451,18 +389,7 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await runGuildPreflight({ - channelId, - guildId, - message, - discordConfig: {} as DiscordConfig, - guildEntries: { - [guildId]: { - requireMention: false, - ignoreOtherMentions: true, - }, - }, - }); + const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message }); expect(result).toBeNull(); }); @@ -470,7 +397,7 @@ describe("preflightDiscordMessage", () => { it("does not drop @everyone messages when ignoreOtherMentions=true", async () => { const channelId = "channel-other-mention-everyone"; const guildId = "guild-other-mention-everyone"; - const message = createMessage({ + const message = createDiscordMessage({ id: "m-other-mention-everyone", channelId, content: "@everyone heads up", @@ -482,18 +409,7 @@ describe("preflightDiscordMessage", () => { }, }); - const result = await runGuildPreflight({ - channelId, - guildId, - message, - discordConfig: {} as DiscordConfig, - guildEntries: { - [guildId]: { - requireMention: false, - ignoreOtherMentions: true, - }, - }, - }); + const result = await runIgnoreOtherMentionsPreflight({ channelId, guildId, message }); expect(result).not.toBeNull(); expect(result?.hasAnyMention).toBe(true); @@ -503,7 +419,7 @@ describe("preflightDiscordMessage", () => { const channelId = "channel-everyone-1"; const guildId = "guild-everyone-1"; const client = createGuildTextClient(channelId); - const message = createMessage({ + const message = createDiscordMessage({ id: "m-everyone-1", channelId, content: "@everyone heads up", @@ -517,7 +433,7 @@ describe("preflightDiscordMessage", () => { const result = await preflightDiscordMessage({ ...createPreflightArgs({ - cfg: DEFAULT_CFG, + cfg: DEFAULT_PREFLIGHT_CFG, discordConfig: { allowBots: true, } as DiscordConfig, @@ -546,7 +462,7 @@ describe("preflightDiscordMessage", () => { const channelId = "channel-audio-1"; const client = createGuildTextClient(channelId); - const message = createMessage({ + const message = createDiscordMessage({ id: "m-audio-1", channelId, content: "", @@ -568,7 +484,7 @@ describe("preflightDiscordMessage", () => { const result = await preflightDiscordMessage( createPreflightArgs({ cfg: { - ...DEFAULT_CFG, + ...DEFAULT_PREFLIGHT_CFG, messages: { groupChat: { mentionPatterns: ["openclaw"], From fd5243c27e61b8b719c8ef79518a8fcfcffcf717 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 13 Mar 2026 22:39:49 +0000 Subject: [PATCH 61/94] refactor: share discord exec approval helpers --- src/discord/monitor/exec-approvals.test.ts | 138 +++++++++++---------- src/discord/monitor/exec-approvals.ts | 31 +++-- 2 files changed, 97 insertions(+), 72 deletions(-) diff --git a/src/discord/monitor/exec-approvals.test.ts b/src/discord/monitor/exec-approvals.test.ts index 8f9430393a2..c7cb72b82ec 100644 --- a/src/discord/monitor/exec-approvals.test.ts +++ b/src/discord/monitor/exec-approvals.test.ts @@ -116,6 +116,62 @@ function createHandler(config: DiscordExecApprovalConfig, accountId = "default") }); } +function mockSuccessfulDmDelivery(params?: { + noteChannelId?: string; + expectedNoteText?: string; + throwOnUnexpectedRoute?: boolean; +}) { + mockRestPost.mockImplementation( + async (route: string, requestParams?: { body?: { content?: string } }) => { + if (params?.noteChannelId && route === Routes.channelMessages(params.noteChannelId)) { + if (params.expectedNoteText) { + expect(requestParams?.body?.content).toContain(params.expectedNoteText); + } + return { id: "note-1", channel_id: params.noteChannelId }; + } + if (route === Routes.userChannels()) { + return { id: "dm-1" }; + } + if (route === Routes.channelMessages("dm-1")) { + return { id: "msg-1", channel_id: "dm-1" }; + } + if (params?.throwOnUnexpectedRoute) { + throw new Error(`unexpected route: ${route}`); + } + return { id: "msg-unknown" }; + }, + ); +} + +async function expectGatewayAuthStart(params: { + handler: DiscordExecApprovalHandler; + expectedUrl: string; + expectedSource: "cli" | "env"; + expectedToken?: string; + expectedPassword?: string; +}) { + await params.handler.start(); + + expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( + expect.objectContaining({ + env: process.env, + urlOverride: params.expectedUrl, + urlOverrideSource: params.expectedSource, + }), + ); + + const expectedClientParams: Record = { + url: params.expectedUrl, + }; + if (params.expectedToken !== undefined) { + expectedClientParams.token = params.expectedToken; + } + if (params.expectedPassword !== undefined) { + expectedClientParams.password = params.expectedPassword; + } + expect(mockGatewayClientCtor).toHaveBeenCalledWith(expect.objectContaining(expectedClientParams)); +} + type ExecApprovalHandlerInternals = { pending: Map< string, @@ -772,15 +828,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation(async (route: string) => { - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - return { id: "msg-unknown" }; - }); + mockSuccessfulDmDelivery(); const request = createRequest({ sessionKey: "agent:main:discord:dm:123" }); await internals.handleApprovalRequested(request); @@ -809,21 +857,11 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation( - async (route: string, params?: { body?: { content?: string } }) => { - if (route === Routes.channelMessages("999888777")) { - expect(params?.body?.content).toContain("I sent the allowed approvers DMs"); - return { id: "note-1", channel_id: "999888777" }; - } - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - throw new Error(`unexpected route: ${route}`); - }, - ); + mockSuccessfulDmDelivery({ + noteChannelId: "999888777", + expectedNoteText: "I sent the allowed approvers DMs", + throwOnUnexpectedRoute: true, + }); await internals.handleApprovalRequested(createRequest()); @@ -853,15 +891,7 @@ describe("DiscordExecApprovalHandler delivery routing", () => { }); const internals = getHandlerInternals(handler); - mockRestPost.mockImplementation(async (route: string) => { - if (route === Routes.userChannels()) { - return { id: "dm-1" }; - } - if (route === Routes.channelMessages("dm-1")) { - return { id: "msg-1", channel_id: "dm-1" }; - } - throw new Error(`unexpected route: ${route}`); - }); + mockSuccessfulDmDelivery({ throwOnUnexpectedRoute: true }); await internals.handleApprovalRequested( createRequest({ sessionKey: "agent:main:discord:dm:123" }), @@ -890,22 +920,13 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { cfg: { session: { store: STORE_PATH } }, }); - await handler.start(); - - expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( - expect.objectContaining({ - env: process.env, - urlOverride: "wss://override.example/ws", - urlOverrideSource: "cli", - }), - ); - expect(mockGatewayClientCtor).toHaveBeenCalledWith( - expect.objectContaining({ - url: "wss://override.example/ws", - token: "resolved-token", - password: "resolved-password", // pragma: allowlist secret - }), - ); + await expectGatewayAuthStart({ + handler, + expectedUrl: "wss://override.example/ws", + expectedSource: "cli", + expectedToken: "resolved-token", + expectedPassword: "resolved-password", // pragma: allowlist secret + }); await handler.stop(); }); @@ -921,20 +942,11 @@ describe("DiscordExecApprovalHandler gateway auth resolution", () => { cfg: { session: { store: STORE_PATH } }, }); - await handler.start(); - - expect(mockResolveGatewayConnectionAuth).toHaveBeenCalledWith( - expect.objectContaining({ - env: process.env, - urlOverride: "wss://gateway-from-env.example/ws", - urlOverrideSource: "env", - }), - ); - expect(mockGatewayClientCtor).toHaveBeenCalledWith( - expect.objectContaining({ - url: "wss://gateway-from-env.example/ws", - }), - ); + await expectGatewayAuthStart({ + handler, + expectedUrl: "wss://gateway-from-env.example/ws", + expectedSource: "env", + }); await handler.stop(); } finally { diff --git a/src/discord/monitor/exec-approvals.ts b/src/discord/monitor/exec-approvals.ts index 87dc0c9a07d..8dd3156e991 100644 --- a/src/discord/monitor/exec-approvals.ts +++ b/src/discord/monitor/exec-approvals.ts @@ -252,17 +252,30 @@ function formatOptionalCommandPreview( return formatCommandPreview(commandText, maxChars); } +function resolveExecApprovalPreviews( + request: ExecApprovalRequest["request"], + maxChars: number, + secondaryMaxChars: number, +): { commandPreview: string; commandSecondaryPreview: string | null } { + const { commandText, commandPreview: secondaryPreview } = + resolveExecApprovalCommandDisplay(request); + return { + commandPreview: formatCommandPreview(commandText, maxChars), + commandSecondaryPreview: formatOptionalCommandPreview(secondaryPreview, secondaryMaxChars), + }; +} + function createExecApprovalRequestContainer(params: { request: ExecApprovalRequest; cfg: OpenClawConfig; accountId: string; actionRow?: Row