openclaw/src/infra/outbound/session-binding-service.test.ts
Vincent Koc bcbfbb831e
Plugins: fail fast on channel and binding collisions (#45628)
* Plugins: reject duplicate channel ids

* Bindings: reject duplicate adapter registration

* Plugins: fail on export id mismatch
2026-03-13 19:13:35 -07:00

222 lines
6.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import {
__testing,
getSessionBindingService,
isSessionBindingError,
registerSessionBindingAdapter,
type SessionBindingBindInput,
type SessionBindingRecord,
} from "./session-binding-service.js";
function createRecord(input: SessionBindingBindInput): SessionBindingRecord {
const conversationId =
input.placement === "child"
? "thread-created"
: input.conversation.conversationId.trim() || "thread-current";
return {
bindingId: `default:${conversationId}`,
targetSessionKey: input.targetSessionKey,
targetKind: input.targetKind,
conversation: {
channel: "discord",
accountId: "default",
conversationId,
parentConversationId: input.conversation.parentConversationId?.trim() || undefined,
},
status: "active",
boundAt: 1,
};
}
describe("session binding service", () => {
beforeEach(() => {
__testing.resetSessionBindingAdaptersForTests();
});
it("normalizes conversation refs and infers current placement", async () => {
const bind = vi.fn(async (input: SessionBindingBindInput) => createRecord(input));
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
bind,
listBySession: () => [],
resolveByConversation: () => null,
});
const result = await getSessionBindingService().bind({
targetSessionKey: "agent:main:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "Discord",
accountId: "DEFAULT",
conversationId: " thread-1 ",
},
});
expect(result.conversation.channel).toBe("discord");
expect(result.conversation.accountId).toBe("default");
expect(bind).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "discord",
accountId: "default",
conversationId: "thread-1",
}),
}),
);
});
it("supports explicit child placement when adapter advertises it", async () => {
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
capabilities: { placements: ["child"] },
bind: async (input) => createRecord(input),
listBySession: () => [],
resolveByConversation: () => null,
});
const result = await getSessionBindingService().bind({
targetSessionKey: "agent:codex:acp:1",
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
},
placement: "child",
});
expect(result.conversation.conversationId).toBe("thread-created");
});
it("returns structured errors when adapter is unavailable", async () => {
await expect(
getSessionBindingService().bind({
targetSessionKey: "agent:main:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
},
}),
).rejects.toMatchObject({
code: "BINDING_ADAPTER_UNAVAILABLE",
});
});
it("returns structured errors for unsupported placement", async () => {
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
capabilities: { placements: ["current"] },
bind: async (input) => createRecord(input),
listBySession: () => [],
resolveByConversation: () => null,
});
const rejected = await getSessionBindingService()
.bind({
targetSessionKey: "agent:codex:acp:1",
targetKind: "session",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
},
placement: "child",
})
.catch((error) => error);
expect(isSessionBindingError(rejected)).toBe(true);
expect(rejected).toMatchObject({
code: "BINDING_CAPABILITY_UNSUPPORTED",
details: {
placement: "child",
},
});
});
it("returns structured errors when adapter bind fails", async () => {
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
bind: async () => null,
listBySession: () => [],
resolveByConversation: () => null,
});
await expect(
getSessionBindingService().bind({
targetSessionKey: "agent:main:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "discord",
accountId: "default",
conversationId: "thread-1",
},
}),
).rejects.toMatchObject({
code: "BINDING_CREATE_FAILED",
});
});
it("reports adapter capabilities for command preflight messaging", () => {
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
capabilities: {
placements: ["current", "child"],
},
bind: async (input) => createRecord(input),
listBySession: () => [],
resolveByConversation: () => null,
unbind: async () => [],
});
const known = getSessionBindingService().getCapabilities({
channel: "discord",
accountId: "default",
});
const unknown = getSessionBindingService().getCapabilities({
channel: "discord",
accountId: "other",
});
expect(known).toEqual({
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
});
expect(unknown).toEqual({
adapterAvailable: false,
bindSupported: false,
unbindSupported: false,
placements: [],
});
});
it("rejects duplicate adapter registration for the same channel account", () => {
registerSessionBindingAdapter({
channel: "discord",
accountId: "default",
bind: async (input) => createRecord(input),
listBySession: () => [],
resolveByConversation: () => null,
});
expect(() =>
registerSessionBindingAdapter({
channel: "Discord",
accountId: "DEFAULT",
bind: async (input) => createRecord(input),
listBySession: () => [],
resolveByConversation: () => null,
}),
).toThrow("Session binding adapter already registered for discord:default");
});
});