mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 03:11:10 +00:00
test(discord): add acp bind flow integration coverage
This commit is contained in:
239
extensions/discord/src/monitor/acp-bind-here.integration.test.ts
Normal file
239
extensions/discord/src/monitor/acp-bind-here.integration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user