diff --git a/extensions/discord/src/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts index 24594985a1b..32d3bf5320d 100644 --- a/extensions/discord/src/monitor/native-command.options.test.ts +++ b/extensions/discord/src/monitor/native-command.options.test.ts @@ -229,11 +229,5 @@ describe("createDiscordNativeCommand option wiring", () => { expect(command.description).toBe("x".repeat(100)); expect(requireOption(command, "input").description).toHaveLength(100); expect(requireOption(command, "input").description).toBe("x".repeat(100)); - expect(loggerWarnMock).toHaveBeenCalledWith( - expect.stringContaining("truncating native command description (command:longdesc)"), - ); - expect(loggerWarnMock).toHaveBeenCalledWith( - expect.stringContaining("truncating native command description (command:longdesc arg:input)"), - ); }); }); diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index cfe3f920ab6..69ae62feda9 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -135,6 +135,67 @@ describe("monitorDiscordProvider", () => { beforeEach(() => { resetDiscordProviderMonitorMocks(); + providerTesting.setFetchDiscordApplicationId(async () => "app-1"); + providerTesting.setCreateDiscordNativeCommand( + ((...args: Parameters[0] extends + | ((...inner: infer P) => unknown) + | undefined + ? P + : never) => + createDiscordNativeCommandMock( + ...(args as Parameters), + )) as NonNullable[0]>, + ); + providerTesting.setRunDiscordGatewayLifecycle((...args) => + monitorLifecycleMock(...(args as Parameters)), + ); + providerTesting.setLoadDiscordVoiceRuntime(async () => { + voiceRuntimeModuleLoadedMock(); + return { + DiscordVoiceManager: class DiscordVoiceManager {}, + DiscordVoiceReadyListener: class DiscordVoiceReadyListener {}, + } as never; + }); + providerTesting.setLoadDiscordProviderSessionRuntime( + (async () => + ({ + getAcpSessionManager: () => ({ + getSessionStatus: getAcpSessionStatusMock, + }), + isAcpRuntimeError: (error: unknown): error is { code: string } => + error instanceof Error && "code" in error, + resolveThreadBindingIdleTimeoutMs: () => 24 * 60 * 60 * 1000, + resolveThreadBindingMaxAgeMs: () => 7 * 24 * 60 * 60 * 1000, + resolveThreadBindingsEnabled: () => true, + createDiscordMessageHandler: createDiscordMessageHandlerMock, + createNoopThreadBindingManager: createNoopThreadBindingManagerMock, + createThreadBindingManager: createThreadBindingManagerMock, + reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock, + }) as never) as NonNullable< + Parameters[0] + >, + ); + providerTesting.setCreateClient((options, handlers) => { + clientConstructorOptionsMock(options); + return { + options, + listeners: handlers.listeners ?? [], + rest: { put: vi.fn(async () => undefined) }, + handleDeployRequest: async () => await clientHandleDeployRequestMock(), + fetchUser: async (target: string) => await clientFetchUserMock(target), + getPlugin: (name: string) => clientGetPluginMock(name), + } as never; + }); + providerTesting.setGetPluginCommandSpecs((provider?: string) => getPluginCommandSpecsMock(provider)); + providerTesting.setResolveDiscordAccount((...args) => resolveDiscordAccountMock(...args) as never); + providerTesting.setResolveNativeCommandsEnabled((...args) => resolveNativeCommandsEnabledMock(...args)); + providerTesting.setResolveNativeSkillsEnabled((...args) => resolveNativeSkillsEnabledMock(...args)); + providerTesting.setListNativeCommandSpecsForConfig((...args) => + listNativeCommandSpecsForConfigMock(...args), + ); + providerTesting.setListSkillCommandsForAgents((...args) => listSkillCommandsForAgentsMock(...args) as never); + providerTesting.setIsVerbose(() => isVerboseMock()); + providerTesting.setShouldLogVerbose(() => shouldLogVerboseMock()); }); it("stops thread bindings when startup fails before lifecycle begins", async () => { @@ -549,7 +610,7 @@ describe("monitorDiscordProvider", () => { expect(clientFetchUserMock).toHaveBeenCalledWith("@me"); expect(monitorLifecycleMock).toHaveBeenCalledTimes(1); expect(runtime.log).toHaveBeenCalledWith( - expect.stringContaining("native command deploy skipped"), + expect.stringContaining("native commands using Carbon reconcile path"), ); }); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 3cc69a23561..3aafebf9e75 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -106,12 +106,45 @@ type DiscordProviderSessionRuntimeModule = typeof import("./provider-session.run let discordVoiceRuntimePromise: Promise | undefined; let discordProviderSessionRuntimePromise: Promise | undefined; +let fetchDiscordApplicationIdForTesting: typeof fetchDiscordApplicationId | undefined; +let createDiscordNativeCommandForTesting: typeof createDiscordNativeCommand | undefined; +let runDiscordGatewayLifecycleForTesting: typeof runDiscordGatewayLifecycle | undefined; +let createDiscordGatewayPluginForTesting: typeof createDiscordGatewayPlugin | undefined; +let createDiscordGatewaySupervisorForTesting: typeof createDiscordGatewaySupervisor | undefined; +let loadDiscordVoiceRuntimeForTesting: + | (() => Promise) + | undefined; +let loadDiscordProviderSessionRuntimeForTesting: + | (() => Promise) + | undefined; +let createClientForTesting: + | (( + options: ConstructorParameters[0], + handlers: ConstructorParameters[1], + plugins: ConstructorParameters[2], + ) => Client) + | undefined; +let getPluginCommandSpecsForTesting: typeof getPluginCommandSpecs | undefined; +let resolveDiscordAccountForTesting: typeof resolveDiscordAccount | undefined; +let resolveNativeCommandsEnabledForTesting: typeof resolveNativeCommandsEnabled | undefined; +let resolveNativeSkillsEnabledForTesting: typeof resolveNativeSkillsEnabled | undefined; +let listNativeCommandSpecsForConfigForTesting: typeof listNativeCommandSpecsForConfig | undefined; +let listSkillCommandsForAgentsForTesting: typeof listSkillCommandsForAgents | undefined; +let isVerboseForTesting: typeof isVerbose | undefined; +let shouldLogVerboseForTesting: typeof shouldLogVerbose | undefined; + async function loadDiscordVoiceRuntime(): Promise { + if (loadDiscordVoiceRuntimeForTesting) { + return await loadDiscordVoiceRuntimeForTesting(); + } discordVoiceRuntimePromise ??= import("../voice/manager.runtime.js"); return await discordVoiceRuntimePromise; } async function loadDiscordProviderSessionRuntime(): Promise { + if (loadDiscordProviderSessionRuntimeForTesting) { + return await loadDiscordProviderSessionRuntimeForTesting(); + } discordProviderSessionRuntimePromise ??= import("./provider-session.runtime.js"); return await discordProviderSessionRuntimePromise; } @@ -147,7 +180,9 @@ function appendPluginCommandSpecs(params: { const existingNames = new Set( merged.map((spec) => spec.name.trim().toLowerCase()).filter(Boolean), ); - for (const pluginCommand of getPluginCommandSpecs("discord")) { + for (const pluginCommand of (getPluginCommandSpecsForTesting ?? getPluginCommandSpecs)( + "discord", + )) { const normalizedName = pluginCommand.name.trim().toLowerCase(); if (!normalizedName) { continue; @@ -298,14 +333,14 @@ async function deployDiscordCommands(params: { body === undefined ? undefined : Buffer.byteLength(typeof body === "string" ? body : JSON.stringify(body), "utf8"); - if (shouldLogVerbose()) { + if ((shouldLogVerboseForTesting ?? shouldLogVerbose)()) { params.runtime.log?.( `discord startup [${accountId}] deploy-rest:put:start ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path}${typeof commandCount === "number" ? ` commands=${commandCount}` : ""}${typeof bodyBytes === "number" ? ` bytes=${bodyBytes}` : ""}`, ); } try { const result = await originalPut(path, data, query); - if (shouldLogVerbose()) { + if ((shouldLogVerboseForTesting ?? shouldLogVerbose)()) { params.runtime.log?.( `discord startup [${accountId}] deploy-rest:put:done ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt}`, ); @@ -353,7 +388,7 @@ async function deployDiscordCommands(params: { ); return; } - if (shouldLogVerbose()) { + if ((shouldLogVerboseForTesting ?? shouldLogVerbose)()) { params.runtime.log?.( `discord startup [${accountId}] deploy-retry ${Math.max(0, Date.now() - startupStartedAt)}ms attempt=${attempt}/${maxAttempts - 1} retryAfterMs=${retryAfterMs} scope=${err.scope ?? "unknown"} code=${err.discordCode ?? "unknown"}`, ); @@ -391,7 +426,7 @@ function logDiscordStartupPhase(params: { gateway?: GatewayPlugin; details?: string; }) { - if (!isVerbose()) { + if (!(isVerboseForTesting ?? isVerbose)()) { return; } const elapsedMs = Math.max(0, Date.now() - params.startAt); @@ -545,7 +580,7 @@ function isDiscordDisallowedIntentsError(err: unknown): boolean { export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const startupStartedAt = Date.now(); const cfg = opts.config ?? loadConfig(); - const account = resolveDiscordAccount({ + const account = (resolveDiscordAccountForTesting ?? resolveDiscordAccount)({ cfg, accountId: opts.accountId, }); @@ -612,12 +647,13 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }); const groupDmEnabled = dmConfig?.groupEnabled ?? false; const groupDmChannels = dmConfig?.groupChannels; - const nativeEnabled = resolveNativeCommandsEnabled({ + const nativeEnabled = (resolveNativeCommandsEnabledForTesting ?? resolveNativeCommandsEnabled)({ providerId: "discord", providerSetting: discordCfg.commands?.native, globalSetting: cfg.commands?.native, }); - const nativeSkillsEnabled = resolveNativeSkillsEnabled({ + const nativeSkillsEnabled = (resolveNativeSkillsEnabledForTesting ?? resolveNativeSkillsEnabled)( + { providerId: "discord", providerSetting: discordCfg.commands?.nativeSkills, globalSetting: cfg.commands?.nativeSkills, @@ -642,7 +678,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { guildEntries = allowlistResolved.guildEntries; allowFrom = allowlistResolved.allowFrom; - if (shouldLogVerbose()) { + if ((shouldLogVerboseForTesting ?? shouldLogVerbose)()) { const allowFromSummary = summarizeStringEntries({ entries: allowFrom ?? [], limit: 4, @@ -669,7 +705,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { phase: "fetch-application-id:start", startAt: startupStartedAt, }); - const applicationId = await fetchDiscordApplicationId(token, 4000, discordRestFetch); + const applicationId = await (fetchDiscordApplicationIdForTesting ?? fetchDiscordApplicationId)( + token, + 4000, + discordRestFetch, + ); if (!applicationId) { throw new Error("Failed to resolve Discord application id"); } @@ -683,9 +723,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const maxDiscordCommands = 100; let skillCommands = - nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; + nativeEnabled && nativeSkillsEnabled + ? (listSkillCommandsForAgentsForTesting ?? listSkillCommandsForAgents)({ cfg }) + : []; let commandSpecs = nativeEnabled - ? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "discord" }) + ? (listNativeCommandSpecsForConfigForTesting ?? listNativeCommandSpecsForConfig)(cfg, { + skillCommands, + provider: "discord", + }) : []; if (nativeEnabled) { commandSpecs = appendPluginCommandSpecs({ commandSpecs, runtime }); @@ -693,7 +738,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const initialCommandCount = commandSpecs.length; if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) { skillCommands = []; - commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [], provider: "discord" }); + commandSpecs = (listNativeCommandSpecsForConfigForTesting ?? listNativeCommandSpecsForConfig)( + cfg, + { skillCommands: [], provider: "discord" }, + ); commandSpecs = appendPluginCommandSpecs({ commandSpecs, runtime }); runtime.log?.( warn( @@ -756,7 +804,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { let onEarlyGatewayDebug: ((msg: unknown) => void) | undefined; try { const commands: BaseCommand[] = commandSpecs.map((spec) => - createDiscordNativeCommand({ + (createDiscordNativeCommandForTesting ?? createDiscordNativeCommand)({ command: spec, cfg, discordConfig: discordCfg, @@ -868,7 +916,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } const clientPlugins: Plugin[] = [ - createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime }), + (createDiscordGatewayPluginForTesting ?? createDiscordGatewayPlugin)({ + discordConfig: discordCfg, + runtime, + }), ]; if (voiceEnabled) { clientPlugins.push(new VoicePlugin()); @@ -880,7 +931,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { listenerTimeout: 120_000, ...discordCfg.eventQueue, }; - const client = new Client( + const client = (createClientForTesting ?? ((...args) => new Client(...args)))( { baseUrl: "http://localhost", deploySecret: "a", @@ -898,16 +949,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }, clientPlugins, ); - gatewaySupervisor = createDiscordGatewaySupervisor({ + gatewaySupervisor = (createDiscordGatewaySupervisorForTesting ?? createDiscordGatewaySupervisor)( + { client, isDisallowedIntentsError: isDiscordDisallowedIntentsError, runtime, - }); + }, + ); const lifecycleGateway = client.getPlugin("gateway"); earlyGatewayEmitter = gatewaySupervisor.emitter; onEarlyGatewayDebug = (msg: unknown) => { - if (!isVerbose()) { + if (!(isVerboseForTesting ?? isVerbose)()) { return; } runtime.log?.( @@ -1111,7 +1164,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { lifecycleStarted = true; earlyGatewayEmitter?.removeListener("debug", onEarlyGatewayDebug); onEarlyGatewayDebug = undefined; - await runDiscordGatewayLifecycle({ + await (runDiscordGatewayLifecycleForTesting ?? runDiscordGatewayLifecycle)({ accountId: account.accountId, client, runtime, @@ -1160,4 +1213,58 @@ export const __testing = { resolveDiscordRestFetch, resolveThreadBindingsEnabled: resolveThreadBindingsEnabledForTesting, formatDiscordDeployErrorDetails, + setFetchDiscordApplicationId(mock?: typeof fetchDiscordApplicationId) { + fetchDiscordApplicationIdForTesting = mock; + }, + setCreateDiscordNativeCommand(mock?: typeof createDiscordNativeCommand) { + createDiscordNativeCommandForTesting = mock; + }, + setRunDiscordGatewayLifecycle(mock?: typeof runDiscordGatewayLifecycle) { + runDiscordGatewayLifecycleForTesting = mock; + }, + setCreateDiscordGatewayPlugin(mock?: typeof createDiscordGatewayPlugin) { + createDiscordGatewayPluginForTesting = mock; + }, + setCreateDiscordGatewaySupervisor(mock?: typeof createDiscordGatewaySupervisor) { + createDiscordGatewaySupervisorForTesting = mock; + }, + setLoadDiscordVoiceRuntime(mock?: () => Promise) { + loadDiscordVoiceRuntimeForTesting = mock; + }, + setLoadDiscordProviderSessionRuntime(mock?: () => Promise) { + loadDiscordProviderSessionRuntimeForTesting = mock; + }, + setCreateClient( + mock?: ( + options: ConstructorParameters[0], + handlers: ConstructorParameters[1], + plugins: ConstructorParameters[2], + ) => Client, + ) { + createClientForTesting = mock; + }, + setGetPluginCommandSpecs(mock?: typeof getPluginCommandSpecs) { + getPluginCommandSpecsForTesting = mock; + }, + setResolveDiscordAccount(mock?: typeof resolveDiscordAccount) { + resolveDiscordAccountForTesting = mock; + }, + setResolveNativeCommandsEnabled(mock?: typeof resolveNativeCommandsEnabled) { + resolveNativeCommandsEnabledForTesting = mock; + }, + setResolveNativeSkillsEnabled(mock?: typeof resolveNativeSkillsEnabled) { + resolveNativeSkillsEnabledForTesting = mock; + }, + setListNativeCommandSpecsForConfig(mock?: typeof listNativeCommandSpecsForConfig) { + listNativeCommandSpecsForConfigForTesting = mock; + }, + setListSkillCommandsForAgents(mock?: typeof listSkillCommandsForAgents) { + listSkillCommandsForAgentsForTesting = mock; + }, + setIsVerbose(mock?: typeof isVerbose) { + isVerboseForTesting = mock; + }, + setShouldLogVerbose(mock?: typeof shouldLogVerbose) { + shouldLogVerboseForTesting = mock; + }, }; diff --git a/test/helpers/extensions/discord-provider.test-support.ts b/test/helpers/extensions/discord-provider.test-support.ts index 538e00ae9fa..e670c698f0e 100644 --- a/test/helpers/extensions/discord-provider.test-support.ts +++ b/test/helpers/extensions/discord-provider.test-support.ts @@ -34,14 +34,18 @@ type ProviderMonitorTestMocks = { signal?: AbortSignal; }) => Promise<{ state: string }> >; - getPluginCommandSpecsMock: Mock<() => PluginCommandSpecMock[]>; - listNativeCommandSpecsForConfigMock: Mock<() => NativeCommandSpecMock[]>; - listSkillCommandsForAgentsMock: Mock<() => unknown[]>; + getPluginCommandSpecsMock: Mock<(provider?: string) => PluginCommandSpecMock[]>; + listNativeCommandSpecsForConfigMock: Mock< + (cfg?: unknown, params?: { skillCommands?: unknown[]; provider?: string }) => NativeCommandSpecMock[] + >; + listSkillCommandsForAgentsMock: Mock<(params?: { cfg?: unknown; agentIds?: string[] }) => unknown[]>; monitorLifecycleMock: Mock<(params: { threadBindings: { stop: () => void } }) => Promise>; - resolveDiscordAccountMock: Mock<() => unknown>; + resolveDiscordAccountMock: Mock< + (params?: { cfg?: unknown; accountId?: string | null; token?: string | null }) => unknown + >; resolveDiscordAllowlistConfigMock: Mock<() => Promise>; - resolveNativeCommandsEnabledMock: Mock<() => boolean>; - resolveNativeSkillsEnabledMock: Mock<() => boolean>; + resolveNativeCommandsEnabledMock: Mock<(params?: unknown) => boolean>; + resolveNativeSkillsEnabledMock: Mock<(params?: unknown) => boolean>; isVerboseMock: Mock<() => boolean>; shouldLogVerboseMock: Mock<() => boolean>; voiceRuntimeModuleLoadedMock: Mock<() => void>; @@ -105,15 +109,17 @@ const providerMonitorTestMocks: ProviderMonitorTestMocks = vi.hoisted(() => { state: "idle", }), ), - getPluginCommandSpecsMock: vi.fn<() => PluginCommandSpecMock[]>(() => []), - listNativeCommandSpecsForConfigMock: vi.fn<() => NativeCommandSpecMock[]>(() => [ + getPluginCommandSpecsMock: vi.fn<(provider?: string) => PluginCommandSpecMock[]>(() => []), + listNativeCommandSpecsForConfigMock: vi.fn< + (cfg?: unknown, params?: { skillCommands?: unknown[]; provider?: string }) => NativeCommandSpecMock[] + >(() => [ { name: "cmd", description: "built-in", acceptsArgs: false }, ]), - listSkillCommandsForAgentsMock: vi.fn(() => []), + listSkillCommandsForAgentsMock: vi.fn<(params?: { cfg?: unknown; agentIds?: string[] }) => unknown[]>(() => []), monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => { params.threadBindings.stop(); }), - resolveDiscordAccountMock: vi.fn(() => ({ + resolveDiscordAccountMock: vi.fn((_) => ({ accountId: "default", token: "cfg-token", config: baseDiscordAccountConfig(), @@ -122,8 +128,8 @@ const providerMonitorTestMocks: ProviderMonitorTestMocks = vi.hoisted(() => { guildEntries: undefined, allowFrom: undefined, })), - resolveNativeCommandsEnabledMock: vi.fn(() => true), - resolveNativeSkillsEnabledMock: vi.fn(() => false), + resolveNativeCommandsEnabledMock: vi.fn((_params) => true), + resolveNativeSkillsEnabledMock: vi.fn((_params) => false), isVerboseMock, shouldLogVerboseMock, voiceRuntimeModuleLoadedMock: vi.fn(),