mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
7bf80dc2c6
* chore(tooling): enforce current formatter and refresh checks * chore(tooling): keep release changelog formatter-owned * chore(tooling): retain compatible Node type surface * ci: enforce formatting for docs-only changes * ci: isolate docs formatter check * chore(tooling): apply updated lint and format rules * chore(tooling): satisfy updated switch lint * style(ui): apply Linux formatter layout * test(doctor): match quiet local audio contribution * test(doctor): assert quiet output only * test(doctor): follow restored information contract
397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
// Discord tests cover threading.auto thread plugin behavior.
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ChannelType } from "../internal/discord.js";
|
|
import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
|
|
type MaybeCreateDiscordAutoThreadFn = typeof import("./threading.js").maybeCreateDiscordAutoThread;
|
|
|
|
const { generateThreadTitleMock } = vi.hoisted(() => ({
|
|
generateThreadTitleMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("./thread-title.js", () => ({
|
|
generateThreadTitle: generateThreadTitleMock,
|
|
}));
|
|
|
|
let maybeCreateDiscordAutoThread: MaybeCreateDiscordAutoThreadFn;
|
|
|
|
const postMock = vi.fn();
|
|
const getMock = vi.fn();
|
|
const patchMock = vi.fn();
|
|
const mockClient = {
|
|
rest: { post: postMock, get: getMock, patch: patchMock },
|
|
} as unknown as Parameters<MaybeCreateDiscordAutoThreadFn>[0]["client"];
|
|
const mockMessage = {
|
|
id: "msg1",
|
|
timestamp: "123",
|
|
} as unknown as Parameters<MaybeCreateDiscordAutoThreadFn>[0]["message"];
|
|
|
|
function createMockMessage(overrides: Record<string, unknown>) {
|
|
return Object.assign(
|
|
{},
|
|
mockMessage,
|
|
overrides,
|
|
) as Parameters<MaybeCreateDiscordAutoThreadFn>[0]["message"];
|
|
}
|
|
|
|
function createBaseParams(
|
|
overrides: Partial<Parameters<MaybeCreateDiscordAutoThreadFn>[0]> = {},
|
|
): Parameters<MaybeCreateDiscordAutoThreadFn>[0] {
|
|
return {
|
|
client: mockClient,
|
|
message: mockMessage,
|
|
messageChannelId: "text1",
|
|
channel: "discord",
|
|
isGuildMessage: true,
|
|
channelConfig: { allowed: true, autoThread: true },
|
|
channelType: ChannelType.GuildText,
|
|
baseText: "test",
|
|
combinedBody: "test",
|
|
cfg: EMPTY_DISCORD_TEST_CONFIG,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function flushAsyncWork() {
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
}
|
|
|
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (!value || typeof value !== "object") {
|
|
throw new Error(`expected ${label}`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function callArg(mock: unknown, callIndex: number, argIndex: number, label: string) {
|
|
const calls = (mock as { mock?: { calls?: Array<Array<unknown>> } }).mock?.calls ?? [];
|
|
const call = calls.at(callIndex);
|
|
if (!call) {
|
|
throw new Error(`Expected ${label}`);
|
|
}
|
|
return call[argIndex];
|
|
}
|
|
|
|
function expectRestBodyField(mock: unknown, field: string, expected: unknown) {
|
|
expect(callArg(mock, 0, 0, "rest path")).toBeTypeOf("string");
|
|
const options = requireRecord(callArg(mock, 0, 1, "rest options"), "rest options");
|
|
const body = requireRecord(options.body, "rest body");
|
|
expect(body[field]).toBe(expected);
|
|
}
|
|
|
|
function expectGeneratedTitleField(field: string, expected: unknown) {
|
|
const params = requireRecord(
|
|
callArg(generateThreadTitleMock, 0, 0, "thread title params"),
|
|
"thread title params",
|
|
);
|
|
expect(params[field]).toBe(expected);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
({ maybeCreateDiscordAutoThread } = await import("./threading.js"));
|
|
});
|
|
|
|
beforeEach(() => {
|
|
postMock.mockReset();
|
|
getMock.mockReset();
|
|
patchMock.mockReset();
|
|
generateThreadTitleMock.mockReset();
|
|
});
|
|
|
|
describe("maybeCreateDiscordAutoThread", () => {
|
|
it("skips auto-thread if channelType is GuildForum", async () => {
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({ channelType: ChannelType.GuildForum }),
|
|
);
|
|
expect(result).toBeUndefined();
|
|
expect(postMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips auto-thread if channelType is GuildMedia", async () => {
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({ channelType: ChannelType.GuildMedia }),
|
|
);
|
|
expect(result).toBeUndefined();
|
|
expect(postMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips auto-thread if channelType is GuildVoice", async () => {
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({ channelType: ChannelType.GuildVoice }),
|
|
);
|
|
expect(result).toBeUndefined();
|
|
expect(postMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips auto-thread if channelType is GuildStageVoice", async () => {
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({ channelType: ChannelType.GuildStageVoice }),
|
|
);
|
|
expect(result).toBeUndefined();
|
|
expect(postMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("creates auto-thread if channelType is GuildText", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
const result = await maybeCreateDiscordAutoThread(createBaseParams());
|
|
expect(result).toBe("thread1");
|
|
expect(postMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("reuses an existing message thread before creating a new one", async () => {
|
|
getMock.mockResolvedValueOnce({ thread: { id: "existing-thread" } });
|
|
const result = await maybeCreateDiscordAutoThread(createBaseParams());
|
|
|
|
expect(result).toBe("existing-thread");
|
|
expect(postMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reuses an existing message thread before skipping bot-authored messages", async () => {
|
|
getMock.mockResolvedValueOnce({ thread: { id: "existing-thread" } });
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
message: createMockMessage({
|
|
author: { bot: true },
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(result).toBe("existing-thread");
|
|
expect(postMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips creating new auto-threads for bot-authored messages", async () => {
|
|
getMock.mockResolvedValueOnce({});
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
message: createMockMessage({
|
|
author: { bot: true },
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(result).toBeUndefined();
|
|
expect(postMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still creates an auto-thread when the existing-thread lookup fails", async () => {
|
|
getMock.mockRejectedValueOnce(new Error("transient fetch failure"));
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
|
|
const result = await maybeCreateDiscordAutoThread(createBaseParams());
|
|
|
|
expect(result).toBe("thread1");
|
|
expect(postMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
|
it("uses configured autoArchiveDuration", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: "10080" },
|
|
}),
|
|
);
|
|
expectRestBodyField(postMock, "auto_archive_duration", 10080);
|
|
});
|
|
|
|
it("accepts numeric autoArchiveDuration", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: 4320 },
|
|
}),
|
|
);
|
|
expectRestBodyField(postMock, "auto_archive_duration", 4320);
|
|
});
|
|
|
|
it("defaults to 60 when autoArchiveDuration not set", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
await maybeCreateDiscordAutoThread(createBaseParams());
|
|
expectRestBodyField(postMock, "auto_archive_duration", 60);
|
|
});
|
|
});
|
|
|
|
describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
|
it("renames created thread when generated mode is enabled", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
patchMock.mockResolvedValueOnce({});
|
|
generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");
|
|
|
|
const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } } as OpenClawConfig;
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
baseText: "Need help with deploy rollout",
|
|
combinedBody: "Need help with deploy rollout",
|
|
channelName: "openclaw",
|
|
channelDescription: "OpenClaw development coordination and release planning",
|
|
channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
|
|
cfg,
|
|
agentId: "main",
|
|
}),
|
|
);
|
|
expect(result).toBe("thread1");
|
|
expectRestBodyField(postMock, "name", "Need help with deploy rollout");
|
|
await flushAsyncWork();
|
|
expectGeneratedTitleField("agentId", "main");
|
|
expectGeneratedTitleField("messageText", "Need help with deploy rollout");
|
|
expectGeneratedTitleField("channelName", "openclaw");
|
|
expectGeneratedTitleField(
|
|
"channelDescription",
|
|
"OpenClaw development coordination and release planning",
|
|
);
|
|
expectRestBodyField(patchMock, "name", "Deploy rollout summary");
|
|
});
|
|
|
|
it("does not block thread creation while title summary is pending", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
patchMock.mockResolvedValueOnce({});
|
|
let resolveTitle: ((value: string | null) => void) | undefined;
|
|
generateThreadTitleMock.mockReturnValueOnce(
|
|
new Promise((resolve: (value: string | null) => void) => {
|
|
resolveTitle = resolve;
|
|
}),
|
|
);
|
|
|
|
const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } } as OpenClawConfig;
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
|
|
cfg,
|
|
agentId: "main",
|
|
}),
|
|
);
|
|
expect(result).toBe("thread1");
|
|
expect(patchMock).not.toHaveBeenCalled();
|
|
|
|
resolveTitle?.("Async summary");
|
|
await flushAsyncWork();
|
|
expect(patchMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses channel-specific thread override for generated title model", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
patchMock.mockResolvedValueOnce({});
|
|
generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");
|
|
|
|
const cfg = {
|
|
agents: {
|
|
defaults: { model: "anthropic/claude-opus-4-6" },
|
|
},
|
|
channels: {
|
|
modelByChannel: {
|
|
discord: {
|
|
thread1: "openai/gpt-4.1-mini",
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
|
|
cfg,
|
|
agentId: "main",
|
|
}),
|
|
);
|
|
|
|
await flushAsyncWork();
|
|
expectGeneratedTitleField("modelRef", "openai/gpt-4.1-mini");
|
|
});
|
|
|
|
it("falls back to parent channel override for generated title model", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
patchMock.mockResolvedValueOnce({});
|
|
generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");
|
|
|
|
const cfg = {
|
|
agents: {
|
|
defaults: { model: "anthropic/claude-opus-4-6" },
|
|
},
|
|
channels: {
|
|
modelByChannel: {
|
|
discord: {
|
|
text1: "openai/gpt-4.1-mini",
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
|
|
cfg,
|
|
agentId: "main",
|
|
}),
|
|
);
|
|
|
|
await flushAsyncWork();
|
|
expectGeneratedTitleField("modelRef", "openai/gpt-4.1-mini");
|
|
});
|
|
|
|
it("skips summarization when cfg or agentId is missing", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
|
|
}),
|
|
);
|
|
await flushAsyncWork();
|
|
expect(generateThreadTitleMock).not.toHaveBeenCalled();
|
|
expect(patchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not rename when autoThreadName is not set", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
channelConfig: { allowed: true, autoThread: true },
|
|
}),
|
|
);
|
|
await flushAsyncWork();
|
|
expect(generateThreadTitleMock).not.toHaveBeenCalled();
|
|
expect(patchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not rename when generated title sanitizes to fallback thread name", async () => {
|
|
postMock.mockResolvedValueOnce({ id: "thread1" });
|
|
getMock.mockResolvedValueOnce({});
|
|
generateThreadTitleMock.mockResolvedValueOnce("<@123456789012345678> <#987654321098765432>");
|
|
|
|
const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } } as OpenClawConfig;
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
baseText: "Need help with deploy rollout",
|
|
combinedBody: "Need help with deploy rollout",
|
|
channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
|
|
cfg,
|
|
agentId: "main",
|
|
}),
|
|
);
|
|
|
|
expect(result).toBe("thread1");
|
|
await flushAsyncWork();
|
|
expect(patchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips thread creation when autoThread is false", async () => {
|
|
const result = await maybeCreateDiscordAutoThread(
|
|
createBaseParams({
|
|
channelConfig: { allowed: true, autoThread: false },
|
|
}),
|
|
);
|
|
expect(result).toBeUndefined();
|
|
expect(postMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|