fix(ci): restore discord provider test seams

This commit is contained in:
Tak Hoffman
2026-03-26 15:58:25 -05:00
parent 37894d0f1a
commit aeee72426d
4 changed files with 207 additions and 39 deletions

View File

@@ -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)"),
);
});
});

View File

@@ -135,6 +135,67 @@ describe("monitorDiscordProvider", () => {
beforeEach(() => {
resetDiscordProviderMonitorMocks();
providerTesting.setFetchDiscordApplicationId(async () => "app-1");
providerTesting.setCreateDiscordNativeCommand(
((...args: Parameters<typeof providerTesting.setCreateDiscordNativeCommand>[0] extends
| ((...inner: infer P) => unknown)
| undefined
? P
: never) =>
createDiscordNativeCommandMock(
...(args as Parameters<typeof createDiscordNativeCommandMock>),
)) as NonNullable<Parameters<typeof providerTesting.setCreateDiscordNativeCommand>[0]>,
);
providerTesting.setRunDiscordGatewayLifecycle((...args) =>
monitorLifecycleMock(...(args as Parameters<typeof monitorLifecycleMock>)),
);
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<typeof providerTesting.setLoadDiscordProviderSessionRuntime>[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"),
);
});

View File

@@ -106,12 +106,45 @@ type DiscordProviderSessionRuntimeModule = typeof import("./provider-session.run
let discordVoiceRuntimePromise: Promise<DiscordVoiceRuntimeModule> | undefined;
let discordProviderSessionRuntimePromise: Promise<DiscordProviderSessionRuntimeModule> | 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<DiscordVoiceRuntimeModule>)
| undefined;
let loadDiscordProviderSessionRuntimeForTesting:
| (() => Promise<DiscordProviderSessionRuntimeModule>)
| undefined;
let createClientForTesting:
| ((
options: ConstructorParameters<typeof Client>[0],
handlers: ConstructorParameters<typeof Client>[1],
plugins: ConstructorParameters<typeof Client>[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<DiscordVoiceRuntimeModule> {
if (loadDiscordVoiceRuntimeForTesting) {
return await loadDiscordVoiceRuntimeForTesting();
}
discordVoiceRuntimePromise ??= import("../voice/manager.runtime.js");
return await discordVoiceRuntimePromise;
}
async function loadDiscordProviderSessionRuntime(): Promise<DiscordProviderSessionRuntimeModule> {
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<GatewayPlugin>("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<DiscordVoiceRuntimeModule>) {
loadDiscordVoiceRuntimeForTesting = mock;
},
setLoadDiscordProviderSessionRuntime(mock?: () => Promise<DiscordProviderSessionRuntimeModule>) {
loadDiscordProviderSessionRuntimeForTesting = mock;
},
setCreateClient(
mock?: (
options: ConstructorParameters<typeof Client>[0],
handlers: ConstructorParameters<typeof Client>[1],
plugins: ConstructorParameters<typeof Client>[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;
},
};

View File

@@ -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<void>>;
resolveDiscordAccountMock: Mock<() => unknown>;
resolveDiscordAccountMock: Mock<
(params?: { cfg?: unknown; accountId?: string | null; token?: string | null }) => unknown
>;
resolveDiscordAllowlistConfigMock: Mock<() => Promise<unknown>>;
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(),