* Plugins: reject duplicate channel ids * Bindings: reject duplicate adapter registration * Plugins: fail on export id mismatch
222 lines
6.2 KiB
TypeScript
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");
|
|
});
|
|
});
|