mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 03:42:43 +00:00
test: dedupe channel and transport adapters
This commit is contained in:
@@ -424,45 +424,27 @@ describe("discord mention gating", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require mention inside autoThread threads", () => {
|
||||
const { guildInfo, channelConfig } = createAutoThreadMentionContext();
|
||||
expect(
|
||||
resolveDiscordShouldRequireMention({
|
||||
isGuildMessage: true,
|
||||
isThread: true,
|
||||
botId: "bot123",
|
||||
threadOwnerId: "bot123",
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
it("applies autoThread mention rules based on thread ownership", () => {
|
||||
const cases = [
|
||||
{ name: "bot-owned thread", threadOwnerId: "bot123", expected: false },
|
||||
{ name: "user-owned thread", threadOwnerId: "user456", expected: true },
|
||||
{ name: "unknown thread owner", threadOwnerId: undefined, expected: true },
|
||||
] as const;
|
||||
|
||||
it("requires mention inside user-created threads with autoThread enabled", () => {
|
||||
const { guildInfo, channelConfig } = createAutoThreadMentionContext();
|
||||
expect(
|
||||
resolveDiscordShouldRequireMention({
|
||||
isGuildMessage: true,
|
||||
isThread: true,
|
||||
botId: "bot123",
|
||||
threadOwnerId: "user456",
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires mention when thread owner is unknown", () => {
|
||||
const { guildInfo, channelConfig } = createAutoThreadMentionContext();
|
||||
expect(
|
||||
resolveDiscordShouldRequireMention({
|
||||
isGuildMessage: true,
|
||||
isThread: true,
|
||||
botId: "bot123",
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
}),
|
||||
).toBe(true);
|
||||
for (const testCase of cases) {
|
||||
const { guildInfo, channelConfig } = createAutoThreadMentionContext();
|
||||
expect(
|
||||
resolveDiscordShouldRequireMention({
|
||||
isGuildMessage: true,
|
||||
isThread: true,
|
||||
botId: "bot123",
|
||||
threadOwnerId: testCase.threadOwnerId,
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
}),
|
||||
testCase.name,
|
||||
).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("inherits parent channel mention rules for threads", () => {
|
||||
@@ -496,70 +478,73 @@ describe("discord mention gating", () => {
|
||||
});
|
||||
|
||||
describe("discord groupPolicy gating", () => {
|
||||
it("allows when policy is open", () => {
|
||||
expect(
|
||||
isDiscordGroupAllowedByPolicy({
|
||||
groupPolicy: "open",
|
||||
guildAllowlisted: false,
|
||||
channelAllowlistConfigured: false,
|
||||
channelAllowed: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
it("applies open/disabled/allowlist policy rules", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "open policy always allows",
|
||||
input: {
|
||||
groupPolicy: "open" as const,
|
||||
guildAllowlisted: false,
|
||||
channelAllowlistConfigured: false,
|
||||
channelAllowed: false,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "disabled policy always blocks",
|
||||
input: {
|
||||
groupPolicy: "disabled" as const,
|
||||
guildAllowlisted: true,
|
||||
channelAllowlistConfigured: true,
|
||||
channelAllowed: true,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "allowlist blocks when guild not allowlisted",
|
||||
input: {
|
||||
groupPolicy: "allowlist" as const,
|
||||
guildAllowlisted: false,
|
||||
channelAllowlistConfigured: false,
|
||||
channelAllowed: true,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "allowlist allows when guild allowlisted and no channel allowlist",
|
||||
input: {
|
||||
groupPolicy: "allowlist" as const,
|
||||
guildAllowlisted: true,
|
||||
channelAllowlistConfigured: false,
|
||||
channelAllowed: true,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "allowlist allows when channel is allowed",
|
||||
input: {
|
||||
groupPolicy: "allowlist" as const,
|
||||
guildAllowlisted: true,
|
||||
channelAllowlistConfigured: true,
|
||||
channelAllowed: true,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "allowlist blocks when channel is not allowed",
|
||||
input: {
|
||||
groupPolicy: "allowlist" as const,
|
||||
guildAllowlisted: true,
|
||||
channelAllowlistConfigured: true,
|
||||
channelAllowed: false,
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("blocks when policy is disabled", () => {
|
||||
expect(
|
||||
isDiscordGroupAllowedByPolicy({
|
||||
groupPolicy: "disabled",
|
||||
guildAllowlisted: true,
|
||||
channelAllowlistConfigured: true,
|
||||
channelAllowed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks allowlist when guild is not allowlisted", () => {
|
||||
expect(
|
||||
isDiscordGroupAllowedByPolicy({
|
||||
groupPolicy: "allowlist",
|
||||
guildAllowlisted: false,
|
||||
channelAllowlistConfigured: false,
|
||||
channelAllowed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows allowlist when guild allowlisted but no channel allowlist", () => {
|
||||
expect(
|
||||
isDiscordGroupAllowedByPolicy({
|
||||
groupPolicy: "allowlist",
|
||||
guildAllowlisted: true,
|
||||
channelAllowlistConfigured: false,
|
||||
channelAllowed: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist when channel is allowed", () => {
|
||||
expect(
|
||||
isDiscordGroupAllowedByPolicy({
|
||||
groupPolicy: "allowlist",
|
||||
guildAllowlisted: true,
|
||||
channelAllowlistConfigured: true,
|
||||
channelAllowed: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks allowlist when channel is not allowed", () => {
|
||||
expect(
|
||||
isDiscordGroupAllowedByPolicy({
|
||||
groupPolicy: "allowlist",
|
||||
guildAllowlisted: true,
|
||||
channelAllowlistConfigured: true,
|
||||
channelAllowed: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
for (const testCase of cases) {
|
||||
expect(isDiscordGroupAllowedByPolicy(testCase.input), testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -596,48 +581,45 @@ describe("discord group DM gating", () => {
|
||||
});
|
||||
|
||||
describe("discord reply target selection", () => {
|
||||
it("skips replies when mode is off", () => {
|
||||
expect(
|
||||
resolveDiscordReplyTarget({
|
||||
replyToMode: "off",
|
||||
replyToId: "123",
|
||||
it("handles off/first/all reply modes", () => {
|
||||
const cases = [
|
||||
{ name: "off mode", replyToMode: "off" as const, hasReplied: false, expected: undefined },
|
||||
{
|
||||
name: "first mode before reply",
|
||||
replyToMode: "first" as const,
|
||||
hasReplied: false,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replies only once when mode is first", () => {
|
||||
expect(
|
||||
resolveDiscordReplyTarget({
|
||||
replyToMode: "first",
|
||||
replyToId: "123",
|
||||
hasReplied: false,
|
||||
}),
|
||||
).toBe("123");
|
||||
expect(
|
||||
resolveDiscordReplyTarget({
|
||||
replyToMode: "first",
|
||||
replyToId: "123",
|
||||
expected: "123",
|
||||
},
|
||||
{
|
||||
name: "first mode after reply",
|
||||
replyToMode: "first" as const,
|
||||
hasReplied: true,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replies on every message when mode is all", () => {
|
||||
expect(
|
||||
resolveDiscordReplyTarget({
|
||||
replyToMode: "all",
|
||||
replyToId: "123",
|
||||
expected: undefined,
|
||||
},
|
||||
{
|
||||
name: "all mode before reply",
|
||||
replyToMode: "all" as const,
|
||||
hasReplied: false,
|
||||
}),
|
||||
).toBe("123");
|
||||
expect(
|
||||
resolveDiscordReplyTarget({
|
||||
replyToMode: "all",
|
||||
replyToId: "123",
|
||||
expected: "123",
|
||||
},
|
||||
{
|
||||
name: "all mode after reply",
|
||||
replyToMode: "all" as const,
|
||||
hasReplied: true,
|
||||
}),
|
||||
).toBe("123");
|
||||
expected: "123",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
expect(
|
||||
resolveDiscordReplyTarget({
|
||||
replyToMode: testCase.replyToMode,
|
||||
replyToId: "123",
|
||||
hasReplied: testCase.hasReplied,
|
||||
}),
|
||||
testCase.name,
|
||||
).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -654,86 +636,98 @@ describe("discord autoThread name sanitization", () => {
|
||||
});
|
||||
|
||||
describe("discord reaction notification gating", () => {
|
||||
it("defaults to own when mode is unset", () => {
|
||||
expect(
|
||||
shouldEmitDiscordReactionNotification({
|
||||
mode: undefined,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "bot-1",
|
||||
userId: "user-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldEmitDiscordReactionNotification({
|
||||
mode: undefined,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-1",
|
||||
userId: "user-2",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
it("applies mode-specific reaction notification rules", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "unset defaults to own (author is bot)",
|
||||
input: {
|
||||
mode: undefined,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "bot-1",
|
||||
userId: "user-1",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "unset defaults to own (author is not bot)",
|
||||
input: {
|
||||
mode: undefined,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-1",
|
||||
userId: "user-2",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "off mode",
|
||||
input: {
|
||||
mode: "off" as const,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "bot-1",
|
||||
userId: "user-1",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "all mode",
|
||||
input: {
|
||||
mode: "all" as const,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-1",
|
||||
userId: "user-2",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "own mode with bot-authored message",
|
||||
input: {
|
||||
mode: "own" as const,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "bot-1",
|
||||
userId: "user-2",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "own mode with non-bot-authored message",
|
||||
input: {
|
||||
mode: "own" as const,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-2",
|
||||
userId: "user-3",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "allowlist mode without match",
|
||||
input: {
|
||||
mode: "allowlist" as const,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-1",
|
||||
userId: "user-2",
|
||||
allowlist: [],
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "allowlist mode with id match",
|
||||
input: {
|
||||
mode: "allowlist" as const,
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-1",
|
||||
userId: "123",
|
||||
userName: "steipete",
|
||||
allowlist: ["123", "other"],
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("skips when mode is off", () => {
|
||||
expect(
|
||||
shouldEmitDiscordReactionNotification({
|
||||
mode: "off",
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "bot-1",
|
||||
userId: "user-1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows all reactions when mode is all", () => {
|
||||
expect(
|
||||
shouldEmitDiscordReactionNotification({
|
||||
mode: "all",
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-1",
|
||||
userId: "user-2",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires bot ownership when mode is own", () => {
|
||||
expect(
|
||||
shouldEmitDiscordReactionNotification({
|
||||
mode: "own",
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "bot-1",
|
||||
userId: "user-2",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldEmitDiscordReactionNotification({
|
||||
mode: "own",
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-2",
|
||||
userId: "user-3",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires allowlist matches when mode is allowlist", () => {
|
||||
expect(
|
||||
shouldEmitDiscordReactionNotification({
|
||||
mode: "allowlist",
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-1",
|
||||
userId: "user-2",
|
||||
allowlist: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldEmitDiscordReactionNotification({
|
||||
mode: "allowlist",
|
||||
botId: "bot-1",
|
||||
messageAuthorId: "user-1",
|
||||
userId: "123",
|
||||
userName: "steipete",
|
||||
allowlist: ["123", "other"],
|
||||
}),
|
||||
).toBe(true);
|
||||
for (const testCase of cases) {
|
||||
expect(shouldEmitDiscordReactionNotification(testCase.input), testCase.name).toBe(
|
||||
testCase.expected,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -858,37 +852,37 @@ function makeReactionListenerParams(overrides?: {
|
||||
}
|
||||
|
||||
describe("discord DM reaction handling", () => {
|
||||
it("processes DM reactions instead of dropping them", async () => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
it("processes DM reactions with or without guild allowlists", async () => {
|
||||
const cases = [
|
||||
{ name: "no guild allowlist", guildEntries: undefined },
|
||||
{
|
||||
name: "guild allowlist configured",
|
||||
guildEntries: makeEntries({
|
||||
"guild-123": { slug: "guild-123" },
|
||||
}),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const data = makeReactionEvent({ botAsAuthor: true });
|
||||
const client = makeReactionClient({ channelType: ChannelType.DM });
|
||||
const listener = new DiscordReactionListener(makeReactionListenerParams());
|
||||
for (const testCase of cases) {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
|
||||
await listener.handle(data, client);
|
||||
const data = makeReactionEvent({ botAsAuthor: true });
|
||||
const client = makeReactionClient({ channelType: ChannelType.DM });
|
||||
const listener = new DiscordReactionListener(
|
||||
makeReactionListenerParams({ guildEntries: testCase.guildEntries }),
|
||||
);
|
||||
|
||||
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
|
||||
const [text, opts] = enqueueSystemEventSpy.mock.calls[0];
|
||||
expect(text).toContain("Discord reaction added");
|
||||
expect(text).toContain("👍");
|
||||
expect(opts.sessionKey).toBe("discord:acc-1:dm:user-1");
|
||||
});
|
||||
await listener.handle(data, client);
|
||||
|
||||
it("does not drop DM reactions when guild allowlist is configured", async () => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
|
||||
const data = makeReactionEvent({ botAsAuthor: true });
|
||||
const client = makeReactionClient({ channelType: ChannelType.DM });
|
||||
const guildEntries = makeEntries({
|
||||
"guild-123": { slug: "guild-123" },
|
||||
});
|
||||
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
|
||||
|
||||
await listener.handle(data, client);
|
||||
|
||||
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
|
||||
expect(enqueueSystemEventSpy, testCase.name).toHaveBeenCalledOnce();
|
||||
const [text, opts] = enqueueSystemEventSpy.mock.calls[0];
|
||||
expect(text, testCase.name).toContain("Discord reaction added");
|
||||
expect(text, testCase.name).toContain("👍");
|
||||
expect(text, testCase.name).toContain("dm");
|
||||
expect(text, testCase.name).not.toContain("undefined");
|
||||
expect(opts.sessionKey, testCase.name).toBe("discord:acc-1:dm:user-1");
|
||||
}
|
||||
});
|
||||
|
||||
it("still processes guild reactions (no regression)", async () => {
|
||||
@@ -916,22 +910,6 @@ describe("discord DM reaction handling", () => {
|
||||
expect(text).toContain("Discord reaction added");
|
||||
});
|
||||
|
||||
it("uses 'dm' in log text for DM reactions, not 'undefined'", async () => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
|
||||
const data = makeReactionEvent({ botAsAuthor: true });
|
||||
const client = makeReactionClient({ channelType: ChannelType.DM });
|
||||
const listener = new DiscordReactionListener(makeReactionListenerParams());
|
||||
|
||||
await listener.handle(data, client);
|
||||
|
||||
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
|
||||
const [text] = enqueueSystemEventSpy.mock.calls[0];
|
||||
expect(text).toContain("dm");
|
||||
expect(text).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("routes DM reactions with peer kind 'direct' and user id", async () => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
@@ -977,111 +955,102 @@ describe("discord reaction notification modes", () => {
|
||||
const guildId = "guild-900";
|
||||
const guild = fakeGuild(guildId, "Mode Guild");
|
||||
|
||||
it("skips message fetch when mode is off", async () => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
it("applies message-fetch behavior across notification modes and channel types", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "off mode",
|
||||
reactionNotifications: "off" as const,
|
||||
users: undefined,
|
||||
userId: undefined,
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId: undefined,
|
||||
parentId: undefined,
|
||||
messageAuthorId: "other-user",
|
||||
expectedMessageFetchCalls: 0,
|
||||
expectedEnqueueCalls: 0,
|
||||
},
|
||||
{
|
||||
name: "all mode",
|
||||
reactionNotifications: "all" as const,
|
||||
users: undefined,
|
||||
userId: undefined,
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId: undefined,
|
||||
parentId: undefined,
|
||||
messageAuthorId: "other-user",
|
||||
expectedMessageFetchCalls: 0,
|
||||
expectedEnqueueCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "allowlist mode",
|
||||
reactionNotifications: "allowlist" as const,
|
||||
users: ["123"],
|
||||
userId: "123",
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId: undefined,
|
||||
parentId: undefined,
|
||||
messageAuthorId: "other-user",
|
||||
expectedMessageFetchCalls: 0,
|
||||
expectedEnqueueCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "own mode",
|
||||
reactionNotifications: "own" as const,
|
||||
users: undefined,
|
||||
userId: undefined,
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId: undefined,
|
||||
parentId: undefined,
|
||||
messageAuthorId: "bot-1",
|
||||
expectedMessageFetchCalls: 1,
|
||||
expectedEnqueueCalls: 1,
|
||||
},
|
||||
{
|
||||
name: "all mode thread channel",
|
||||
reactionNotifications: "all" as const,
|
||||
users: undefined,
|
||||
userId: undefined,
|
||||
channelType: ChannelType.PublicThread,
|
||||
channelId: "thread-1",
|
||||
parentId: "parent-1",
|
||||
messageAuthorId: "other-user",
|
||||
expectedMessageFetchCalls: 0,
|
||||
expectedEnqueueCalls: 1,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const messageFetch = vi.fn(async () => ({
|
||||
author: { id: "bot-1", username: "bot", discriminator: "0" },
|
||||
}));
|
||||
const data = makeReactionEvent({ guildId, guild, messageFetch });
|
||||
const client = makeReactionClient({ channelType: ChannelType.GuildText });
|
||||
const guildEntries = makeEntries({
|
||||
[guildId]: { reactionNotifications: "off" },
|
||||
});
|
||||
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
|
||||
for (const testCase of cases) {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
|
||||
await listener.handle(data, client);
|
||||
const messageFetch = vi.fn(async () => ({
|
||||
author: { id: testCase.messageAuthorId, username: "author", discriminator: "0" },
|
||||
}));
|
||||
const data = makeReactionEvent({
|
||||
guildId,
|
||||
guild,
|
||||
userId: testCase.userId,
|
||||
channelId: testCase.channelId,
|
||||
messageFetch,
|
||||
});
|
||||
const client = makeReactionClient({
|
||||
channelType: testCase.channelType,
|
||||
parentId: testCase.parentId,
|
||||
});
|
||||
const guildEntries = makeEntries({
|
||||
[guildId]: {
|
||||
reactionNotifications: testCase.reactionNotifications,
|
||||
users: testCase.users,
|
||||
},
|
||||
});
|
||||
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
|
||||
|
||||
expect(messageFetch).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEventSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
await listener.handle(data, client);
|
||||
|
||||
it("skips message fetch when mode is all", async () => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
|
||||
const messageFetch = vi.fn(async () => ({
|
||||
author: { id: "other-user", username: "other", discriminator: "0" },
|
||||
}));
|
||||
const data = makeReactionEvent({ guildId, guild, messageFetch });
|
||||
const client = makeReactionClient({ channelType: ChannelType.GuildText });
|
||||
const guildEntries = makeEntries({
|
||||
[guildId]: { reactionNotifications: "all" },
|
||||
});
|
||||
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
|
||||
|
||||
await listener.handle(data, client);
|
||||
|
||||
expect(messageFetch).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips message fetch when mode is allowlist", async () => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
|
||||
const messageFetch = vi.fn(async () => ({
|
||||
author: { id: "other-user", username: "other", discriminator: "0" },
|
||||
}));
|
||||
const data = makeReactionEvent({ guildId, guild, userId: "123", messageFetch });
|
||||
const client = makeReactionClient({ channelType: ChannelType.GuildText });
|
||||
const guildEntries = makeEntries({
|
||||
[guildId]: { reactionNotifications: "allowlist", users: ["123"] },
|
||||
});
|
||||
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
|
||||
|
||||
await listener.handle(data, client);
|
||||
|
||||
expect(messageFetch).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("fetches message when mode is own", async () => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
|
||||
const messageFetch = vi.fn(async () => ({
|
||||
author: { id: "bot-1", username: "bot", discriminator: "0" },
|
||||
}));
|
||||
const data = makeReactionEvent({ guildId, guild, messageFetch });
|
||||
const client = makeReactionClient({ channelType: ChannelType.GuildText });
|
||||
const guildEntries = makeEntries({
|
||||
[guildId]: { reactionNotifications: "own" },
|
||||
});
|
||||
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
|
||||
|
||||
await listener.handle(data, client);
|
||||
|
||||
expect(messageFetch).toHaveBeenCalledOnce();
|
||||
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips message fetch for thread channels in all mode", async () => {
|
||||
enqueueSystemEventSpy.mockClear();
|
||||
resolveAgentRouteMock.mockClear();
|
||||
|
||||
const messageFetch = vi.fn(async () => ({
|
||||
author: { id: "other-user", username: "other", discriminator: "0" },
|
||||
}));
|
||||
const data = makeReactionEvent({
|
||||
guildId,
|
||||
guild,
|
||||
channelId: "thread-1",
|
||||
messageFetch,
|
||||
});
|
||||
const client = makeReactionClient({
|
||||
channelType: ChannelType.PublicThread,
|
||||
parentId: "parent-1",
|
||||
});
|
||||
const guildEntries = makeEntries({
|
||||
[guildId]: { reactionNotifications: "all" },
|
||||
});
|
||||
const listener = new DiscordReactionListener(makeReactionListenerParams({ guildEntries }));
|
||||
|
||||
await listener.handle(data, client);
|
||||
|
||||
expect(messageFetch).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEventSpy).toHaveBeenCalledOnce();
|
||||
expect(messageFetch, testCase.name).toHaveBeenCalledTimes(testCase.expectedMessageFetchCalls);
|
||||
expect(enqueueSystemEventSpy, testCase.name).toHaveBeenCalledTimes(
|
||||
testCase.expectedEnqueueCalls,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { ChannelType, MessageType } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
|
||||
import {
|
||||
dispatchMock,
|
||||
@@ -64,6 +64,12 @@ beforeEach(() => {
|
||||
const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000;
|
||||
|
||||
type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
|
||||
let createDiscordMessageHandler: typeof import("./monitor.js").createDiscordMessageHandler;
|
||||
let createDiscordNativeCommand: typeof import("./monitor.js").createDiscordNativeCommand;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createDiscordMessageHandler, createDiscordNativeCommand } = await import("./monitor.js"));
|
||||
});
|
||||
|
||||
function makeRuntime() {
|
||||
return {
|
||||
@@ -76,7 +82,6 @@ function makeRuntime() {
|
||||
}
|
||||
|
||||
async function createHandler(cfg: LoadedConfig) {
|
||||
const { createDiscordMessageHandler } = await import("./monitor.js");
|
||||
return createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord,
|
||||
@@ -267,7 +272,6 @@ describe("discord tool result dispatch", () => {
|
||||
"skips tool results for native slash commands",
|
||||
{ timeout: MENTION_PATTERNS_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { createDiscordNativeCommand } = await import("./monitor.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
|
||||
@@ -204,42 +204,50 @@ describe("roundtrip encoding", () => {
|
||||
// ─── extractDiscordChannelId ──────────────────────────────────────────────────
|
||||
|
||||
describe("extractDiscordChannelId", () => {
|
||||
it("extracts channel ID from standard session key", () => {
|
||||
expect(extractDiscordChannelId("agent:main:discord:channel:123456789")).toBe("123456789");
|
||||
});
|
||||
it("extracts channel IDs and rejects invalid session key inputs", () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
input: string | null | undefined;
|
||||
expected: string | null;
|
||||
}> = [
|
||||
{
|
||||
name: "standard session key",
|
||||
input: "agent:main:discord:channel:123456789",
|
||||
expected: "123456789",
|
||||
},
|
||||
{
|
||||
name: "agent-specific session key",
|
||||
input: "agent:test-agent:discord:channel:999888777",
|
||||
expected: "999888777",
|
||||
},
|
||||
{
|
||||
name: "group session key",
|
||||
input: "agent:main:discord:group:222333444",
|
||||
expected: "222333444",
|
||||
},
|
||||
{
|
||||
name: "longer session key",
|
||||
input: "agent:my-agent:discord:channel:111222333:thread:444555",
|
||||
expected: "111222333",
|
||||
},
|
||||
{
|
||||
name: "non-discord session key",
|
||||
input: "agent:main:telegram:channel:123456789",
|
||||
expected: null,
|
||||
},
|
||||
{
|
||||
name: "missing channel/group segment",
|
||||
input: "agent:main:discord:dm:123456789",
|
||||
expected: null,
|
||||
},
|
||||
{ name: "null input", input: null, expected: null },
|
||||
{ name: "undefined input", input: undefined, expected: null },
|
||||
{ name: "empty input", input: "", expected: null },
|
||||
];
|
||||
|
||||
it("extracts channel ID from agent session key", () => {
|
||||
expect(extractDiscordChannelId("agent:test-agent:discord:channel:999888777")).toBe("999888777");
|
||||
});
|
||||
|
||||
it("extracts channel ID from group session key", () => {
|
||||
expect(extractDiscordChannelId("agent:main:discord:group:222333444")).toBe("222333444");
|
||||
});
|
||||
|
||||
it("returns null for non-discord session key", () => {
|
||||
expect(extractDiscordChannelId("agent:main:telegram:channel:123456789")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for session key without channel segment", () => {
|
||||
expect(extractDiscordChannelId("agent:main:discord:dm:123456789")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for null input", () => {
|
||||
expect(extractDiscordChannelId(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for undefined input", () => {
|
||||
expect(extractDiscordChannelId(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
expect(extractDiscordChannelId("")).toBeNull();
|
||||
});
|
||||
|
||||
it("extracts from longer session keys", () => {
|
||||
expect(extractDiscordChannelId("agent:my-agent:discord:channel:111222333:thread:444555")).toBe(
|
||||
"111222333",
|
||||
);
|
||||
for (const testCase of cases) {
|
||||
expect(extractDiscordChannelId(testCase.input), testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -353,19 +361,29 @@ describe("DiscordExecApprovalHandler.shouldHandle", () => {
|
||||
// ─── DiscordExecApprovalHandler.getApprovers ──────────────────────────────────
|
||||
|
||||
describe("DiscordExecApprovalHandler.getApprovers", () => {
|
||||
it("returns configured approvers", () => {
|
||||
const handler = createHandler({ enabled: true, approvers: ["111", "222"] });
|
||||
expect(handler.getApprovers()).toEqual(["111", "222"]);
|
||||
});
|
||||
it("returns approvers for configured, empty, and undefined lists", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "configured approvers",
|
||||
config: { enabled: true, approvers: ["111", "222"] } as DiscordExecApprovalConfig,
|
||||
expected: ["111", "222"],
|
||||
},
|
||||
{
|
||||
name: "empty approvers",
|
||||
config: { enabled: true, approvers: [] } as DiscordExecApprovalConfig,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
name: "undefined approvers",
|
||||
config: { enabled: true } as DiscordExecApprovalConfig,
|
||||
expected: [],
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("returns empty array when no approvers configured", () => {
|
||||
const handler = createHandler({ enabled: true, approvers: [] });
|
||||
expect(handler.getApprovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when approvers is undefined", () => {
|
||||
const handler = createHandler({ enabled: true } as DiscordExecApprovalConfig);
|
||||
expect(handler.getApprovers()).toEqual([]);
|
||||
for (const testCase of cases) {
|
||||
const handler = createHandler(testCase.config);
|
||||
expect(handler.getApprovers(), testCase.name).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,44 +548,46 @@ describe("DiscordExecApprovalHandler target config", () => {
|
||||
mockRestDelete.mockReset();
|
||||
});
|
||||
|
||||
it("defaults target to dm when not specified", () => {
|
||||
const config: DiscordExecApprovalConfig = {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
};
|
||||
// target should be undefined, handler defaults to "dm"
|
||||
expect(config.target).toBeUndefined();
|
||||
it("accepts all target modes and defaults to dm when target is omitted", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "default target",
|
||||
config: { enabled: true, approvers: ["123"] } as DiscordExecApprovalConfig,
|
||||
expectedTarget: undefined,
|
||||
},
|
||||
{
|
||||
name: "channel target",
|
||||
config: {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
target: "channel",
|
||||
} as DiscordExecApprovalConfig,
|
||||
},
|
||||
{
|
||||
name: "both target",
|
||||
config: {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
target: "both",
|
||||
} as DiscordExecApprovalConfig,
|
||||
},
|
||||
{
|
||||
name: "dm target",
|
||||
config: {
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
target: "dm",
|
||||
} as DiscordExecApprovalConfig,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const handler = createHandler(config);
|
||||
// Handler should still handle requests (no crash on missing target)
|
||||
expect(handler.shouldHandle(createRequest())).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts target=channel in config", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
target: "channel",
|
||||
});
|
||||
expect(handler.shouldHandle(createRequest())).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts target=both in config", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
target: "both",
|
||||
});
|
||||
expect(handler.shouldHandle(createRequest())).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts target=dm in config", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
target: "dm",
|
||||
});
|
||||
expect(handler.shouldHandle(createRequest())).toBe(true);
|
||||
for (const testCase of cases) {
|
||||
if ("expectedTarget" in testCase) {
|
||||
expect(testCase.config.target, testCase.name).toBe(testCase.expectedTarget);
|
||||
}
|
||||
const handler = createHandler(testCase.config);
|
||||
expect(handler.shouldHandle(createRequest()), testCase.name).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -631,105 +631,133 @@ describe("resolveDiscordPresenceUpdate", () => {
|
||||
});
|
||||
|
||||
describe("resolveDiscordAutoThreadContext", () => {
|
||||
it("returns null when no createdThreadId", () => {
|
||||
expect(
|
||||
resolveDiscordAutoThreadContext({
|
||||
it("returns null without a created thread and re-keys context when present", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "no created thread",
|
||||
createdThreadId: undefined,
|
||||
expectedNull: true,
|
||||
},
|
||||
{
|
||||
name: "created thread",
|
||||
createdThreadId: "thread",
|
||||
expectedNull: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const context = resolveDiscordAutoThreadContext({
|
||||
agentId: "agent",
|
||||
channel: "discord",
|
||||
messageChannelId: "parent",
|
||||
createdThreadId: undefined,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
createdThreadId: testCase.createdThreadId,
|
||||
});
|
||||
|
||||
it("re-keys session context to the created thread", () => {
|
||||
const context = resolveDiscordAutoThreadContext({
|
||||
agentId: "agent",
|
||||
channel: "discord",
|
||||
messageChannelId: "parent",
|
||||
createdThreadId: "thread",
|
||||
});
|
||||
expect(context).not.toBeNull();
|
||||
expect(context?.To).toBe("channel:thread");
|
||||
expect(context?.From).toBe("discord:channel:thread");
|
||||
expect(context?.OriginatingTo).toBe("channel:thread");
|
||||
expect(context?.SessionKey).toBe(
|
||||
buildAgentSessionKey({
|
||||
agentId: "agent",
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "thread" },
|
||||
}),
|
||||
);
|
||||
expect(context?.ParentSessionKey).toBe(
|
||||
buildAgentSessionKey({
|
||||
agentId: "agent",
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "parent" },
|
||||
}),
|
||||
);
|
||||
if (testCase.expectedNull) {
|
||||
expect(context, testCase.name).toBeNull();
|
||||
continue;
|
||||
}
|
||||
|
||||
expect(context, testCase.name).not.toBeNull();
|
||||
expect(context?.To, testCase.name).toBe("channel:thread");
|
||||
expect(context?.From, testCase.name).toBe("discord:channel:thread");
|
||||
expect(context?.OriginatingTo, testCase.name).toBe("channel:thread");
|
||||
expect(context?.SessionKey, testCase.name).toBe(
|
||||
buildAgentSessionKey({
|
||||
agentId: "agent",
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "thread" },
|
||||
}),
|
||||
);
|
||||
expect(context?.ParentSessionKey, testCase.name).toBe(
|
||||
buildAgentSessionKey({
|
||||
agentId: "agent",
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "parent" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordReplyDeliveryPlan", () => {
|
||||
it("uses reply references when posting to the original target", () => {
|
||||
const plan = resolveDiscordReplyDeliveryPlan({
|
||||
replyTarget: "channel:parent",
|
||||
replyToMode: "all",
|
||||
messageId: "m1",
|
||||
threadChannel: null,
|
||||
createdThreadId: null,
|
||||
});
|
||||
expect(plan.deliverTarget).toBe("channel:parent");
|
||||
expect(plan.replyTarget).toBe("channel:parent");
|
||||
expect(plan.replyReference.use()).toBe("m1");
|
||||
});
|
||||
it("applies delivery targets and reply reference behavior across thread modes", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "original target with reply references",
|
||||
input: {
|
||||
replyTarget: "channel:parent" as const,
|
||||
replyToMode: "all" as const,
|
||||
messageId: "m1",
|
||||
threadChannel: null,
|
||||
createdThreadId: null,
|
||||
},
|
||||
expectedDeliverTarget: "channel:parent",
|
||||
expectedReplyTarget: "channel:parent",
|
||||
expectedReplyReferenceCalls: ["m1"],
|
||||
},
|
||||
{
|
||||
name: "created thread disables reply references",
|
||||
input: {
|
||||
replyTarget: "channel:parent" as const,
|
||||
replyToMode: "all" as const,
|
||||
messageId: "m1",
|
||||
threadChannel: null,
|
||||
createdThreadId: "thread",
|
||||
},
|
||||
expectedDeliverTarget: "channel:thread",
|
||||
expectedReplyTarget: "channel:thread",
|
||||
expectedReplyReferenceCalls: [undefined],
|
||||
},
|
||||
{
|
||||
name: "thread + off mode",
|
||||
input: {
|
||||
replyTarget: "channel:thread" as const,
|
||||
replyToMode: "off" as const,
|
||||
messageId: "m1",
|
||||
threadChannel: { id: "thread" },
|
||||
createdThreadId: null,
|
||||
},
|
||||
expectedDeliverTarget: "channel:thread",
|
||||
expectedReplyTarget: "channel:thread",
|
||||
expectedReplyReferenceCalls: [undefined],
|
||||
},
|
||||
{
|
||||
name: "thread + all mode",
|
||||
input: {
|
||||
replyTarget: "channel:thread" as const,
|
||||
replyToMode: "all" as const,
|
||||
messageId: "m1",
|
||||
threadChannel: { id: "thread" },
|
||||
createdThreadId: null,
|
||||
},
|
||||
expectedDeliverTarget: "channel:thread",
|
||||
expectedReplyTarget: "channel:thread",
|
||||
expectedReplyReferenceCalls: ["m1", "m1"],
|
||||
},
|
||||
{
|
||||
name: "thread + first mode",
|
||||
input: {
|
||||
replyTarget: "channel:thread" as const,
|
||||
replyToMode: "first" as const,
|
||||
messageId: "m1",
|
||||
threadChannel: { id: "thread" },
|
||||
createdThreadId: null,
|
||||
},
|
||||
expectedDeliverTarget: "channel:thread",
|
||||
expectedReplyTarget: "channel:thread",
|
||||
expectedReplyReferenceCalls: ["m1", undefined],
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("disables reply references when autoThread creates a new thread", () => {
|
||||
const plan = resolveDiscordReplyDeliveryPlan({
|
||||
replyTarget: "channel:parent",
|
||||
replyToMode: "all",
|
||||
messageId: "m1",
|
||||
threadChannel: null,
|
||||
createdThreadId: "thread",
|
||||
});
|
||||
expect(plan.deliverTarget).toBe("channel:thread");
|
||||
expect(plan.replyTarget).toBe("channel:thread");
|
||||
expect(plan.replyReference.use()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects replyToMode off even inside a thread", () => {
|
||||
const plan = resolveDiscordReplyDeliveryPlan({
|
||||
replyTarget: "channel:thread",
|
||||
replyToMode: "off",
|
||||
messageId: "m1",
|
||||
threadChannel: { id: "thread" },
|
||||
createdThreadId: null,
|
||||
});
|
||||
expect(plan.replyReference.use()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses existingId when inside a thread with replyToMode all", () => {
|
||||
const plan = resolveDiscordReplyDeliveryPlan({
|
||||
replyTarget: "channel:thread",
|
||||
replyToMode: "all",
|
||||
messageId: "m1",
|
||||
threadChannel: { id: "thread" },
|
||||
createdThreadId: null,
|
||||
});
|
||||
expect(plan.replyReference.use()).toBe("m1");
|
||||
expect(plan.replyReference.use()).toBe("m1");
|
||||
});
|
||||
|
||||
it("uses existingId only on first call with replyToMode first inside a thread", () => {
|
||||
const plan = resolveDiscordReplyDeliveryPlan({
|
||||
replyTarget: "channel:thread",
|
||||
replyToMode: "first",
|
||||
messageId: "m1",
|
||||
threadChannel: { id: "thread" },
|
||||
createdThreadId: null,
|
||||
});
|
||||
expect(plan.replyReference.use()).toBe("m1");
|
||||
expect(plan.replyReference.use()).toBeUndefined();
|
||||
for (const testCase of cases) {
|
||||
const plan = resolveDiscordReplyDeliveryPlan(testCase.input);
|
||||
expect(plan.deliverTarget, testCase.name).toBe(testCase.expectedDeliverTarget);
|
||||
expect(plan.replyTarget, testCase.name).toBe(testCase.expectedReplyTarget);
|
||||
for (const expected of testCase.expectedReplyReferenceCalls) {
|
||||
expect(plan.replyReference.use(), testCase.name).toBe(expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -751,34 +779,35 @@ describe("maybeCreateDiscordAutoThread", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("returns existing thread ID when creation fails due to race condition", async () => {
|
||||
const client = {
|
||||
rest: {
|
||||
post: async () => {
|
||||
throw new Error("A thread has already been created on this message");
|
||||
},
|
||||
get: async () => ({ thread: { id: "existing-thread" } }),
|
||||
it("handles create-thread failures with and without an existing thread", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "race condition returns existing thread",
|
||||
postError: "A thread has already been created on this message",
|
||||
getResponse: { thread: { id: "existing-thread" } },
|
||||
expected: "existing-thread",
|
||||
},
|
||||
} as unknown as Client;
|
||||
|
||||
const result = await maybeCreateDiscordAutoThread(createAutoThreadParams(client));
|
||||
|
||||
expect(result).toBe("existing-thread");
|
||||
});
|
||||
|
||||
it("returns undefined when creation fails and no existing thread found", async () => {
|
||||
const client = {
|
||||
rest: {
|
||||
post: async () => {
|
||||
throw new Error("Some other error");
|
||||
},
|
||||
get: async () => ({ thread: null }),
|
||||
{
|
||||
name: "other error returns undefined",
|
||||
postError: "Some other error",
|
||||
getResponse: { thread: null },
|
||||
expected: undefined,
|
||||
},
|
||||
} as unknown as Client;
|
||||
] as const;
|
||||
|
||||
const result = await maybeCreateDiscordAutoThread(createAutoThreadParams(client));
|
||||
for (const testCase of cases) {
|
||||
const client = {
|
||||
rest: {
|
||||
post: async () => {
|
||||
throw new Error(testCase.postError);
|
||||
},
|
||||
get: async () => testCase.getResponse,
|
||||
},
|
||||
} as unknown as Client;
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
const result = await maybeCreateDiscordAutoThread(createAutoThreadParams(client));
|
||||
expect(result, testCase.name).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -809,38 +838,50 @@ describe("resolveDiscordAutoThreadReplyPlan", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("switches delivery + session context to the created thread", async () => {
|
||||
const plan = await resolveDiscordAutoThreadReplyPlan(createAutoThreadPlanParams());
|
||||
expect(plan.deliverTarget).toBe("channel:thread");
|
||||
expect(plan.replyReference.use()).toBeUndefined();
|
||||
expect(plan.autoThreadContext?.SessionKey).toBe(
|
||||
buildAgentSessionKey({
|
||||
agentId: "agent",
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "thread" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("applies auto-thread reply planning across created, existing, and disabled modes", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "created thread",
|
||||
params: undefined,
|
||||
expectedDeliverTarget: "channel:thread",
|
||||
expectedReplyReference: undefined,
|
||||
expectedSessionKey: buildAgentSessionKey({
|
||||
agentId: "agent",
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "thread" },
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "existing thread channel",
|
||||
params: {
|
||||
threadChannel: { id: "thread" },
|
||||
},
|
||||
expectedDeliverTarget: "channel:thread",
|
||||
expectedReplyReference: "m1",
|
||||
expectedSessionKey: null,
|
||||
},
|
||||
{
|
||||
name: "autoThread disabled",
|
||||
params: {
|
||||
channelConfig: { autoThread: false } as unknown as DiscordChannelConfigResolved,
|
||||
},
|
||||
expectedDeliverTarget: "channel:parent",
|
||||
expectedReplyReference: "m1",
|
||||
expectedSessionKey: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
it("routes replies to an existing thread channel", async () => {
|
||||
const plan = await resolveDiscordAutoThreadReplyPlan(
|
||||
createAutoThreadPlanParams({
|
||||
threadChannel: { id: "thread" },
|
||||
}),
|
||||
);
|
||||
expect(plan.deliverTarget).toBe("channel:thread");
|
||||
expect(plan.replyTarget).toBe("channel:thread");
|
||||
expect(plan.replyReference.use()).toBe("m1");
|
||||
expect(plan.autoThreadContext).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing when autoThread is disabled", async () => {
|
||||
const plan = await resolveDiscordAutoThreadReplyPlan(
|
||||
createAutoThreadPlanParams({
|
||||
channelConfig: { autoThread: false } as unknown as DiscordChannelConfigResolved,
|
||||
}),
|
||||
);
|
||||
expect(plan.deliverTarget).toBe("channel:parent");
|
||||
expect(plan.autoThreadContext).toBeNull();
|
||||
for (const testCase of cases) {
|
||||
const plan = await resolveDiscordAutoThreadReplyPlan(
|
||||
createAutoThreadPlanParams(testCase.params),
|
||||
);
|
||||
expect(plan.deliverTarget, testCase.name).toBe(testCase.expectedDeliverTarget);
|
||||
expect(plan.replyReference.use(), testCase.name).toBe(testCase.expectedReplyReference);
|
||||
if (testCase.expectedSessionKey == null) {
|
||||
expect(plan.autoThreadContext, testCase.name).toBeNull();
|
||||
} else {
|
||||
expect(plan.autoThreadContext?.SessionKey, testCase.name).toBe(testCase.expectedSessionKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user