test(discord): add acp bind flow integration coverage

This commit is contained in:
Peter Steinberger 2026-03-28 03:08:36 +00:00
parent e11a74843e
commit 21136238ce
No known key found for this signature in database
1 changed files with 239 additions and 0 deletions

View File

@ -0,0 +1,239 @@
import { ChannelType } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => loadConfigMock(),
};
});
import { __testing as acpManagerTesting } from "../../../../src/acp/control-plane/manager.js";
import { handleAcpCommand } from "../../../../src/auto-reply/reply/commands-acp.js";
import { buildCommandTestParams } from "../../../../src/auto-reply/reply/commands-spawn.test-harness.js";
import { setDefaultChannelPluginRegistryForTests } from "../../../../src/commands/channel-test-helpers.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import * as gatewayCall from "../../../../src/gateway/call.js";
import {
__testing as sessionBindingTesting,
getSessionBindingService,
registerSessionBindingAdapter,
type SessionBindingBindInput,
type SessionBindingRecord,
} from "../../../../src/infra/outbound/session-binding-service.js";
import { preflightDiscordMessage } from "./message-handler.preflight.js";
import {
createDiscordMessage,
createDiscordPreflightArgs,
type DiscordClient,
type DiscordConfig,
type DiscordMessageEvent,
} from "./message-handler.preflight.test-helpers.js";
const callGatewaySpy = vi.spyOn(gatewayCall, "callGateway");
const baseCfg = {
session: {
mainKey: "main",
scope: "per-sender",
},
acp: {
enabled: true,
dispatch: {
enabled: true,
},
backend: "acpx",
},
channels: {
discord: {
threadBindings: {
enabled: true,
},
},
},
} satisfies OpenClawConfig;
function createDiscordDmCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "discord",
Surface: "discord",
OriginatingChannel: "discord",
OriginatingTo: "user:user-1",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
function createDmClient(channelId: string): DiscordClient {
return {
fetchChannel: async (id: string) => {
if (id === channelId) {
return {
id: channelId,
type: ChannelType.DM,
};
}
return null;
},
} as unknown as DiscordClient;
}
function createInMemoryDiscordBindingAdapter() {
const bindings: SessionBindingRecord[] = [];
const bind = async (input: SessionBindingBindInput) => {
const normalizedConversation = {
...input.conversation,
parentConversationId:
input.conversation.parentConversationId ??
(input.placement === "current" ? input.conversation.conversationId : undefined),
};
const record = {
bindingId: `discord:default:${normalizedConversation.conversationId}`,
targetSessionKey: input.targetSessionKey,
targetKind: input.targetKind,
conversation: normalizedConversation,
status: "active",
boundAt: 1,
...(input.metadata ? { metadata: input.metadata } : {}),
} satisfies SessionBindingRecord;
const existingIndex = bindings.findIndex((entry) => entry.bindingId === record.bindingId);
if (existingIndex >= 0) {
bindings.splice(existingIndex, 1, record);
} else {
bindings.push(record);
}
return record;
};
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
capabilities: {
placements: ["current", "child"],
bindSupported: true,
unbindSupported: true,
},
bind,
listBySession: (targetSessionKey) =>
bindings.filter((entry) => entry.targetSessionKey === targetSessionKey),
resolveByConversation: (ref) =>
bindings.find(
(entry) =>
entry.conversation.channel === ref.channel &&
entry.conversation.accountId === ref.accountId &&
entry.conversation.conversationId === ref.conversationId,
) ?? null,
unbind: async ({ bindingId, targetSessionKey }) => {
const removed = bindings.filter(
(entry) =>
(bindingId && entry.bindingId === bindingId) ||
(targetSessionKey && entry.targetSessionKey === targetSessionKey),
);
for (const entry of removed) {
const index = bindings.findIndex((candidate) => candidate.bindingId === entry.bindingId);
if (index >= 0) {
bindings.splice(index, 1);
}
}
return removed;
},
});
return { bindings };
}
describe("Discord ACP bind here end-to-end flow", () => {
beforeEach(() => {
setDefaultChannelPluginRegistryForTests();
sessionBindingTesting.resetSessionBindingAdaptersForTests();
acpManagerTesting.resetAcpSessionManagerForTests();
loadConfigMock.mockReset().mockReturnValue(baseCfg);
callGatewaySpy.mockReset().mockResolvedValue({ ok: true } as never);
acpManagerTesting.setAcpSessionManagerForTests({
initializeSession: async (input: { sessionKey: string; agent: string; mode: string }) => ({
runtime: {} as never,
handle: {
backend: "acpx",
runtimeSessionName: `${input.sessionKey}:runtime`,
},
meta: {
backend: "acpx",
agent: input.agent,
runtimeSessionName: `${input.sessionKey}:runtime`,
mode: input.mode,
state: "idle",
lastActivityAt: 1,
},
}),
});
});
it("binds a Discord DM through /acp spawn --bind here and routes the next DM turn to that ACP session", async () => {
const adapter = createInMemoryDiscordBindingAdapter();
const commandResult = await handleAcpCommand(
createDiscordDmCommandParams("/acp spawn codex --bind here"),
true,
);
expect(commandResult?.reply?.text).toContain("Bound this conversation to");
expect(adapter.bindings).toHaveLength(1);
const binding = adapter.bindings[0];
expect(binding).toMatchObject({
targetSessionKey: expect.stringMatching(/^agent:codex:acp:/),
conversation: {
channel: "discord",
accountId: "default",
conversationId: "user:user-1",
parentConversationId: "user:user-1",
},
});
expect(
getSessionBindingService().resolveByConversation({
channel: "discord",
accountId: "default",
conversationId: "user:user-1",
}),
)?.toMatchObject({
targetSessionKey: binding.targetSessionKey,
});
const message = createDiscordMessage({
id: "m-followup-1",
channelId: "dm-1",
content: "follow up after bind",
author: {
id: "user-1",
bot: false,
username: "alice",
},
});
const preflight = await preflightDiscordMessage({
...createDiscordPreflightArgs({
cfg: baseCfg,
discordConfig: {
dmPolicy: "open",
} as DiscordConfig,
data: {
channel_id: "dm-1",
author: message.author,
message,
} as DiscordMessageEvent,
client: createDmClient("dm-1"),
botUserId: "bot-1",
}),
});
expect(preflight).not.toBeNull();
expect(preflight?.boundSessionKey).toBe(binding.targetSessionKey);
expect(preflight?.route.sessionKey).toBe(binding.targetSessionKey);
expect(preflight?.route.agentId).toBe("codex");
});
});