import type { ButtonInteraction, ComponentData, ModalInteraction, StringSelectMenuInteraction, } from "@buape/carbon"; import type { Client } from "@buape/carbon"; import type { GatewayPresenceUpdate } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { DiscordAccountConfig } from "../../config/types.discord.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { clearDiscordComponentEntries, registerDiscordComponentEntries, resolveDiscordComponentEntry, resolveDiscordModalEntry, } from "../components-registry.js"; import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js"; import { createAgentComponentButton, createAgentSelectMenu, createDiscordComponentButton, createDiscordComponentModal, } from "./agent-components.js"; import type { DiscordChannelConfigResolved } from "./allow-list.js"; import { resolveDiscordMemberAllowed, resolveDiscordOwnerAllowFrom, resolveDiscordRoleAllowed, } from "./allow-list.js"; import { clearGateways, getGateway, registerGateway, unregisterGateway, } from "./gateway-registry.js"; import { clearPresences, getPresence, presenceCacheSize, setPresence } from "./presence-cache.js"; import { resolveDiscordPresenceUpdate } from "./presence.js"; import { maybeCreateDiscordAutoThread, resolveDiscordAutoThreadContext, resolveDiscordAutoThreadReplyPlan, resolveDiscordReplyDeliveryPlan, } from "./threading.js"; const readAllowFromStoreMock = vi.hoisted(() => vi.fn()); const upsertPairingRequestMock = vi.hoisted(() => vi.fn()); const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); const dispatchReplyMock = vi.hoisted(() => vi.fn()); const deliverDiscordReplyMock = vi.hoisted(() => vi.fn()); const recordInboundSessionMock = vi.hoisted(() => vi.fn()); const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn()); const resolveStorePathMock = vi.hoisted(() => vi.fn()); let lastDispatchCtx: Record | undefined; vi.mock("../../pairing/pairing-store.js", () => ({ readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), })); vi.mock("../../infra/system-events.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), }; }); vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({ dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args), })); vi.mock("./reply-delivery.js", () => ({ deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args), })); vi.mock("../../channels/session.js", () => ({ recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), })); vi.mock("../../config/sessions.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args), resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args), }; }); describe("agent components", () => { const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; const createBaseDmInteraction = (overrides: Record = {}) => { const reply = vi.fn().mockResolvedValue(undefined); const defer = vi.fn().mockResolvedValue(undefined); const interaction = { rawData: { channel_id: "dm-channel" }, user: { id: "123456789", username: "Alice", discriminator: "1234" }, defer, reply, ...overrides, }; return { interaction, defer, reply }; }; const createDmButtonInteraction = (overrides: Partial = {}) => { const { interaction, defer, reply } = createBaseDmInteraction( overrides as Record, ); return { interaction: interaction as unknown as ButtonInteraction, defer, reply, }; }; const createDmSelectInteraction = (overrides: Partial = {}) => { const { interaction, defer, reply } = createBaseDmInteraction({ values: ["alpha"], ...(overrides as Record), }); return { interaction: interaction as unknown as StringSelectMenuInteraction, defer, reply, }; }; beforeEach(() => { readAllowFromStoreMock.mockClear().mockResolvedValue([]); upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true }); enqueueSystemEventMock.mockClear(); }); it("sends pairing reply when DM sender is not allowlisted", async () => { const button = createAgentComponentButton({ cfg: createCfg(), accountId: "default", dmPolicy: "pairing", }); const { interaction, defer, reply } = createDmButtonInteraction(); await button.run(interaction, { componentId: "hello" } as ComponentData); expect(defer).toHaveBeenCalledWith({ ephemeral: true }); expect(reply).toHaveBeenCalledTimes(1); expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE"); expect(enqueueSystemEventMock).not.toHaveBeenCalled(); }); it("blocks DM interactions when only pairing store entries match in allowlist mode", async () => { readAllowFromStoreMock.mockResolvedValue(["123456789"]); const button = createAgentComponentButton({ cfg: createCfg(), accountId: "default", dmPolicy: "allowlist", }); const { interaction, defer, reply } = createDmButtonInteraction(); await button.run(interaction, { componentId: "hello" } as ComponentData); expect(defer).toHaveBeenCalledWith({ ephemeral: true }); expect(reply).toHaveBeenCalledWith({ content: "You are not authorized to use this button." }); expect(enqueueSystemEventMock).not.toHaveBeenCalled(); expect(readAllowFromStoreMock).not.toHaveBeenCalled(); }); it("matches tag-based allowlist entries for DM select menus", async () => { const select = createAgentSelectMenu({ cfg: createCfg(), accountId: "default", discordConfig: { dangerouslyAllowNameMatching: true } as DiscordAccountConfig, dmPolicy: "allowlist", allowFrom: ["Alice#1234"], }); const { interaction, defer, reply } = createDmSelectInteraction(); await select.run(interaction, { componentId: "hello" } as ComponentData); expect(defer).toHaveBeenCalledWith({ ephemeral: true }); expect(reply).toHaveBeenCalledWith({ content: "✓" }); expect(enqueueSystemEventMock).toHaveBeenCalled(); }); it("accepts cid payloads for agent button interactions", async () => { const button = createAgentComponentButton({ cfg: createCfg(), accountId: "default", dmPolicy: "allowlist", allowFrom: ["123456789"], }); const { interaction, defer, reply } = createDmButtonInteraction(); await button.run(interaction, { cid: "hello_cid" } as ComponentData); expect(defer).toHaveBeenCalledWith({ ephemeral: true }); expect(reply).toHaveBeenCalledWith({ content: "✓" }); expect(enqueueSystemEventMock).toHaveBeenCalledWith( expect.stringContaining("hello_cid"), expect.any(Object), ); }); it("keeps malformed percent cid values without throwing", async () => { const button = createAgentComponentButton({ cfg: createCfg(), accountId: "default", dmPolicy: "allowlist", allowFrom: ["123456789"], }); const { interaction, defer, reply } = createDmButtonInteraction(); await button.run(interaction, { cid: "hello%2G" } as ComponentData); expect(defer).toHaveBeenCalledWith({ ephemeral: true }); expect(reply).toHaveBeenCalledWith({ content: "✓" }); expect(enqueueSystemEventMock).toHaveBeenCalledWith( expect.stringContaining("hello%2G"), expect.any(Object), ); }); }); describe("discord component interactions", () => { const createCfg = (): OpenClawConfig => ({ channels: { discord: { replyToMode: "first", }, }, }) as OpenClawConfig; const createDiscordConfig = (overrides?: Partial): DiscordAccountConfig => ({ replyToMode: "first", ...overrides, }) as DiscordAccountConfig; type DispatchParams = { ctx: Record; dispatcherOptions: { deliver: (payload: { text?: string }) => Promise | void; }; }; const createComponentContext = ( overrides?: Partial[0]>, ) => ({ cfg: createCfg(), accountId: "default", dmPolicy: "allowlist", allowFrom: ["123456789"], discordConfig: createDiscordConfig(), token: "token", ...overrides, }) as Parameters[0]; const createComponentButtonInteraction = (overrides: Partial = {}) => { const reply = vi.fn().mockResolvedValue(undefined); const defer = vi.fn().mockResolvedValue(undefined); const interaction = { rawData: { channel_id: "dm-channel", id: "interaction-1" }, user: { id: "123456789", username: "AgentUser", discriminator: "0001" }, customId: "occomp:cid=btn_1", message: { id: "msg-1" }, client: { rest: {} }, defer, reply, ...overrides, } as unknown as ButtonInteraction; return { interaction, defer, reply }; }; const createModalInteraction = (overrides: Partial = {}) => { const reply = vi.fn().mockResolvedValue(undefined); const acknowledge = vi.fn().mockResolvedValue(undefined); const fields = { getText: (key: string) => (key === "fld_1" ? "Casey" : undefined), getStringSelect: (_key: string) => undefined, getRoleSelect: (_key: string) => [], getUserSelect: (_key: string) => [], }; const interaction = { rawData: { channel_id: "dm-channel", id: "interaction-2" }, user: { id: "123456789", username: "AgentUser", discriminator: "0001" }, customId: "ocmodal:mid=mdl_1", fields, acknowledge, reply, client: { rest: {} }, ...overrides, } as unknown as ModalInteraction; return { interaction, acknowledge, reply }; }; const createButtonEntry = ( overrides: Partial = {}, ): DiscordComponentEntry => ({ id: "btn_1", kind: "button", label: "Approve", messageId: "msg-1", sessionKey: "session-1", agentId: "agent-1", accountId: "default", ...overrides, }); const createModalEntry = (overrides: Partial = {}): DiscordModalEntry => ({ id: "mdl_1", title: "Details", messageId: "msg-2", sessionKey: "session-2", agentId: "agent-2", accountId: "default", fields: [ { id: "fld_1", name: "name", label: "Name", type: "text", }, ], ...overrides, }); beforeEach(() => { clearDiscordComponentEntries(); lastDispatchCtx = undefined; readAllowFromStoreMock.mockClear().mockResolvedValue([]); upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true }); enqueueSystemEventMock.mockClear(); dispatchReplyMock.mockClear().mockImplementation(async (params: DispatchParams) => { lastDispatchCtx = params.ctx; await params.dispatcherOptions.deliver({ text: "ok" }); }); deliverDiscordReplyMock.mockClear(); recordInboundSessionMock.mockClear().mockResolvedValue(undefined); readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined); resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json"); }); it("routes button clicks with reply references", async () => { registerDiscordComponentEntries({ entries: [createButtonEntry()], modals: [], }); const button = createDiscordComponentButton(createComponentContext()); const { interaction, reply } = createComponentButtonInteraction(); await button.run(interaction, { cid: "btn_1" } as ComponentData); expect(reply).toHaveBeenCalledWith({ content: "✓" }); expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".'); expect(dispatchReplyMock).toHaveBeenCalledTimes(1); expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1); expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-1"); expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull(); }); it("keeps reusable buttons active after use", async () => { registerDiscordComponentEntries({ entries: [createButtonEntry({ reusable: true })], modals: [], }); const button = createDiscordComponentButton(createComponentContext()); const { interaction } = createComponentButtonInteraction(); await button.run(interaction, { cid: "btn_1" } as ComponentData); const { interaction: secondInteraction } = createComponentButtonInteraction({ rawData: { channel_id: "dm-channel", id: "interaction-2", } as unknown as ButtonInteraction["rawData"], }); await button.run(secondInteraction, { cid: "btn_1" } as ComponentData); expect(dispatchReplyMock).toHaveBeenCalledTimes(2); expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull(); }); it("blocks buttons when allowedUsers does not match", async () => { registerDiscordComponentEntries({ entries: [createButtonEntry({ allowedUsers: ["999"] })], modals: [], }); const button = createDiscordComponentButton(createComponentContext()); const { interaction, reply } = createComponentButtonInteraction(); await button.run(interaction, { cid: "btn_1" } as ComponentData); expect(reply).toHaveBeenCalledWith({ content: "You are not authorized to use this button." }); expect(dispatchReplyMock).not.toHaveBeenCalled(); expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull(); }); async function runModalSubmission(params?: { reusable?: boolean }) { registerDiscordComponentEntries({ entries: [], modals: [createModalEntry({ reusable: params?.reusable ?? false })], }); const modal = createDiscordComponentModal( createComponentContext({ discordConfig: createDiscordConfig({ replyToMode: "all" }), }), ); const { interaction, acknowledge } = createModalInteraction(); await modal.run(interaction, { mid: "mdl_1" } as ComponentData); return { acknowledge }; } it("routes modal submissions with field values", async () => { const { acknowledge } = await runModalSubmission(); expect(acknowledge).toHaveBeenCalledTimes(1); expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.'); expect(lastDispatchCtx?.BodyForAgent).toContain("- Name: Casey"); expect(dispatchReplyMock).toHaveBeenCalledTimes(1); expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1); expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2"); expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull(); }); it("does not mark guild modal events as command-authorized for non-allowlisted users", async () => { registerDiscordComponentEntries({ entries: [], modals: [createModalEntry()], }); const modal = createDiscordComponentModal( createComponentContext({ cfg: { commands: { useAccessGroups: true }, channels: { discord: { replyToMode: "first" } }, } as OpenClawConfig, allowFrom: ["owner-1"], }), ); const { interaction, acknowledge } = createModalInteraction({ rawData: { channel_id: "guild-channel", guild_id: "guild-1", id: "interaction-guild-1", member: { roles: [] }, } as unknown as ModalInteraction["rawData"], guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"], }); await modal.run(interaction, { mid: "mdl_1" } as ComponentData); expect(acknowledge).toHaveBeenCalledTimes(1); expect(dispatchReplyMock).toHaveBeenCalledTimes(1); expect(lastDispatchCtx?.CommandAuthorized).toBe(false); }); it("marks guild modal events as command-authorized for allowlisted users", async () => { registerDiscordComponentEntries({ entries: [], modals: [createModalEntry()], }); const modal = createDiscordComponentModal( createComponentContext({ cfg: { commands: { useAccessGroups: true }, channels: { discord: { replyToMode: "first" } }, } as OpenClawConfig, allowFrom: ["123456789"], }), ); const { interaction, acknowledge } = createModalInteraction({ rawData: { channel_id: "guild-channel", guild_id: "guild-1", id: "interaction-guild-2", member: { roles: [] }, } as unknown as ModalInteraction["rawData"], guild: { id: "guild-1", name: "Test Guild" } as unknown as ModalInteraction["guild"], }); await modal.run(interaction, { mid: "mdl_1" } as ComponentData); expect(acknowledge).toHaveBeenCalledTimes(1); expect(dispatchReplyMock).toHaveBeenCalledTimes(1); expect(lastDispatchCtx?.CommandAuthorized).toBe(true); }); it("keeps reusable modal entries active after submission", async () => { const { acknowledge } = await runModalSubmission({ reusable: true }); expect(acknowledge).toHaveBeenCalledTimes(1); expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull(); }); }); describe("resolveDiscordOwnerAllowFrom", () => { it("returns undefined when no allowlist is configured", () => { const result = resolveDiscordOwnerAllowFrom({ channelConfig: { allowed: true } as DiscordChannelConfigResolved, sender: { id: "123" }, }); expect(result).toBeUndefined(); }); it("skips wildcard matches for owner allowFrom", () => { const result = resolveDiscordOwnerAllowFrom({ channelConfig: { allowed: true, users: ["*"] } as DiscordChannelConfigResolved, sender: { id: "123" }, }); expect(result).toBeUndefined(); }); it("returns a matching user id entry", () => { const result = resolveDiscordOwnerAllowFrom({ channelConfig: { allowed: true, users: ["123"] } as DiscordChannelConfigResolved, sender: { id: "123" }, }); expect(result).toEqual(["123"]); }); it("returns the normalized name slug for name matches only when enabled", () => { const defaultResult = resolveDiscordOwnerAllowFrom({ channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved, sender: { id: "999", name: "Some User" }, }); expect(defaultResult).toBeUndefined(); const enabledResult = resolveDiscordOwnerAllowFrom({ channelConfig: { allowed: true, users: ["Some User"] } as DiscordChannelConfigResolved, sender: { id: "999", name: "Some User" }, allowNameMatching: true, }); expect(enabledResult).toEqual(["some-user"]); }); }); describe("resolveDiscordRoleAllowed", () => { it("allows when no role allowlist is configured", () => { const allowed = resolveDiscordRoleAllowed({ allowList: undefined, memberRoleIds: ["role-1"], }); expect(allowed).toBe(true); }); it("matches role IDs only", () => { const allowed = resolveDiscordRoleAllowed({ allowList: ["123"], memberRoleIds: ["123", "456"], }); expect(allowed).toBe(true); }); it("does not match non-ID role entries", () => { const allowed = resolveDiscordRoleAllowed({ allowList: ["Admin"], memberRoleIds: ["Admin"], }); expect(allowed).toBe(false); }); it("returns false when no matching role IDs", () => { const allowed = resolveDiscordRoleAllowed({ allowList: ["456"], memberRoleIds: ["123"], }); expect(allowed).toBe(false); }); }); describe("resolveDiscordMemberAllowed", () => { it("allows when no user or role allowlists are configured", () => { const allowed = resolveDiscordMemberAllowed({ userAllowList: undefined, roleAllowList: undefined, memberRoleIds: [], userId: "u1", }); expect(allowed).toBe(true); }); it("allows when user allowlist matches", () => { const allowed = resolveDiscordMemberAllowed({ userAllowList: ["123"], roleAllowList: ["456"], memberRoleIds: ["999"], userId: "123", }); expect(allowed).toBe(true); }); it("allows when role allowlist matches", () => { const allowed = resolveDiscordMemberAllowed({ userAllowList: ["999"], roleAllowList: ["456"], memberRoleIds: ["456"], userId: "123", }); expect(allowed).toBe(true); }); it("denies when user and role allowlists do not match", () => { const allowed = resolveDiscordMemberAllowed({ userAllowList: ["u2"], roleAllowList: ["role-2"], memberRoleIds: ["role-1"], userId: "u1", }); expect(allowed).toBe(false); }); }); describe("gateway-registry", () => { type GatewayPlugin = { isConnected: boolean }; function fakeGateway(props: Partial = {}): GatewayPlugin { return { isConnected: true, ...props }; } beforeEach(() => { clearGateways(); }); it("stores and retrieves a gateway by account", () => { const gateway = fakeGateway(); registerGateway("account-a", gateway as never); expect(getGateway("account-a")).toBe(gateway); expect(getGateway("account-b")).toBeUndefined(); }); it("uses collision-safe key when accountId is undefined", () => { const gateway = fakeGateway(); registerGateway(undefined, gateway as never); expect(getGateway(undefined)).toBe(gateway); expect(getGateway("default")).toBeUndefined(); }); it("unregisters a gateway", () => { const gateway = fakeGateway(); registerGateway("account-a", gateway as never); unregisterGateway("account-a"); expect(getGateway("account-a")).toBeUndefined(); }); it("clears all gateways", () => { registerGateway("a", fakeGateway() as never); registerGateway("b", fakeGateway() as never); clearGateways(); expect(getGateway("a")).toBeUndefined(); expect(getGateway("b")).toBeUndefined(); }); it("overwrites existing entry for same account", () => { const gateway1 = fakeGateway({ isConnected: true }); const gateway2 = fakeGateway({ isConnected: false }); registerGateway("account-a", gateway1 as never); registerGateway("account-a", gateway2 as never); expect(getGateway("account-a")).toBe(gateway2); }); }); describe("presence-cache", () => { beforeEach(() => { clearPresences(); }); it("scopes presence entries by account", () => { const presenceA = { status: "online" } as GatewayPresenceUpdate; const presenceB = { status: "idle" } as GatewayPresenceUpdate; setPresence("account-a", "user-1", presenceA); setPresence("account-b", "user-1", presenceB); expect(getPresence("account-a", "user-1")).toBe(presenceA); expect(getPresence("account-b", "user-1")).toBe(presenceB); expect(getPresence("account-a", "user-2")).toBeUndefined(); }); it("clears presence per account", () => { const presence = { status: "dnd" } as GatewayPresenceUpdate; setPresence("account-a", "user-1", presence); setPresence("account-b", "user-2", presence); clearPresences("account-a"); expect(getPresence("account-a", "user-1")).toBeUndefined(); expect(getPresence("account-b", "user-2")).toBe(presence); expect(presenceCacheSize()).toBe(1); }); }); describe("resolveDiscordPresenceUpdate", () => { it("returns null when no presence config provided", () => { expect(resolveDiscordPresenceUpdate({})).toBeNull(); }); it("returns status-only presence when activity is omitted", () => { const presence = resolveDiscordPresenceUpdate({ status: "dnd" }); expect(presence).not.toBeNull(); expect(presence?.status).toBe("dnd"); expect(presence?.activities).toEqual([]); }); it("defaults to custom activity type when activity is set without type", () => { const presence = resolveDiscordPresenceUpdate({ activity: "Focus time" }); expect(presence).not.toBeNull(); expect(presence?.status).toBe("online"); expect(presence?.activities).toHaveLength(1); expect(presence?.activities[0]).toMatchObject({ type: 4, name: "Custom Status", state: "Focus time", }); }); it("includes streaming url when activityType is streaming", () => { const presence = resolveDiscordPresenceUpdate({ activity: "Live", activityType: 1, activityUrl: "https://twitch.tv/openclaw", }); expect(presence).not.toBeNull(); expect(presence?.activities).toHaveLength(1); expect(presence?.activities[0]).toMatchObject({ type: 1, name: "Live", url: "https://twitch.tv/openclaw", }); }); }); describe("resolveDiscordAutoThreadContext", () => { it("returns null without a created thread and re-keys context when present", () => { const cases = [ { name: "no created thread", createdThreadId: undefined, expectedNull: true, }, { name: "created thread", createdThreadId: "thread", expectedNull: false, }, ] as const; for (const testCase of cases) { const context = resolveDiscordAutoThreadContext({ agentId: "agent", channel: "discord", messageChannelId: "parent", createdThreadId: testCase.createdThreadId, }); if (testCase.expectedNull) { expect(context, testCase.name).toBeNull(); continue; } expect(context, testCase.name).not.toBeNull(); expect(context?.To, testCase.name).toBe("channel:thread"); expect(context?.From, testCase.name).toBe("discord:channel:thread"); expect(context?.OriginatingTo, testCase.name).toBe("channel:thread"); expect(context?.SessionKey, testCase.name).toBe( buildAgentSessionKey({ agentId: "agent", channel: "discord", peer: { kind: "channel", id: "thread" }, }), ); expect(context?.ParentSessionKey, testCase.name).toBe( buildAgentSessionKey({ agentId: "agent", channel: "discord", peer: { kind: "channel", id: "parent" }, }), ); } }); }); describe("resolveDiscordReplyDeliveryPlan", () => { it("applies delivery targets and reply reference behavior across thread modes", () => { const cases = [ { name: "original target with reply references", input: { replyTarget: "channel:parent" as const, replyToMode: "all" as const, messageId: "m1", threadChannel: null, createdThreadId: null, }, expectedDeliverTarget: "channel:parent", expectedReplyTarget: "channel:parent", expectedReplyReferenceCalls: ["m1"], }, { name: "created thread disables reply references", input: { replyTarget: "channel:parent" as const, replyToMode: "all" as const, messageId: "m1", threadChannel: null, createdThreadId: "thread", }, expectedDeliverTarget: "channel:thread", expectedReplyTarget: "channel:thread", expectedReplyReferenceCalls: [undefined], }, { name: "thread + off mode", input: { replyTarget: "channel:thread" as const, replyToMode: "off" as const, messageId: "m1", threadChannel: { id: "thread" }, createdThreadId: null, }, expectedDeliverTarget: "channel:thread", expectedReplyTarget: "channel:thread", expectedReplyReferenceCalls: [undefined], }, { name: "thread + all mode", input: { replyTarget: "channel:thread" as const, replyToMode: "all" as const, messageId: "m1", threadChannel: { id: "thread" }, createdThreadId: null, }, expectedDeliverTarget: "channel:thread", expectedReplyTarget: "channel:thread", expectedReplyReferenceCalls: ["m1", "m1"], }, { name: "thread + first mode", input: { replyTarget: "channel:thread" as const, replyToMode: "first" as const, messageId: "m1", threadChannel: { id: "thread" }, createdThreadId: null, }, expectedDeliverTarget: "channel:thread", expectedReplyTarget: "channel:thread", expectedReplyReferenceCalls: ["m1", undefined], }, ] as const; for (const testCase of cases) { const plan = resolveDiscordReplyDeliveryPlan(testCase.input); expect(plan.deliverTarget, testCase.name).toBe(testCase.expectedDeliverTarget); expect(plan.replyTarget, testCase.name).toBe(testCase.expectedReplyTarget); for (const expected of testCase.expectedReplyReferenceCalls) { expect(plan.replyReference.use(), testCase.name).toBe(expected); } } }); }); describe("maybeCreateDiscordAutoThread", () => { function createAutoThreadParams(client: Client) { return { client, message: { id: "m1", channelId: "parent", } as unknown as import("./listeners.js").DiscordMessageEvent["message"], isGuildMessage: true, channelConfig: { autoThread: true, } as unknown as DiscordChannelConfigResolved, threadChannel: null, baseText: "hello", combinedBody: "hello", }; } it("handles create-thread failures with and without an existing thread", async () => { const cases = [ { name: "race condition returns existing thread", postError: "A thread has already been created on this message", getResponse: { thread: { id: "existing-thread" } }, expected: "existing-thread", }, { name: "other error returns undefined", postError: "Some other error", getResponse: { thread: null }, expected: undefined, }, ] as const; for (const testCase of cases) { const client = { rest: { post: async () => { throw new Error(testCase.postError); }, get: async () => testCase.getResponse, }, } as unknown as Client; const result = await maybeCreateDiscordAutoThread(createAutoThreadParams(client)); expect(result, testCase.name).toBe(testCase.expected); } }); }); describe("resolveDiscordAutoThreadReplyPlan", () => { function createAutoThreadPlanParams(overrides?: { client?: Client; channelConfig?: DiscordChannelConfigResolved; threadChannel?: { id: string } | null; }) { return { client: overrides?.client ?? ({ rest: { post: async () => ({ id: "thread" }) } } as unknown as Client), message: { id: "m1", channelId: "parent", } as unknown as import("./listeners.js").DiscordMessageEvent["message"], isGuildMessage: true, channelConfig: overrides?.channelConfig ?? ({ autoThread: true } as unknown as DiscordChannelConfigResolved), threadChannel: overrides?.threadChannel ?? null, baseText: "hello", combinedBody: "hello", replyToMode: "all" as const, agentId: "agent", channel: "discord" as const, }; } it("applies auto-thread reply planning across created, existing, and disabled modes", async () => { const cases = [ { name: "created thread", params: undefined, expectedDeliverTarget: "channel:thread", expectedReplyReference: undefined, expectedSessionKey: buildAgentSessionKey({ agentId: "agent", channel: "discord", peer: { kind: "channel", id: "thread" }, }), }, { name: "existing thread channel", params: { threadChannel: { id: "thread" }, }, expectedDeliverTarget: "channel:thread", expectedReplyReference: "m1", expectedSessionKey: null, }, { name: "autoThread disabled", params: { channelConfig: { autoThread: false } as unknown as DiscordChannelConfigResolved, }, expectedDeliverTarget: "channel:parent", expectedReplyReference: "m1", expectedSessionKey: null, }, ] as const; for (const testCase of cases) { const plan = await resolveDiscordAutoThreadReplyPlan( createAutoThreadPlanParams(testCase.params), ); expect(plan.deliverTarget, testCase.name).toBe(testCase.expectedDeliverTarget); expect(plan.replyReference.use(), testCase.name).toBe(testCase.expectedReplyReference); if (testCase.expectedSessionKey == null) { expect(plan.autoThreadContext, testCase.name).toBeNull(); } else { expect(plan.autoThreadContext?.SessionKey, testCase.name).toBe(testCase.expectedSessionKey); } } }); });