mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 18:34:33 +00:00
test: dedupe gateway browser discord and channel coverage
This commit is contained in:
@@ -223,6 +223,12 @@ function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayload
|
||||
return { components };
|
||||
}
|
||||
|
||||
function formatCommandPreview(commandText: string, maxChars: number): string {
|
||||
const commandRaw =
|
||||
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
|
||||
return commandRaw.replace(/`/g, "\u200b`");
|
||||
}
|
||||
|
||||
function createExecApprovalRequestContainer(params: {
|
||||
request: ExecApprovalRequest;
|
||||
cfg: OpenClawConfig;
|
||||
@@ -230,8 +236,7 @@ function createExecApprovalRequestContainer(params: {
|
||||
actionRow?: Row<Button>;
|
||||
}): ExecApprovalContainer {
|
||||
const commandText = params.request.request.command;
|
||||
const commandRaw = commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText;
|
||||
const commandPreview = commandRaw.replace(/`/g, "\u200b`");
|
||||
const commandPreview = formatCommandPreview(commandText, 1000);
|
||||
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
@@ -255,8 +260,7 @@ function createResolvedContainer(params: {
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const commandText = params.request.request.command;
|
||||
const commandRaw = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
||||
const commandPreview = commandRaw.replace(/`/g, "\u200b`");
|
||||
const commandPreview = formatCommandPreview(commandText, 500);
|
||||
|
||||
const decisionLabel =
|
||||
params.decision === "allow-once"
|
||||
@@ -289,8 +293,7 @@ function createExpiredContainer(params: {
|
||||
accountId: string;
|
||||
}): ExecApprovalContainer {
|
||||
const commandText = params.request.request.command;
|
||||
const commandRaw = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
||||
const commandPreview = commandRaw.replace(/`/g, "\u200b`");
|
||||
const commandPreview = formatCommandPreview(commandText, 500);
|
||||
|
||||
return new ExecApprovalContainer({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -10,17 +10,21 @@ const sendMocks = vi.hoisted(() => ({
|
||||
reactMessageDiscord: vi.fn(async () => {}),
|
||||
removeReactionDiscord: vi.fn(async () => {}),
|
||||
}));
|
||||
const deliveryMocks = vi.hoisted(() => ({
|
||||
editMessageDiscord: vi.fn(async () => ({})),
|
||||
deliverDiscordReply: vi.fn(async () => {}),
|
||||
createDiscordDraftStream: vi.fn(() => ({
|
||||
function createMockDraftStream() {
|
||||
return {
|
||||
update: vi.fn<(text: string) => void>(() => {}),
|
||||
flush: vi.fn(async () => {}),
|
||||
messageId: vi.fn(() => "preview-1"),
|
||||
clear: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
forceNewMessage: vi.fn(() => {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const deliveryMocks = vi.hoisted(() => ({
|
||||
editMessageDiscord: vi.fn(async () => ({})),
|
||||
deliverDiscordReply: vi.fn(async () => {}),
|
||||
createDiscordDraftStream: vi.fn(() => createMockDraftStream()),
|
||||
}));
|
||||
const editMessageDiscord = deliveryMocks.editMessageDiscord;
|
||||
const deliverDiscordReply = deliveryMocks.deliverDiscordReply;
|
||||
@@ -373,17 +377,6 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
await processDiscordMessage(ctx as any);
|
||||
}
|
||||
|
||||
function createMockDraftStream() {
|
||||
return {
|
||||
update: vi.fn<(text: string) => void>(() => {}),
|
||||
flush: vi.fn(async () => {}),
|
||||
messageId: vi.fn(() => "preview-1"),
|
||||
clear: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
forceNewMessage: vi.fn(() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function createBlockModeContext() {
|
||||
return await createBaseContext({
|
||||
cfg: {
|
||||
@@ -424,17 +417,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
});
|
||||
|
||||
it("falls back to standard send when final needs multiple chunks", async () => {
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({ text: "Hello\nWorld" });
|
||||
return { queuedFinal: true, counts: { final: 1, tool: 0, block: 0 } };
|
||||
});
|
||||
|
||||
const ctx = await createBaseContext({
|
||||
discordConfig: { streamMode: "partial", maxLinesPerMessage: 1 },
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await processDiscordMessage(ctx as any);
|
||||
await runSingleChunkFinalScenario({ streamMode: "partial", maxLinesPerMessage: 1 });
|
||||
|
||||
expect(editMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
|
||||
25
src/discord/monitor/model-picker.test-utils.ts
Normal file
25
src/discord/monitor/model-picker.test-utils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
|
||||
|
||||
export function createModelsProviderData(
|
||||
entries: Record<string, string[]>,
|
||||
opts?: { defaultProviderOrder?: "insertion" | "sorted" },
|
||||
): ModelsProviderData {
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
for (const [provider, models] of Object.entries(entries)) {
|
||||
byProvider.set(provider, new Set(models));
|
||||
}
|
||||
const providers = Object.keys(entries).toSorted();
|
||||
const insertionProvider = Object.keys(entries)[0];
|
||||
const defaultProvider =
|
||||
opts?.defaultProviderOrder === "sorted"
|
||||
? (providers[0] ?? "openai")
|
||||
: (insertionProvider ?? "openai");
|
||||
return {
|
||||
byProvider,
|
||||
providers,
|
||||
resolvedDefault: {
|
||||
provider: defaultProvider,
|
||||
model: entries[defaultProvider]?.[0] ?? "gpt-4o",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { serializePayload } from "@buape/carbon";
|
||||
import { ComponentType } from "discord-api-types/v10";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
|
||||
import * as modelsCommandModule from "../../auto-reply/reply/commands-models.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
@@ -20,21 +19,7 @@ import {
|
||||
renderDiscordModelPickerRecentsView,
|
||||
toDiscordModelPickerMessagePayload,
|
||||
} from "./model-picker.js";
|
||||
|
||||
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
for (const [provider, models] of Object.entries(entries)) {
|
||||
byProvider.set(provider, new Set(models));
|
||||
}
|
||||
return {
|
||||
byProvider,
|
||||
providers: Object.keys(entries).toSorted(),
|
||||
resolvedDefault: {
|
||||
provider: Object.keys(entries)[0] ?? "openai",
|
||||
model: entries[Object.keys(entries)[0]]?.[0] ?? "gpt-4o",
|
||||
},
|
||||
};
|
||||
}
|
||||
import { createModelsProviderData } from "./model-picker.test-utils.js";
|
||||
|
||||
type SerializedComponent = {
|
||||
type: number;
|
||||
@@ -55,6 +40,26 @@ function extractContainerRows(components?: SerializedComponent[]): SerializedCom
|
||||
);
|
||||
}
|
||||
|
||||
function renderModelsViewRows(
|
||||
params: Parameters<typeof renderDiscordModelPickerModelsView>[0],
|
||||
): SerializedComponent[] {
|
||||
const rendered = renderDiscordModelPickerModelsView(params);
|
||||
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
|
||||
components?: SerializedComponent[];
|
||||
};
|
||||
return extractContainerRows(payload.components);
|
||||
}
|
||||
|
||||
function renderRecentsViewRows(
|
||||
params: Parameters<typeof renderDiscordModelPickerRecentsView>[0],
|
||||
): SerializedComponent[] {
|
||||
const rendered = renderDiscordModelPickerRecentsView(params);
|
||||
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
|
||||
components?: SerializedComponent[];
|
||||
};
|
||||
return extractContainerRows(payload.components);
|
||||
}
|
||||
|
||||
describe("loadDiscordModelPickerData", () => {
|
||||
it("reuses buildModelsProviderData as source of truth", async () => {
|
||||
const expected = createModelsProviderData({ openai: ["gpt-4o"] });
|
||||
@@ -467,7 +472,7 @@ describe("Discord model picker rendering", () => {
|
||||
anthropic: ["claude-sonnet-4-5"],
|
||||
});
|
||||
|
||||
const rendered = renderDiscordModelPickerModelsView({
|
||||
const rows = renderModelsViewRows({
|
||||
command: "model",
|
||||
userId: "42",
|
||||
data,
|
||||
@@ -477,12 +482,6 @@ describe("Discord model picker rendering", () => {
|
||||
currentModel: "openai/gpt-4o",
|
||||
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
|
||||
});
|
||||
|
||||
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
|
||||
components?: SerializedComponent[];
|
||||
};
|
||||
|
||||
const rows = extractContainerRows(payload.components);
|
||||
const buttonRow = rows[2];
|
||||
const buttons = buttonRow?.components ?? [];
|
||||
expect(buttons).toHaveLength(4);
|
||||
@@ -497,7 +496,7 @@ describe("Discord model picker rendering", () => {
|
||||
openai: ["gpt-4.1", "gpt-4o"],
|
||||
});
|
||||
|
||||
const rendered = renderDiscordModelPickerModelsView({
|
||||
const rows = renderModelsViewRows({
|
||||
command: "model",
|
||||
userId: "42",
|
||||
data,
|
||||
@@ -506,12 +505,6 @@ describe("Discord model picker rendering", () => {
|
||||
providerPage: 1,
|
||||
currentModel: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
|
||||
components?: SerializedComponent[];
|
||||
};
|
||||
|
||||
const rows = extractContainerRows(payload.components);
|
||||
const buttonRow = rows[2];
|
||||
const buttons = buttonRow?.components ?? [];
|
||||
expect(buttons).toHaveLength(3);
|
||||
@@ -532,19 +525,13 @@ describe("Discord model picker recents view", () => {
|
||||
|
||||
// Default is openai/gpt-4.1 (first key in entries).
|
||||
// Neither quickModel matches, so no deduping — 1 default + 2 recents + 1 back = 4 rows.
|
||||
const rendered = renderDiscordModelPickerRecentsView({
|
||||
const rows = renderRecentsViewRows({
|
||||
command: "model",
|
||||
userId: "42",
|
||||
data,
|
||||
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
|
||||
currentModel: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
|
||||
components?: SerializedComponent[];
|
||||
};
|
||||
|
||||
const rows = extractContainerRows(payload.components);
|
||||
expect(rows).toHaveLength(4);
|
||||
|
||||
// First row: default model button (slot 1).
|
||||
@@ -577,19 +564,13 @@ describe("Discord model picker recents view", () => {
|
||||
openai: ["gpt-4o"],
|
||||
});
|
||||
|
||||
const rendered = renderDiscordModelPickerRecentsView({
|
||||
const rows = renderRecentsViewRows({
|
||||
command: "model",
|
||||
userId: "42",
|
||||
data,
|
||||
quickModels: ["openai/gpt-4o"],
|
||||
currentModel: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
|
||||
components?: SerializedComponent[];
|
||||
};
|
||||
|
||||
const rows = extractContainerRows(payload.components);
|
||||
const defaultBtn = rows[0]?.components?.[0] as { label?: string };
|
||||
expect(defaultBtn?.label).toContain("(default)");
|
||||
});
|
||||
@@ -600,19 +581,13 @@ describe("Discord model picker recents view", () => {
|
||||
anthropic: ["claude-sonnet-4-5"],
|
||||
});
|
||||
// Default is openai/gpt-4o (first key). quickModels contains the default.
|
||||
const rendered = renderDiscordModelPickerRecentsView({
|
||||
const rows = renderRecentsViewRows({
|
||||
command: "model",
|
||||
userId: "42",
|
||||
data,
|
||||
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
|
||||
currentModel: "openai/gpt-4o",
|
||||
});
|
||||
|
||||
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
|
||||
components?: SerializedComponent[];
|
||||
};
|
||||
|
||||
const rows = extractContainerRows(payload.components);
|
||||
// 1 default + 1 deduped recent + 1 back = 3 rows (openai/gpt-4o not shown twice)
|
||||
expect(rows).toHaveLength(3);
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ vi.mock("../../config/sessions.js", async (importOriginal) => {
|
||||
describe("agent components", () => {
|
||||
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
||||
|
||||
const createDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
|
||||
const createBaseDmInteraction = (overrides: Record<string, unknown> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const defer = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = {
|
||||
@@ -100,22 +100,31 @@ describe("agent components", () => {
|
||||
defer,
|
||||
reply,
|
||||
...overrides,
|
||||
} as unknown as ButtonInteraction;
|
||||
};
|
||||
return { interaction, defer, reply };
|
||||
};
|
||||
|
||||
const createDmSelectInteraction = (overrides: Partial<StringSelectMenuInteraction> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const defer = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = {
|
||||
rawData: { channel_id: "dm-channel" },
|
||||
user: { id: "123456789", username: "Alice", discriminator: "1234" },
|
||||
values: ["alpha"],
|
||||
const createDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
|
||||
const { interaction, defer, reply } = createBaseDmInteraction(
|
||||
overrides as Record<string, unknown>,
|
||||
);
|
||||
return {
|
||||
interaction: interaction as unknown as ButtonInteraction,
|
||||
defer,
|
||||
reply,
|
||||
...overrides,
|
||||
} as unknown as StringSelectMenuInteraction;
|
||||
return { interaction, defer, reply };
|
||||
};
|
||||
};
|
||||
|
||||
const createDmSelectInteraction = (overrides: Partial<StringSelectMenuInteraction> = {}) => {
|
||||
const { interaction, defer, reply } = createBaseDmInteraction({
|
||||
values: ["alpha"],
|
||||
...(overrides as Record<string, unknown>),
|
||||
});
|
||||
return {
|
||||
interaction: interaction as unknown as StringSelectMenuInteraction,
|
||||
defer,
|
||||
reply,
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -12,28 +12,13 @@ import * as globalsModule from "../../globals.js";
|
||||
import * as timeoutModule from "../../utils/with-timeout.js";
|
||||
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
|
||||
import * as modelPickerModule from "./model-picker.js";
|
||||
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
|
||||
import {
|
||||
createDiscordModelPickerFallbackButton,
|
||||
createDiscordModelPickerFallbackSelect,
|
||||
} from "./native-command.js";
|
||||
import { createNoopThreadBindingManager, type ThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
for (const [provider, models] of Object.entries(entries)) {
|
||||
byProvider.set(provider, new Set(models));
|
||||
}
|
||||
const providers = Object.keys(entries).toSorted();
|
||||
return {
|
||||
byProvider,
|
||||
providers,
|
||||
resolvedDefault: {
|
||||
provider: providers[0] ?? "openai",
|
||||
model: entries[providers[0] ?? "openai"]?.[0] ?? "gpt-4o",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ModelPickerContext = Parameters<typeof createDiscordModelPickerFallbackButton>[0];
|
||||
type PickerButton = ReturnType<typeof createDiscordModelPickerFallbackButton>;
|
||||
type PickerSelect = ReturnType<typeof createDiscordModelPickerFallbackSelect>;
|
||||
@@ -55,6 +40,10 @@ type MockInteraction = {
|
||||
client: object;
|
||||
};
|
||||
|
||||
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
|
||||
return createBaseModelsProviderData(entries, { defaultProviderOrder: "sorted" });
|
||||
}
|
||||
|
||||
function createModelPickerContext(): ModelPickerContext {
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -152,6 +141,36 @@ function createModelsViewSubmitData(): PickerButtonData {
|
||||
};
|
||||
}
|
||||
|
||||
async function runSubmitButton(params: {
|
||||
context: ModelPickerContext;
|
||||
data: PickerButtonData;
|
||||
userId?: string;
|
||||
}) {
|
||||
const button = createDiscordModelPickerFallbackButton(params.context);
|
||||
const submitInteraction = createInteraction({ userId: params.userId ?? "owner" });
|
||||
await button.run(submitInteraction as unknown as PickerButtonInteraction, params.data);
|
||||
return submitInteraction;
|
||||
}
|
||||
|
||||
function expectDispatchedModelSelection(params: {
|
||||
dispatchSpy: { mock: { calls: Array<[unknown]> } };
|
||||
model: string;
|
||||
requireTargetSessionKey?: boolean;
|
||||
}) {
|
||||
const dispatchCall = params.dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: {
|
||||
CommandBody?: string;
|
||||
CommandArgs?: { values?: { model?: string } };
|
||||
CommandTargetSessionKey?: string;
|
||||
};
|
||||
};
|
||||
expect(dispatchCall.ctx?.CommandBody).toBe(`/model ${params.model}`);
|
||||
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe(params.model);
|
||||
if (params.requireTargetSessionKey) {
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
function createBoundThreadBindingManager(params: {
|
||||
accountId: string;
|
||||
threadId: string;
|
||||
@@ -244,25 +263,18 @@ describe("Discord model picker interactions", () => {
|
||||
expect(selectInteraction.update).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const submitInteraction = createInteraction({ userId: "owner" });
|
||||
const submitData = createModelsViewSubmitData();
|
||||
|
||||
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
|
||||
const submitInteraction = await runSubmitButton({
|
||||
context,
|
||||
data: createModelsViewSubmitData(),
|
||||
});
|
||||
|
||||
expect(submitInteraction.update).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: {
|
||||
CommandBody?: string;
|
||||
CommandArgs?: { values?: { model?: string } };
|
||||
CommandTargetSessionKey?: string;
|
||||
};
|
||||
};
|
||||
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
|
||||
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBeDefined();
|
||||
expectDispatchedModelSelection({
|
||||
dispatchSpy,
|
||||
model: "openai/gpt-4o",
|
||||
requireTargetSessionKey: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows timeout status and skips recents write when apply is still processing", async () => {
|
||||
@@ -359,31 +371,22 @@ describe("Discord model picker interactions", () => {
|
||||
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
|
||||
.mockResolvedValue({} as never);
|
||||
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const submitInteraction = createInteraction({ userId: "owner" });
|
||||
// rs=2 → first deduped recent (default is anthropic/claude-sonnet-4-5, so openai/gpt-4o remains)
|
||||
const submitData: PickerButtonData = {
|
||||
cmd: "model",
|
||||
act: "submit",
|
||||
view: "recents",
|
||||
u: "owner",
|
||||
pg: "1",
|
||||
rs: "2",
|
||||
};
|
||||
|
||||
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
|
||||
// rs=2 -> first deduped recent (default is anthropic/claude-sonnet-4-5, so openai/gpt-4o remains)
|
||||
const submitInteraction = await runSubmitButton({
|
||||
context,
|
||||
data: {
|
||||
cmd: "model",
|
||||
act: "submit",
|
||||
view: "recents",
|
||||
u: "owner",
|
||||
pg: "1",
|
||||
rs: "2",
|
||||
},
|
||||
});
|
||||
|
||||
expect(submitInteraction.update).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: {
|
||||
CommandBody?: string;
|
||||
CommandArgs?: { values?: { model?: string } };
|
||||
};
|
||||
};
|
||||
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
|
||||
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
|
||||
expectDispatchedModelSelection({ dispatchSpy, model: "openai/gpt-4o" });
|
||||
});
|
||||
|
||||
it("verifies model state against the bound thread session", async () => {
|
||||
|
||||
@@ -70,6 +70,20 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
};
|
||||
};
|
||||
|
||||
function expectLifecycleCleanup(params: {
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
threadStop: ReturnType<typeof vi.fn>;
|
||||
waitCalls: number;
|
||||
}) {
|
||||
expect(params.start).toHaveBeenCalledTimes(1);
|
||||
expect(params.stop).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(params.waitCalls);
|
||||
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
|
||||
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
|
||||
expect(params.threadStop).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
it("cleans up thread bindings when exec approvals startup fails", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { lifecycleParams, start, stop, threadStop } = createLifecycleHarness({
|
||||
@@ -80,12 +94,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
|
||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow("startup failed");
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDiscordGatewayStopMock).not.toHaveBeenCalled();
|
||||
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
|
||||
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
|
||||
expect(threadStop).toHaveBeenCalledTimes(1);
|
||||
expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 0 });
|
||||
});
|
||||
|
||||
it("cleans up when gateway wait fails after startup", async () => {
|
||||
@@ -97,12 +106,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
"gateway wait failed",
|
||||
);
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(1);
|
||||
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
|
||||
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
|
||||
expect(threadStop).toHaveBeenCalledTimes(1);
|
||||
expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 1 });
|
||||
});
|
||||
|
||||
it("cleans up after successful gateway wait", async () => {
|
||||
@@ -111,11 +115,6 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
|
||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(1);
|
||||
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
|
||||
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
|
||||
expect(threadStop).toHaveBeenCalledTimes(1);
|
||||
expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
|
||||
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
|
||||
import { resolveDiscordRestFetch } from "./rest-fetch.js";
|
||||
import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js";
|
||||
import { formatThreadBindingTtlLabel } from "./thread-bindings.messages.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
@@ -143,17 +144,8 @@ function resolveThreadBindingsEnabled(params: {
|
||||
}
|
||||
|
||||
function formatThreadBindingSessionTtlLabel(ttlMs: number): string {
|
||||
if (ttlMs <= 0) {
|
||||
return "off";
|
||||
}
|
||||
if (ttlMs < 60_000) {
|
||||
return "<1m";
|
||||
}
|
||||
const totalMinutes = Math.floor(ttlMs / 60_000);
|
||||
if (totalMinutes % 60 === 0) {
|
||||
return `${Math.floor(totalMinutes / 60)}h`;
|
||||
}
|
||||
return `${totalMinutes}m`;
|
||||
const label = formatThreadBindingTtlLabel(ttlMs);
|
||||
return label === "disabled" ? "off" : label;
|
||||
}
|
||||
|
||||
function dedupeSkillCommandsForDiscord(
|
||||
|
||||
@@ -22,6 +22,24 @@ import {
|
||||
} from "./thread-bindings.state.js";
|
||||
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
|
||||
|
||||
function resolveBindingIdsForTargetSession(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
}) {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
return resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
targetKind: params.targetKind,
|
||||
});
|
||||
}
|
||||
|
||||
export function listThreadBindingsForAccount(accountId?: string): ThreadBindingRecord[] {
|
||||
const manager = getThreadBindingManager(accountId);
|
||||
if (!manager) {
|
||||
@@ -35,17 +53,7 @@ export function listThreadBindingsBySessionKey(params: {
|
||||
accountId?: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
}): ThreadBindingRecord[] {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
targetKind: params.targetKind,
|
||||
});
|
||||
const ids = resolveBindingIdsForTargetSession(params);
|
||||
return ids
|
||||
.map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey))
|
||||
.filter((entry): entry is ThreadBindingRecord => Boolean(entry));
|
||||
@@ -136,17 +144,7 @@ export function unbindThreadBindingsBySessionKey(params: {
|
||||
sendFarewell?: boolean;
|
||||
farewellText?: string;
|
||||
}): ThreadBindingRecord[] {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
targetKind: params.targetKind,
|
||||
});
|
||||
const ids = resolveBindingIdsForTargetSession(params);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -188,16 +186,7 @@ export function setThreadBindingTtlBySessionKey(params: {
|
||||
accountId?: string;
|
||||
ttlMs: number;
|
||||
}): ThreadBindingRecord[] {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
});
|
||||
const ids = resolveBindingIdsForTargetSession(params);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user