test: dedupe gateway browser discord and channel coverage
This commit is contained in:
parent
34ea33f057
commit
296b19e413
@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn<() => OpenClawConfig>(),
|
||||
@ -38,12 +39,12 @@ describe("ensureBrowserControlAuth", () => {
|
||||
generatedToken?: string;
|
||||
auth: { token?: string };
|
||||
}) => {
|
||||
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
|
||||
expect(result.auth.token).toBe(result.generatedToken);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const persisted = mocks.writeConfigFile.mock.calls[0]?.[0];
|
||||
expect(persisted?.gateway?.auth?.mode).toBe("token");
|
||||
expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken);
|
||||
expectGeneratedTokenPersistedToGatewayAuth({
|
||||
generatedToken: result.generatedToken,
|
||||
authToken: result.auth.token,
|
||||
persistedConfig: mocks.writeConfigFile.mock.calls[0]?.[0],
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@ -426,7 +426,7 @@ function createProfileContext(
|
||||
return chosen;
|
||||
};
|
||||
|
||||
const focusTab = async (targetId: string): Promise<void> => {
|
||||
const resolveTargetIdOrThrow = async (targetId: string): Promise<string> => {
|
||||
const tabs = await listTabs();
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
@ -435,6 +435,11 @@ function createProfileContext(
|
||||
}
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
return resolved.targetId;
|
||||
};
|
||||
|
||||
const focusTab = async (targetId: string): Promise<void> => {
|
||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||
|
||||
if (!profile.cdpIsLoopback) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
@ -443,28 +448,21 @@ function createProfileContext(
|
||||
if (typeof focusPageByTargetIdViaPlaywright === "function") {
|
||||
await focusPageByTargetIdViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
targetId: resolved.targetId,
|
||||
targetId: resolvedTargetId,
|
||||
});
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolved.targetId;
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolved.targetId}`));
|
||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolvedTargetId}`));
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolved.targetId;
|
||||
profileState.lastTargetId = resolvedTargetId;
|
||||
};
|
||||
|
||||
const closeTab = async (targetId: string): Promise<void> => {
|
||||
const tabs = await listTabs();
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === "ambiguous") {
|
||||
throw new Error("ambiguous target id prefix");
|
||||
}
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||
|
||||
// For remote profiles, use Playwright's persistent connection to close tabs
|
||||
if (!profile.cdpIsLoopback) {
|
||||
@ -474,13 +472,13 @@ function createProfileContext(
|
||||
if (typeof closePageByTargetIdViaPlaywright === "function") {
|
||||
await closePageByTargetIdViaPlaywright({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
targetId: resolved.targetId,
|
||||
targetId: resolvedTargetId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolved.targetId}`));
|
||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolvedTargetId}`));
|
||||
};
|
||||
|
||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||
|
||||
@ -90,6 +90,40 @@ function createRuntimeWithExitSignal(exitCallOrder?: string[]) {
|
||||
return { runtime, exited };
|
||||
}
|
||||
|
||||
type GatewayCloseFn = (...args: unknown[]) => Promise<void>;
|
||||
type LoopRuntime = {
|
||||
log: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
exit: (code: number) => void;
|
||||
};
|
||||
|
||||
function createSignaledStart(close: GatewayCloseFn) {
|
||||
let resolveStarted: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve;
|
||||
});
|
||||
const start = vi.fn(async () => {
|
||||
resolveStarted?.();
|
||||
return { close };
|
||||
});
|
||||
return { start, started };
|
||||
}
|
||||
|
||||
async function runLoopWithStart(params: { start: ReturnType<typeof vi.fn>; runtime: LoopRuntime }) {
|
||||
vi.resetModules();
|
||||
const { runGatewayLoop } = await import("./run-loop.js");
|
||||
const loopPromise = runGatewayLoop({
|
||||
start: params.start as unknown as Parameters<typeof runGatewayLoop>[0]["start"],
|
||||
runtime: params.runtime,
|
||||
});
|
||||
return { loopPromise };
|
||||
}
|
||||
|
||||
async function waitForStart(started: Promise<void>) {
|
||||
await started;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe("runGatewayLoop", () => {
|
||||
it("exits 0 on SIGTERM after graceful close", async () => {
|
||||
vi.clearAllMocks();
|
||||
@ -221,15 +255,7 @@ describe("runGatewayLoop", () => {
|
||||
});
|
||||
|
||||
const close = vi.fn(async () => {});
|
||||
let resolveStarted: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve;
|
||||
});
|
||||
|
||||
const start = vi.fn(async () => {
|
||||
resolveStarted?.();
|
||||
return { close };
|
||||
});
|
||||
const { start, started } = createSignaledStart(close);
|
||||
|
||||
const exitCallOrder: string[] = [];
|
||||
const { runtime, exited } = createRuntimeWithExitSignal(exitCallOrder);
|
||||
@ -237,15 +263,9 @@ describe("runGatewayLoop", () => {
|
||||
exitCallOrder.push("lockRelease");
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { runGatewayLoop } = await import("./run-loop.js");
|
||||
const _loopPromise = runGatewayLoop({
|
||||
start: start as unknown as Parameters<typeof runGatewayLoop>[0]["start"],
|
||||
runtime: runtime as unknown as Parameters<typeof runGatewayLoop>[0]["runtime"],
|
||||
});
|
||||
const { loopPromise: _loopPromise } = await runLoopWithStart({ start, runtime });
|
||||
|
||||
await started;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await waitForStart(started);
|
||||
|
||||
process.emit("SIGUSR1");
|
||||
|
||||
@ -272,26 +292,13 @@ describe("runGatewayLoop", () => {
|
||||
});
|
||||
|
||||
const close = vi.fn(async () => {});
|
||||
let resolveStarted: (() => void) | null = null;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
resolveStarted = resolve;
|
||||
});
|
||||
const start = vi.fn(async () => {
|
||||
resolveStarted?.();
|
||||
return { close };
|
||||
});
|
||||
const { start, started } = createSignaledStart(close);
|
||||
|
||||
const { runtime, exited } = createRuntimeWithExitSignal();
|
||||
|
||||
vi.resetModules();
|
||||
const { runGatewayLoop } = await import("./run-loop.js");
|
||||
const _loopPromise = runGatewayLoop({
|
||||
start: start as unknown as Parameters<typeof runGatewayLoop>[0]["start"],
|
||||
runtime: runtime as unknown as Parameters<typeof runGatewayLoop>[0]["runtime"],
|
||||
});
|
||||
const { loopPromise: _loopPromise } = await runLoopWithStart({ start, runtime });
|
||||
|
||||
await started;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await waitForStart(started);
|
||||
process.emit("SIGUSR1");
|
||||
|
||||
await expect(exited).resolves.toBe(1);
|
||||
|
||||
@ -138,6 +138,68 @@ function createDefaultThreadConfig(): LoadedConfig {
|
||||
} as LoadedConfig;
|
||||
}
|
||||
|
||||
function createMentionRequiredGuildConfig(
|
||||
params: {
|
||||
messages?: LoadedConfig["messages"];
|
||||
} = {},
|
||||
): LoadedConfig {
|
||||
return {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/openclaw",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/openclaw-sessions.json" },
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "open" },
|
||||
groupPolicy: "open",
|
||||
guilds: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
...(params.messages ? { messages: params.messages } : {}),
|
||||
} as LoadedConfig;
|
||||
}
|
||||
|
||||
function createGuildTextClient() {
|
||||
return {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
function createGuildMessageEvent(params: {
|
||||
messageId: string;
|
||||
content: string;
|
||||
messagePatch?: Record<string, unknown>;
|
||||
eventPatch?: Record<string, unknown>;
|
||||
}) {
|
||||
return {
|
||||
message: {
|
||||
id: params.messageId,
|
||||
content: params.content,
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
...params.messagePatch,
|
||||
},
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
member: { nickname: "Ada" },
|
||||
guild: { id: "g1", name: "Guild" },
|
||||
guild_id: "g1",
|
||||
...params.eventPatch,
|
||||
};
|
||||
}
|
||||
|
||||
function createThreadChannel(params: { includeStarter?: boolean } = {}) {
|
||||
return {
|
||||
type: ChannelType.GuildText,
|
||||
@ -209,56 +271,18 @@ describe("discord tool result dispatch", () => {
|
||||
it(
|
||||
"accepts guild messages when mentionPatterns match",
|
||||
async () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/openclaw",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/openclaw-sessions.json" },
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "open" },
|
||||
groupPolicy: "open",
|
||||
guilds: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
const cfg = createMentionRequiredGuildConfig({
|
||||
messages: {
|
||||
responsePrefix: "PFX",
|
||||
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
|
||||
},
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
});
|
||||
|
||||
const handler = await createHandler(cfg);
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
const client = createGuildTextClient();
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m2",
|
||||
content: "openclaw: hello",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
},
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
member: { nickname: "Ada" },
|
||||
guild: { id: "g1", name: "Guild" },
|
||||
guild_id: "g1",
|
||||
},
|
||||
createGuildMessageEvent({ messageId: "m2", content: "openclaw: hello" }),
|
||||
client,
|
||||
);
|
||||
|
||||
@ -323,46 +347,16 @@ describe("discord tool result dispatch", () => {
|
||||
);
|
||||
|
||||
it("accepts guild reply-to-bot messages as implicit mentions", async () => {
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: "/tmp/openclaw",
|
||||
},
|
||||
},
|
||||
session: { store: "/tmp/openclaw-sessions.json" },
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "open" },
|
||||
groupPolicy: "open",
|
||||
guilds: { "*": { requireMention: true } },
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof import("../config/config.js").loadConfig>;
|
||||
const cfg = createMentionRequiredGuildConfig();
|
||||
|
||||
const handler = await createHandler(cfg);
|
||||
|
||||
const client = {
|
||||
fetchChannel: vi.fn().mockResolvedValue({
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
}),
|
||||
} as unknown as Client;
|
||||
const client = createGuildTextClient();
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m3",
|
||||
content: "following up",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
createGuildMessageEvent({
|
||||
messageId: "m3",
|
||||
content: "following up",
|
||||
messagePatch: {
|
||||
referencedMessage: {
|
||||
id: "m2",
|
||||
channelId: "c1",
|
||||
@ -377,21 +371,19 @@ describe("discord tool result dispatch", () => {
|
||||
author: { id: "bot-id", bot: true, username: "OpenClaw" },
|
||||
},
|
||||
},
|
||||
author: { id: "u1", bot: false, username: "Ada" },
|
||||
member: { nickname: "Ada" },
|
||||
guild: { id: "g1", name: "Guild" },
|
||||
guild_id: "g1",
|
||||
channel: { id: "c1", type: ChannelType.GuildText },
|
||||
client,
|
||||
data: {
|
||||
id: "m3",
|
||||
content: "following up",
|
||||
channel_id: "c1",
|
||||
guild_id: "g1",
|
||||
type: MessageType.Default,
|
||||
mentions: [],
|
||||
eventPatch: {
|
||||
channel: { id: "c1", type: ChannelType.GuildText },
|
||||
client,
|
||||
data: {
|
||||
id: "m3",
|
||||
content: "following up",
|
||||
channel_id: "c1",
|
||||
guild_id: "g1",
|
||||
type: MessageType.Default,
|
||||
mentions: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
client,
|
||||
);
|
||||
|
||||
|
||||
@ -51,15 +51,18 @@ const CATEGORY_GUILD_CFG = {
|
||||
},
|
||||
} satisfies Config;
|
||||
|
||||
async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown) => void }) {
|
||||
return createDiscordMessageHandler({
|
||||
cfg: opts.cfg,
|
||||
discordConfig: opts.cfg.channels?.discord,
|
||||
function createHandlerBaseConfig(
|
||||
cfg: Config,
|
||||
runtimeError?: (err: unknown) => void,
|
||||
): Parameters<typeof createDiscordMessageHandler>[0] {
|
||||
return {
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: opts.runtimeError ?? vi.fn(),
|
||||
error: runtimeError ?? vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
@ -73,7 +76,11 @@ async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown) => void }) {
|
||||
return createDiscordMessageHandler(createHandlerBaseConfig(opts.cfg, opts.runtimeError));
|
||||
}
|
||||
|
||||
function createDmClient() {
|
||||
@ -87,29 +94,10 @@ function createDmClient() {
|
||||
|
||||
async function createCategoryGuildHandler() {
|
||||
return createDiscordMessageHandler({
|
||||
cfg: CATEGORY_GUILD_CFG,
|
||||
discordConfig: CATEGORY_GUILD_CFG.channels?.discord,
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
botUserId: "bot-id",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 10_000,
|
||||
textLimit: 2000,
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
...createHandlerBaseConfig(CATEGORY_GUILD_CFG),
|
||||
guildEntries: {
|
||||
"*": { requireMention: false, channels: { c1: { allow: true } } },
|
||||
},
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
}
|
||||
|
||||
@ -124,6 +112,32 @@ function createCategoryGuildClient() {
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
function createCategoryGuildEvent(params: {
|
||||
messageId: string;
|
||||
timestamp?: string;
|
||||
author: Record<string, unknown>;
|
||||
}) {
|
||||
return {
|
||||
message: {
|
||||
id: params.messageId,
|
||||
content: "hello",
|
||||
channelId: "c1",
|
||||
timestamp: params.timestamp ?? new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: params.author,
|
||||
},
|
||||
author: params.author,
|
||||
member: { displayName: "Ada" },
|
||||
guild: { id: "g1", name: "Guild" },
|
||||
guild_id: "g1",
|
||||
};
|
||||
}
|
||||
|
||||
describe("discord tool result dispatch", () => {
|
||||
it("uses channel id allowlists for non-thread channels with categories", async () => {
|
||||
let capturedCtx: { SessionKey?: string } | undefined;
|
||||
@ -137,25 +151,10 @@ describe("discord tool result dispatch", () => {
|
||||
const client = createCategoryGuildClient();
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m-category",
|
||||
content: "hello",
|
||||
channelId: "c1",
|
||||
timestamp: new Date().toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
|
||||
},
|
||||
createCategoryGuildEvent({
|
||||
messageId: "m-category",
|
||||
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
|
||||
member: { displayName: "Ada" },
|
||||
guild: { id: "g1", name: "Guild" },
|
||||
guild_id: "g1",
|
||||
},
|
||||
}),
|
||||
client,
|
||||
);
|
||||
|
||||
@ -174,25 +173,11 @@ describe("discord tool result dispatch", () => {
|
||||
const client = createCategoryGuildClient();
|
||||
|
||||
await handler(
|
||||
{
|
||||
message: {
|
||||
id: "m-prefix",
|
||||
content: "hello",
|
||||
channelId: "c1",
|
||||
timestamp: new Date("2026-01-17T00:00:00Z").toISOString(),
|
||||
type: MessageType.Default,
|
||||
attachments: [],
|
||||
embeds: [],
|
||||
mentionedEveryone: false,
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
author: { id: "u1", bot: false, username: "Ada", discriminator: "1234" },
|
||||
},
|
||||
createCategoryGuildEvent({
|
||||
messageId: "m-prefix",
|
||||
timestamp: new Date("2026-01-17T00:00:00Z").toISOString(),
|
||||
author: { id: "u1", bot: false, username: "Ada", discriminator: "1234" },
|
||||
member: { displayName: "Ada" },
|
||||
guild: { id: "g1", name: "Guild" },
|
||||
guild_id: "g1",
|
||||
},
|
||||
}),
|
||||
client,
|
||||
);
|
||||
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
|
||||
@ -40,6 +40,32 @@ describe("handleControlUiHttpRequest", () => {
|
||||
expect(params.end).toHaveBeenCalledWith("Not Found");
|
||||
}
|
||||
|
||||
function runControlUiRequest(params: {
|
||||
url: string;
|
||||
method: "GET" | "HEAD";
|
||||
rootPath: string;
|
||||
basePath?: string;
|
||||
}) {
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
{ url: params.url, method: params.method } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
...(params.basePath ? { basePath: params.basePath } : {}),
|
||||
root: { kind: "resolved", path: params.rootPath },
|
||||
},
|
||||
);
|
||||
return { res, end, handled };
|
||||
}
|
||||
|
||||
async function writeAssetFile(rootPath: string, filename: string, contents: string) {
|
||||
const assetsDir = path.join(rootPath, "assets");
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
const filePath = path.join(assetsDir, filename);
|
||||
await fs.writeFile(filePath, contents);
|
||||
return { assetsDir, filePath };
|
||||
}
|
||||
|
||||
async function withBasePathRootFixture<T>(params: {
|
||||
siblingDir: string;
|
||||
fn: (paths: { root: string; sibling: string }) => Promise<T>;
|
||||
@ -183,19 +209,14 @@ describe("handleControlUiHttpRequest", () => {
|
||||
it("allows symlinked assets that resolve inside control-ui root", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const assetsDir = path.join(tmp, "assets");
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n");
|
||||
await fs.symlink(path.join(assetsDir, "actual.txt"), path.join(assetsDir, "linked.txt"));
|
||||
const { assetsDir, filePath } = await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
|
||||
await fs.symlink(filePath, path.join(assetsDir, "linked.txt"));
|
||||
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
{ url: "/assets/linked.txt", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
root: { kind: "resolved", path: tmp },
|
||||
},
|
||||
);
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
url: "/assets/linked.txt",
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
@ -207,18 +228,13 @@ describe("handleControlUiHttpRequest", () => {
|
||||
it("serves HEAD for in-root assets without writing a body", async () => {
|
||||
await withControlUiRoot({
|
||||
fn: async (tmp) => {
|
||||
const assetsDir = path.join(tmp, "assets");
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n");
|
||||
await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
|
||||
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
{ url: "/assets/actual.txt", method: "HEAD" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
root: { kind: "resolved", path: tmp },
|
||||
},
|
||||
);
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
url: "/assets/actual.txt",
|
||||
method: "HEAD",
|
||||
rootPath: tmp,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
@ -237,14 +253,11 @@ describe("handleControlUiHttpRequest", () => {
|
||||
await fs.rm(path.join(tmp, "index.html"));
|
||||
await fs.symlink(outsideIndex, path.join(tmp, "index.html"));
|
||||
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
{ url: "/app/route", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
root: { kind: "resolved", path: tmp },
|
||||
},
|
||||
);
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
url: "/app/route",
|
||||
method: "GET",
|
||||
rootPath: tmp,
|
||||
});
|
||||
expectNotFoundResponse({ handled, res, end });
|
||||
} finally {
|
||||
await fs.rm(outsideDir, { recursive: true, force: true });
|
||||
@ -262,16 +275,12 @@ describe("handleControlUiHttpRequest", () => {
|
||||
|
||||
const secretPathUrl = secretPath.split(path.sep).join("/");
|
||||
const absolutePathUrl = secretPathUrl.startsWith("/") ? secretPathUrl : `/${secretPathUrl}`;
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
|
||||
const handled = handleControlUiHttpRequest(
|
||||
{ url: `/openclaw/${absolutePathUrl}`, method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
basePath: "/openclaw",
|
||||
root: { kind: "resolved", path: root },
|
||||
},
|
||||
);
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
url: `/openclaw/${absolutePathUrl}`,
|
||||
method: "GET",
|
||||
rootPath: root,
|
||||
basePath: "/openclaw",
|
||||
});
|
||||
expectNotFoundResponse({ handled, res, end });
|
||||
},
|
||||
});
|
||||
@ -295,15 +304,12 @@ describe("handleControlUiHttpRequest", () => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { res, end } = makeMockHttpResponse();
|
||||
const handled = handleControlUiHttpRequest(
|
||||
{ url: "/openclaw/assets/leak.txt", method: "GET" } as IncomingMessage,
|
||||
res,
|
||||
{
|
||||
basePath: "/openclaw",
|
||||
root: { kind: "resolved", path: root },
|
||||
},
|
||||
);
|
||||
const { res, end, handled } = runControlUiRequest({
|
||||
url: "/openclaw/assets/leak.txt",
|
||||
method: "GET",
|
||||
rootPath: root,
|
||||
basePath: "/openclaw",
|
||||
});
|
||||
expectNotFoundResponse({ handled, res, end });
|
||||
},
|
||||
});
|
||||
|
||||
@ -43,6 +43,40 @@ describe("hooks mapping", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function expectAgentMessage(
|
||||
result: Awaited<ReturnType<typeof applyHookMappings>> | undefined,
|
||||
expectedMessage: string,
|
||||
) {
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok && result.action?.kind === "agent") {
|
||||
expect(result.action.kind).toBe("agent");
|
||||
expect(result.action.message).toBe(expectedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectBlockedPrototypeTraversal(params: {
|
||||
id: string;
|
||||
messageTemplate: string;
|
||||
payload: Record<string, unknown>;
|
||||
expectedMessage: string;
|
||||
}) {
|
||||
const mappings = resolveHookMappings({
|
||||
mappings: [
|
||||
createGmailAgentMapping({
|
||||
id: params.id,
|
||||
messageTemplate: params.messageTemplate,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = await applyHookMappings(mappings, {
|
||||
payload: params.payload,
|
||||
headers: {},
|
||||
url: baseUrl,
|
||||
path: "gmail",
|
||||
});
|
||||
expectAgentMessage(result, params.expectedMessage);
|
||||
}
|
||||
|
||||
async function applyNullTransformFromTempConfig(params: {
|
||||
configDir: string;
|
||||
transformsDir?: string;
|
||||
@ -91,11 +125,7 @@ describe("hooks mapping", () => {
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok && result.action?.kind === "agent") {
|
||||
expect(result.action.kind).toBe("agent");
|
||||
expect(result.action.message).toBe("Subject: Hello");
|
||||
}
|
||||
expectAgentMessage(result, "Subject: Hello");
|
||||
});
|
||||
|
||||
it("passes model override from mapping", async () => {
|
||||
@ -342,11 +372,7 @@ describe("hooks mapping", () => {
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok && result.action?.kind === "agent") {
|
||||
expect(result.action.kind).toBe("agent");
|
||||
expect(result.action.message).toBe("Override subject: Hello");
|
||||
}
|
||||
expectAgentMessage(result, "Override subject: Hello");
|
||||
});
|
||||
|
||||
it("passes agentId from mapping", async () => {
|
||||
@ -461,75 +487,30 @@ describe("hooks mapping", () => {
|
||||
|
||||
describe("prototype pollution protection", () => {
|
||||
it("blocks __proto__ traversal in webhook payload", async () => {
|
||||
const mappings = resolveHookMappings({
|
||||
mappings: [
|
||||
createGmailAgentMapping({
|
||||
id: "proto-test",
|
||||
messageTemplate: "value: {{__proto__}}",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = await applyHookMappings(mappings, {
|
||||
await expectBlockedPrototypeTraversal({
|
||||
id: "proto-test",
|
||||
messageTemplate: "value: {{__proto__}}",
|
||||
payload: { __proto__: { polluted: true } } as Record<string, unknown>,
|
||||
headers: {},
|
||||
url: baseUrl,
|
||||
path: "gmail",
|
||||
expectedMessage: "value: ",
|
||||
});
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok) {
|
||||
const action = result.action;
|
||||
if (action?.kind === "agent") {
|
||||
expect(action.message).toBe("value: ");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks constructor traversal in webhook payload", async () => {
|
||||
const mappings = resolveHookMappings({
|
||||
mappings: [
|
||||
createGmailAgentMapping({
|
||||
id: "constructor-test",
|
||||
messageTemplate: "type: {{constructor.name}}",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = await applyHookMappings(mappings, {
|
||||
await expectBlockedPrototypeTraversal({
|
||||
id: "constructor-test",
|
||||
messageTemplate: "type: {{constructor.name}}",
|
||||
payload: { constructor: { name: "INJECTED" } } as Record<string, unknown>,
|
||||
headers: {},
|
||||
url: baseUrl,
|
||||
path: "gmail",
|
||||
expectedMessage: "type: ",
|
||||
});
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok) {
|
||||
const action = result.action;
|
||||
if (action?.kind === "agent") {
|
||||
expect(action.message).toBe("type: ");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks prototype traversal in webhook payload", async () => {
|
||||
const mappings = resolveHookMappings({
|
||||
mappings: [
|
||||
createGmailAgentMapping({
|
||||
id: "prototype-test",
|
||||
messageTemplate: "val: {{prototype}}",
|
||||
}),
|
||||
],
|
||||
});
|
||||
const result = await applyHookMappings(mappings, {
|
||||
await expectBlockedPrototypeTraversal({
|
||||
id: "prototype-test",
|
||||
messageTemplate: "val: {{prototype}}",
|
||||
payload: { prototype: "leaked" } as Record<string, unknown>,
|
||||
headers: {},
|
||||
url: baseUrl,
|
||||
path: "gmail",
|
||||
expectedMessage: "val: ",
|
||||
});
|
||||
expect(result?.ok).toBe(true);
|
||||
if (result?.ok) {
|
||||
const action = result.action;
|
||||
if (action?.kind === "agent") {
|
||||
expect(action.message).toBe("val: ");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -97,6 +97,66 @@ describe("agent event handler", () => {
|
||||
return nodeSendToSession.mock.calls.filter(([, event]) => event === "chat");
|
||||
}
|
||||
|
||||
const FALLBACK_LIFECYCLE_DATA = {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
} as const;
|
||||
|
||||
function emitLifecycleEnd(
|
||||
handler: ReturnType<typeof createHarness>["handler"],
|
||||
runId: string,
|
||||
seq = 2,
|
||||
) {
|
||||
handler({
|
||||
runId,
|
||||
seq,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "end" },
|
||||
});
|
||||
}
|
||||
|
||||
function emitFallbackLifecycle(params: {
|
||||
handler: ReturnType<typeof createHarness>["handler"];
|
||||
runId: string;
|
||||
seq?: number;
|
||||
sessionKey?: string;
|
||||
}) {
|
||||
params.handler({
|
||||
runId: params.runId,
|
||||
seq: params.seq ?? 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
data: { ...FALLBACK_LIFECYCLE_DATA },
|
||||
});
|
||||
}
|
||||
|
||||
function expectSingleAgentBroadcastPayload(broadcast: ReturnType<typeof vi.fn>) {
|
||||
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
|
||||
expect(broadcastAgentCalls).toHaveLength(1);
|
||||
return broadcastAgentCalls[0]?.[1] as {
|
||||
runId?: string;
|
||||
sessionKey?: string;
|
||||
stream?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function expectSingleFinalChatPayload(broadcast: ReturnType<typeof vi.fn>) {
|
||||
const chatCalls = chatBroadcastCalls(broadcast);
|
||||
expect(chatCalls).toHaveLength(1);
|
||||
const payload = chatCalls[0]?.[1] as {
|
||||
state?: string;
|
||||
message?: unknown;
|
||||
};
|
||||
expect(payload.state).toBe("final");
|
||||
return payload;
|
||||
}
|
||||
|
||||
it("emits chat delta for assistant text-only events", () => {
|
||||
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
|
||||
createHarness({ now: 1_000 }),
|
||||
@ -152,18 +212,9 @@ describe("agent event handler", () => {
|
||||
ts: Date.now(),
|
||||
data: { text: "NO_REPLY" },
|
||||
});
|
||||
handler({
|
||||
runId: "run-2",
|
||||
seq: 2,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "end" },
|
||||
});
|
||||
emitLifecycleEnd(handler, "run-2");
|
||||
|
||||
const chatCalls = chatBroadcastCalls(broadcast);
|
||||
expect(chatCalls).toHaveLength(1);
|
||||
const payload = chatCalls[0]?.[1] as { state?: string; message?: unknown };
|
||||
expect(payload.state).toBe("final");
|
||||
const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
|
||||
expect(payload.message).toBeUndefined();
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
||||
nowSpy?.mockRestore();
|
||||
@ -305,28 +356,10 @@ describe("agent event handler", () => {
|
||||
resolveSessionKeyForRun: () => "session-fallback",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-fallback",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
emitFallbackLifecycle({ handler, runId: "run-fallback" });
|
||||
|
||||
expect(broadcastToConnIds).not.toHaveBeenCalled();
|
||||
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
|
||||
expect(broadcastAgentCalls).toHaveLength(1);
|
||||
const payload = broadcastAgentCalls[0]?.[1] as {
|
||||
sessionKey?: string;
|
||||
stream?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
const payload = expectSingleAgentBroadcastPayload(broadcast);
|
||||
expect(payload.stream).toBe("lifecycle");
|
||||
expect(payload.data?.phase).toBe("fallback");
|
||||
expect(payload.sessionKey).toBe("session-fallback");
|
||||
@ -345,28 +378,9 @@ describe("agent event handler", () => {
|
||||
clientRunId: "run-fallback-client",
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-fallback-internal",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
emitFallbackLifecycle({ handler, runId: "run-fallback-internal" });
|
||||
|
||||
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
|
||||
expect(broadcastAgentCalls).toHaveLength(1);
|
||||
const payload = broadcastAgentCalls[0]?.[1] as {
|
||||
runId?: string;
|
||||
sessionKey?: string;
|
||||
stream?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
const payload = expectSingleAgentBroadcastPayload(broadcast);
|
||||
expect(payload.runId).toBe("run-fallback-client");
|
||||
expect(payload.stream).toBe("lifecycle");
|
||||
expect(payload.data?.phase).toBe("fallback");
|
||||
@ -382,24 +396,13 @@ describe("agent event handler", () => {
|
||||
resolveSessionKeyForRun: () => undefined,
|
||||
});
|
||||
|
||||
handler({
|
||||
emitFallbackLifecycle({
|
||||
handler,
|
||||
runId: "run-fallback-session-key",
|
||||
seq: 1,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
sessionKey: "session-from-event",
|
||||
data: {
|
||||
phase: "fallback",
|
||||
selectedProvider: "fireworks",
|
||||
selectedModel: "fireworks/minimax-m2p5",
|
||||
activeProvider: "deepinfra",
|
||||
activeModel: "moonshotai/Kimi-K2.5",
|
||||
},
|
||||
});
|
||||
|
||||
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent");
|
||||
expect(broadcastAgentCalls).toHaveLength(1);
|
||||
const payload = broadcastAgentCalls[0]?.[1] as { sessionKey?: string };
|
||||
const payload = expectSingleAgentBroadcastPayload(broadcast);
|
||||
expect(payload.sessionKey).toBe("session-from-event");
|
||||
});
|
||||
|
||||
@ -464,18 +467,9 @@ describe("agent event handler", () => {
|
||||
expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
|
||||
|
||||
handler({
|
||||
runId: "run-heartbeat",
|
||||
seq: 2,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "end" },
|
||||
});
|
||||
emitLifecycleEnd(handler, "run-heartbeat");
|
||||
|
||||
const chatCalls = chatBroadcastCalls(broadcast);
|
||||
expect(chatCalls).toHaveLength(1);
|
||||
const finalPayload = chatCalls[0]?.[1] as { state?: string; message?: unknown };
|
||||
expect(finalPayload.state).toBe("final");
|
||||
const finalPayload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
|
||||
expect(finalPayload.message).toBeUndefined();
|
||||
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
|
||||
});
|
||||
@ -506,21 +500,11 @@ describe("agent event handler", () => {
|
||||
},
|
||||
});
|
||||
|
||||
handler({
|
||||
runId: "run-heartbeat-alert",
|
||||
seq: 2,
|
||||
stream: "lifecycle",
|
||||
ts: Date.now(),
|
||||
data: { phase: "end" },
|
||||
});
|
||||
emitLifecycleEnd(handler, "run-heartbeat-alert");
|
||||
|
||||
const chatCalls = chatBroadcastCalls(broadcast);
|
||||
expect(chatCalls).toHaveLength(1);
|
||||
const payload = chatCalls[0]?.[1] as {
|
||||
state?: string;
|
||||
const payload = expectSingleFinalChatPayload(broadcast) as {
|
||||
message?: { content?: Array<{ text?: string }> };
|
||||
};
|
||||
expect(payload.state).toBe("final");
|
||||
expect(payload.message?.content?.[0]?.text).toBe(
|
||||
"Disk usage crossed 95 percent on /data and needs cleanup now.",
|
||||
);
|
||||
|
||||
@ -150,6 +150,29 @@ function readLastAgentCommandCall():
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function mockSessionResetSuccess(params: {
|
||||
reason: "new" | "reset";
|
||||
key?: string;
|
||||
sessionId?: string;
|
||||
}) {
|
||||
const key = params.key ?? "agent:main:main";
|
||||
const sessionId = params.sessionId ?? "reset-session-id";
|
||||
mocks.sessionsResetHandler.mockImplementation(
|
||||
async (opts: {
|
||||
params: { key: string; reason: string };
|
||||
respond: (ok: boolean, payload?: unknown) => void;
|
||||
}) => {
|
||||
expect(opts.params.key).toBe(key);
|
||||
expect(opts.params.reason).toBe(params.reason);
|
||||
opts.respond(true, {
|
||||
ok: true,
|
||||
key,
|
||||
entry: { sessionId },
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function invokeAgent(
|
||||
params: AgentParams,
|
||||
options?: {
|
||||
@ -321,20 +344,7 @@ describe("gateway agent handler", () => {
|
||||
});
|
||||
|
||||
it("handles bare /new by resetting the same session and sending reset greeting prompt", async () => {
|
||||
mocks.sessionsResetHandler.mockImplementation(
|
||||
async (opts: {
|
||||
params: { key: string; reason: string };
|
||||
respond: (ok: boolean, payload?: unknown) => void;
|
||||
}) => {
|
||||
expect(opts.params.key).toBe("agent:main:main");
|
||||
expect(opts.params.reason).toBe("new");
|
||||
opts.respond(true, {
|
||||
ok: true,
|
||||
key: "agent:main:main",
|
||||
entry: { sessionId: "reset-session-id" },
|
||||
});
|
||||
},
|
||||
);
|
||||
mockSessionResetSuccess({ reason: "new" });
|
||||
|
||||
primeMainAgentRun({ sessionId: "reset-session-id" });
|
||||
|
||||
@ -366,20 +376,7 @@ describe("gateway agent handler", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
mocks.sessionsResetHandler.mockImplementation(
|
||||
async (opts: {
|
||||
params: { key: string; reason: string };
|
||||
respond: (ok: boolean, payload?: unknown) => void;
|
||||
}) => {
|
||||
expect(opts.params.key).toBe("agent:main:main");
|
||||
expect(opts.params.reason).toBe("reset");
|
||||
opts.respond(true, {
|
||||
ok: true,
|
||||
key: "agent:main:main",
|
||||
entry: { sessionId: "reset-session-id" },
|
||||
});
|
||||
},
|
||||
);
|
||||
mockSessionResetSuccess({ reason: "reset" });
|
||||
mocks.sessionsResetHandler.mockClear();
|
||||
primeMainAgentRun({
|
||||
sessionId: "reset-session-id",
|
||||
|
||||
@ -34,6 +34,16 @@ function createInvokeParams(params: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
function expectInvalidRequestResponse(
|
||||
respond: ReturnType<typeof vi.fn>,
|
||||
expectedMessagePart: string,
|
||||
) {
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(false);
|
||||
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
|
||||
expect(call?.[2]?.message).toContain(expectedMessagePart);
|
||||
}
|
||||
|
||||
describe("push.test handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(loadApnsRegistration).mockClear();
|
||||
@ -45,20 +55,14 @@ describe("push.test handler", () => {
|
||||
it("rejects invalid params", async () => {
|
||||
const { respond, invoke } = createInvokeParams({ title: "hello" });
|
||||
await invoke();
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(false);
|
||||
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
|
||||
expect(call?.[2]?.message).toContain("invalid push.test params");
|
||||
expectInvalidRequestResponse(respond, "invalid push.test params");
|
||||
});
|
||||
|
||||
it("returns invalid request when node has no APNs registration", async () => {
|
||||
vi.mocked(loadApnsRegistration).mockResolvedValue(null);
|
||||
const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1" });
|
||||
await invoke();
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(false);
|
||||
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
|
||||
expect(call?.[2]?.message).toContain("has no APNs registration");
|
||||
expectInvalidRequestResponse(respond, "has no APNs registration");
|
||||
});
|
||||
|
||||
it("sends push test when registration and auth are available", async () => {
|
||||
|
||||
@ -42,6 +42,48 @@ const getInflightMap = (context: GatewayRequestContext) => {
|
||||
return inflight;
|
||||
};
|
||||
|
||||
async function resolveRequestedChannel(params: {
|
||||
requestChannel: unknown;
|
||||
unsupportedMessage: (input: string) => string;
|
||||
rejectWebchatAsInternalOnly?: boolean;
|
||||
}): Promise<
|
||||
| {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
channel: string;
|
||||
}
|
||||
| {
|
||||
error: ReturnType<typeof errorShape>;
|
||||
}
|
||||
> {
|
||||
const channelInput =
|
||||
typeof params.requestChannel === "string" ? params.requestChannel : undefined;
|
||||
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
|
||||
if (channelInput && !normalizedChannel) {
|
||||
const normalizedInput = channelInput.trim().toLowerCase();
|
||||
if (params.rejectWebchatAsInternalOnly && normalizedInput === "webchat") {
|
||||
return {
|
||||
error: errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, params.unsupportedMessage(channelInput)),
|
||||
};
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
let channel = normalizedChannel;
|
||||
if (!channel) {
|
||||
try {
|
||||
channel = (await resolveMessageChannelSelection({ cfg })).channel;
|
||||
} catch (err) {
|
||||
return { error: errorShape(ErrorCodes.INVALID_REQUEST, String(err)) };
|
||||
}
|
||||
}
|
||||
return { cfg, channel };
|
||||
}
|
||||
|
||||
export const sendHandlers: GatewayRequestHandlers = {
|
||||
send: async ({ params, respond, context }) => {
|
||||
const p = params;
|
||||
@ -104,38 +146,16 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const channelInput = typeof request.channel === "string" ? request.channel : undefined;
|
||||
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
|
||||
if (channelInput && !normalizedChannel) {
|
||||
const normalizedInput = channelInput.trim().toLowerCase();
|
||||
if (normalizedInput === "webchat") {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"unsupported channel: webchat (internal-only). Use `chat.send` for WebChat UI messages or choose a deliverable channel.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channelInput}`),
|
||||
);
|
||||
const resolvedChannel = await resolveRequestedChannel({
|
||||
requestChannel: request.channel,
|
||||
unsupportedMessage: (input) => `unsupported channel: ${input}`,
|
||||
rejectWebchatAsInternalOnly: true,
|
||||
});
|
||||
if ("error" in resolvedChannel) {
|
||||
respond(false, undefined, resolvedChannel.error);
|
||||
return;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
let channel = normalizedChannel;
|
||||
if (!channel) {
|
||||
try {
|
||||
channel = (await resolveMessageChannelSelection({ cfg })).channel;
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { cfg, channel } = resolvedChannel;
|
||||
const accountId =
|
||||
typeof request.accountId === "string" && request.accountId.trim().length
|
||||
? request.accountId.trim()
|
||||
@ -322,26 +342,15 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const to = request.to.trim();
|
||||
const channelInput = typeof request.channel === "string" ? request.channel : undefined;
|
||||
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
|
||||
if (channelInput && !normalizedChannel) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported poll channel: ${channelInput}`),
|
||||
);
|
||||
const resolvedChannel = await resolveRequestedChannel({
|
||||
requestChannel: request.channel,
|
||||
unsupportedMessage: (input) => `unsupported poll channel: ${input}`,
|
||||
});
|
||||
if ("error" in resolvedChannel) {
|
||||
respond(false, undefined, resolvedChannel.error);
|
||||
return;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
let channel = normalizedChannel;
|
||||
if (!channel) {
|
||||
try {
|
||||
channel = (await resolveMessageChannelSelection({ cfg })).channel;
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { cfg, channel } = resolvedChannel;
|
||||
if (typeof request.durationSeconds === "number" && channel !== "telegram") {
|
||||
respond(
|
||||
false,
|
||||
|
||||
@ -109,6 +109,23 @@ async function runSessionsUsageLogs(params: Record<string, unknown>) {
|
||||
return respond;
|
||||
}
|
||||
|
||||
const BASE_USAGE_RANGE = {
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
limit: 10,
|
||||
} as const;
|
||||
|
||||
function expectSuccessfulSessionsUsage(
|
||||
respond: ReturnType<typeof vi.fn>,
|
||||
): Array<{ key: string; agentId: string }> {
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
||||
const result = respond.mock.calls[0]?.[1] as {
|
||||
sessions: Array<{ key: string; agentId: string }>;
|
||||
};
|
||||
return result.sessions;
|
||||
}
|
||||
|
||||
describe("sessions.usage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
@ -116,28 +133,20 @@ describe("sessions.usage", () => {
|
||||
});
|
||||
|
||||
it("discovers sessions across configured agents and keeps agentId in key", async () => {
|
||||
const respond = await runSessionsUsage({
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
limit: 10,
|
||||
});
|
||||
const respond = await runSessionsUsage(BASE_USAGE_RANGE);
|
||||
|
||||
expect(vi.mocked(discoverAllSessions)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(discoverAllSessions).mock.calls[0]?.[0]?.agentId).toBe("main");
|
||||
expect(vi.mocked(discoverAllSessions).mock.calls[1]?.[0]?.agentId).toBe("opus");
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
||||
const result = respond.mock.calls[0]?.[1] as unknown as {
|
||||
sessions: Array<{ key: string; agentId: string }>;
|
||||
};
|
||||
expect(result.sessions).toHaveLength(2);
|
||||
const sessions = expectSuccessfulSessionsUsage(respond);
|
||||
expect(sessions).toHaveLength(2);
|
||||
|
||||
// Sorted by most recent first (mtime=200 -> opus first).
|
||||
expect(result.sessions[0].key).toBe("agent:opus:s-opus");
|
||||
expect(result.sessions[0].agentId).toBe("opus");
|
||||
expect(result.sessions[1].key).toBe("agent:main:s-main");
|
||||
expect(result.sessions[1].agentId).toBe("main");
|
||||
expect(sessions[0].key).toBe("agent:opus:s-opus");
|
||||
expect(sessions[0].agentId).toBe("opus");
|
||||
expect(sessions[1].key).toBe("agent:main:s-main");
|
||||
expect(sessions[1].agentId).toBe("main");
|
||||
});
|
||||
|
||||
it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => {
|
||||
@ -166,20 +175,10 @@ describe("sessions.usage", () => {
|
||||
});
|
||||
|
||||
// Query via discovered key: agent:<id>:<sessionId>
|
||||
const respond = await runSessionsUsage({
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
key: "agent:opus:s-opus",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
expect(respond.mock.calls[0]?.[0]).toBe(true);
|
||||
const result = respond.mock.calls[0]?.[1] as unknown as {
|
||||
sessions: Array<{ key: string }>;
|
||||
};
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]?.key).toBe(storeKey);
|
||||
const respond = await runSessionsUsage({ ...BASE_USAGE_RANGE, key: "agent:opus:s-opus" });
|
||||
const sessions = expectSuccessfulSessionsUsage(respond);
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0]?.key).toBe(storeKey);
|
||||
expect(vi.mocked(loadSessionCostSummary)).toHaveBeenCalled();
|
||||
expect(
|
||||
vi.mocked(loadSessionCostSummary).mock.calls.some((call) => call[0]?.agentId === "opus"),
|
||||
@ -192,10 +191,8 @@ describe("sessions.usage", () => {
|
||||
|
||||
it("rejects traversal-style keys in specific session usage lookups", async () => {
|
||||
const respond = await runSessionsUsage({
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
...BASE_USAGE_RANGE,
|
||||
key: "agent:opus:../../etc/passwd",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
|
||||
@ -21,6 +21,54 @@ let gatewayPort: number;
|
||||
const gatewayToken = "test-token";
|
||||
let envSnapshot: ReturnType<typeof captureEnv>;
|
||||
|
||||
type SessionSendTool = ReturnType<typeof createOpenClawTools>[number];
|
||||
|
||||
function getSessionsSendTool(): SessionSendTool {
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_send tool");
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
async function emitLifecycleAssistantReply(params: {
|
||||
opts: unknown;
|
||||
defaultSessionId: string;
|
||||
includeTimestamp?: boolean;
|
||||
resolveText: (extraSystemPrompt?: string) => string;
|
||||
}) {
|
||||
const commandParams = params.opts as {
|
||||
sessionId?: string;
|
||||
runId?: string;
|
||||
extraSystemPrompt?: string;
|
||||
};
|
||||
const sessionId = commandParams.sessionId ?? params.defaultSessionId;
|
||||
const runId = commandParams.runId ?? sessionId;
|
||||
const sessionFile = resolveSessionTranscriptPath(sessionId);
|
||||
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
|
||||
|
||||
const startedAt = Date.now();
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "start", startedAt },
|
||||
});
|
||||
|
||||
const text = params.resolveText(commandParams.extraSystemPrompt);
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
...(params.includeTimestamp ? { timestamp: Date.now() } : {}),
|
||||
};
|
||||
await fs.appendFile(sessionFile, `${JSON.stringify({ message })}\n`, "utf8");
|
||||
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end", startedAt, endedAt: Date.now() },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PORT", "OPENCLAW_GATEWAY_TOKEN"]);
|
||||
gatewayPort = await getFreePort();
|
||||
@ -52,52 +100,24 @@ afterAll(async () => {
|
||||
describe("sessions_send gateway loopback", () => {
|
||||
it("returns reply when lifecycle ends before agent.wait", async () => {
|
||||
const spy = agentCommand as unknown as Mock<(opts: unknown) => Promise<void>>;
|
||||
spy.mockImplementation(async (opts: unknown) => {
|
||||
const params = opts as {
|
||||
sessionId?: string;
|
||||
runId?: string;
|
||||
extraSystemPrompt?: string;
|
||||
};
|
||||
const sessionId = params.sessionId ?? "main";
|
||||
const runId = params.runId ?? sessionId;
|
||||
const sessionFile = resolveSessionTranscriptPath(sessionId);
|
||||
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
|
||||
|
||||
const startedAt = Date.now();
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "start", startedAt },
|
||||
});
|
||||
|
||||
let text = "pong";
|
||||
if (params.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
|
||||
text = "REPLY_SKIP";
|
||||
} else if (params.extraSystemPrompt?.includes("Agent-to-agent announce step")) {
|
||||
text = "ANNOUNCE_SKIP";
|
||||
}
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
await fs.appendFile(sessionFile, `${JSON.stringify({ message })}\n`, "utf8");
|
||||
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: {
|
||||
phase: "end",
|
||||
startedAt,
|
||||
endedAt: Date.now(),
|
||||
spy.mockImplementation(async (opts: unknown) =>
|
||||
emitLifecycleAssistantReply({
|
||||
opts,
|
||||
defaultSessionId: "main",
|
||||
includeTimestamp: true,
|
||||
resolveText: (extraSystemPrompt) => {
|
||||
if (extraSystemPrompt?.includes("Agent-to-agent reply step")) {
|
||||
return "REPLY_SKIP";
|
||||
}
|
||||
if (extraSystemPrompt?.includes("Agent-to-agent announce step")) {
|
||||
return "ANNOUNCE_SKIP";
|
||||
}
|
||||
return "pong";
|
||||
},
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_send tool");
|
||||
}
|
||||
const tool = getSessionsSendTool();
|
||||
|
||||
const result = await tool.execute("call-loopback", {
|
||||
sessionKey: "main",
|
||||
@ -139,37 +159,13 @@ describe("sessions_send label lookup", () => {
|
||||
);
|
||||
|
||||
const spy = agentCommand as unknown as Mock<(opts: unknown) => Promise<void>>;
|
||||
spy.mockImplementation(async (opts: unknown) => {
|
||||
const params = opts as {
|
||||
sessionId?: string;
|
||||
runId?: string;
|
||||
extraSystemPrompt?: string;
|
||||
};
|
||||
const sessionId = params.sessionId ?? "test-labeled";
|
||||
const runId = params.runId ?? sessionId;
|
||||
const sessionFile = resolveSessionTranscriptPath(sessionId);
|
||||
await fs.mkdir(path.dirname(sessionFile), { recursive: true });
|
||||
|
||||
const startedAt = Date.now();
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "start", startedAt },
|
||||
});
|
||||
|
||||
const text = "labeled response";
|
||||
const message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
await fs.appendFile(sessionFile, `${JSON.stringify({ message })}\n`, "utf8");
|
||||
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end", startedAt, endedAt: Date.now() },
|
||||
});
|
||||
});
|
||||
spy.mockImplementation(async (opts: unknown) =>
|
||||
emitLifecycleAssistantReply({
|
||||
opts,
|
||||
defaultSessionId: "test-labeled",
|
||||
resolveText: () => "labeled response",
|
||||
}),
|
||||
);
|
||||
|
||||
// First, create a session with a label via sessions.patch
|
||||
const { callGateway } = await import("./call.js");
|
||||
@ -179,10 +175,7 @@ describe("sessions_send label lookup", () => {
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_send tool");
|
||||
}
|
||||
const tool = getSessionsSendTool();
|
||||
|
||||
// Send using label instead of sessionKey
|
||||
const result = await tool.execute("call-by-label", {
|
||||
@ -201,10 +194,7 @@ describe("sessions_send label lookup", () => {
|
||||
});
|
||||
|
||||
it("returns error when label not found", { timeout: 60_000 }, async () => {
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_send tool");
|
||||
}
|
||||
const tool = getSessionsSendTool();
|
||||
|
||||
const result = await tool.execute("call-missing-label", {
|
||||
label: "nonexistent-label",
|
||||
@ -217,10 +207,7 @@ describe("sessions_send label lookup", () => {
|
||||
});
|
||||
|
||||
it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => {
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_send tool");
|
||||
}
|
||||
const tool = getSessionsSendTool();
|
||||
|
||||
const result = await tool.execute("call-no-key", {
|
||||
message: "hello",
|
||||
|
||||
@ -9,6 +9,8 @@ import { withServer } from "./test-with-server.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
type GatewaySocket = Parameters<Parameters<typeof withServer>[0]>[0];
|
||||
|
||||
async function createFreshOperatorDevice(scopes: string[], nonce: string) {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
const { tmpdir } = await import("node:os");
|
||||
@ -41,6 +43,21 @@ async function createFreshOperatorDevice(scopes: string[], nonce: string) {
|
||||
};
|
||||
}
|
||||
|
||||
async function connectOperator(ws: GatewaySocket, scopes: string[]) {
|
||||
const nonce = await readConnectChallengeNonce(ws);
|
||||
expect(nonce).toBeTruthy();
|
||||
await connectOk(ws, {
|
||||
token: "secret",
|
||||
scopes,
|
||||
device: await createFreshOperatorDevice(scopes, String(nonce)),
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTalkConfig(config: { apiKey?: string; voiceId?: string }) {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({ talk: config });
|
||||
}
|
||||
|
||||
describe("gateway talk.config", () => {
|
||||
it("returns redacted talk config for read scope", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
@ -58,13 +75,7 @@ describe("gateway talk.config", () => {
|
||||
});
|
||||
|
||||
await withServer(async (ws) => {
|
||||
const nonce = await readConnectChallengeNonce(ws);
|
||||
expect(nonce).toBeTruthy();
|
||||
await connectOk(ws, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read"],
|
||||
device: await createFreshOperatorDevice(["operator.read"], String(nonce)),
|
||||
});
|
||||
await connectOperator(ws, ["operator.read"]);
|
||||
const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>(
|
||||
ws,
|
||||
"talk.config",
|
||||
@ -77,21 +88,10 @@ describe("gateway talk.config", () => {
|
||||
});
|
||||
|
||||
it("requires operator.talk.secrets for includeSecrets", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
apiKey: "secret-key-abc",
|
||||
},
|
||||
});
|
||||
await writeTalkConfig({ apiKey: "secret-key-abc" });
|
||||
|
||||
await withServer(async (ws) => {
|
||||
const nonce = await readConnectChallengeNonce(ws);
|
||||
expect(nonce).toBeTruthy();
|
||||
await connectOk(ws, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read"],
|
||||
device: await createFreshOperatorDevice(["operator.read"], String(nonce)),
|
||||
});
|
||||
await connectOperator(ws, ["operator.read"]);
|
||||
const res = await rpcReq(ws, "talk.config", { includeSecrets: true });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message).toContain("missing scope: operator.talk.secrets");
|
||||
@ -99,24 +99,10 @@ describe("gateway talk.config", () => {
|
||||
});
|
||||
|
||||
it("returns secrets for operator.talk.secrets scope", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
apiKey: "secret-key-abc",
|
||||
},
|
||||
});
|
||||
await writeTalkConfig({ apiKey: "secret-key-abc" });
|
||||
|
||||
await withServer(async (ws) => {
|
||||
const nonce = await readConnectChallengeNonce(ws);
|
||||
expect(nonce).toBeTruthy();
|
||||
await connectOk(ws, {
|
||||
token: "secret",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
device: await createFreshOperatorDevice(
|
||||
["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
String(nonce),
|
||||
),
|
||||
});
|
||||
await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]);
|
||||
const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", {
|
||||
includeSecrets: true,
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}),
|
||||
@ -62,11 +63,12 @@ describe("ensureGatewayStartupAuth", () => {
|
||||
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
|
||||
expect(result.persistedGeneratedToken).toBe(true);
|
||||
expect(result.auth.mode).toBe("token");
|
||||
expect(result.auth.token).toBe(result.generatedToken);
|
||||
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
const persisted = mocks.writeConfigFile.mock.calls[0]?.[0];
|
||||
expect(persisted?.gateway?.auth?.mode).toBe("token");
|
||||
expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken);
|
||||
expectGeneratedTokenPersistedToGatewayAuth({
|
||||
generatedToken: result.generatedToken,
|
||||
authToken: result.auth.token,
|
||||
persistedConfig: mocks.writeConfigFile.mock.calls[0]?.[0],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not generate when token already exists", async () => {
|
||||
|
||||
@ -92,6 +92,21 @@ function createMediaDisabledConfig(): OpenClawConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function createMediaDisabledConfigWithAllowedMimes(allowedMimes: string[]): OpenClawConfig {
|
||||
return {
|
||||
...createMediaDisabledConfig(),
|
||||
gateway: {
|
||||
http: {
|
||||
endpoints: {
|
||||
responses: {
|
||||
files: { allowedMimes },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createTempMediaFile(params: { fileName: string; content: Buffer | string }) {
|
||||
const dir = await createTempMediaDir();
|
||||
const mediaPath = path.join(dir, params.fileName);
|
||||
@ -135,6 +150,16 @@ async function applyWithDisabledMedia(params: {
|
||||
return { ctx, result };
|
||||
}
|
||||
|
||||
function expectFileNotApplied(params: {
|
||||
ctx: MsgContext;
|
||||
result: { appliedFile: boolean };
|
||||
body: string;
|
||||
}) {
|
||||
expect(params.result.appliedFile).toBe(false);
|
||||
expect(params.ctx.Body).toBe(params.body);
|
||||
expect(params.ctx.Body).not.toContain("<file");
|
||||
}
|
||||
|
||||
describe("applyMediaUnderstanding", () => {
|
||||
const mockedResolveApiKey = vi.mocked(resolveApiKeyForProvider);
|
||||
const mockedFetchRemoteMedia = vi.mocked(fetchRemoteMedia);
|
||||
@ -627,9 +652,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
mediaType: "audio/mpeg",
|
||||
});
|
||||
|
||||
expect(result.appliedFile).toBe(false);
|
||||
expect(ctx.Body).toBe("<media:audio>");
|
||||
expect(ctx.Body).not.toContain("<file");
|
||||
expectFileNotApplied({ ctx, result, body: "<media:audio>" });
|
||||
});
|
||||
|
||||
it("does not reclassify PDF attachments as text/plain", async () => {
|
||||
@ -639,18 +662,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
content: pseudoPdf,
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
...createMediaDisabledConfig(),
|
||||
gateway: {
|
||||
http: {
|
||||
endpoints: {
|
||||
responses: {
|
||||
files: { allowedMimes: ["text/plain"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = createMediaDisabledConfigWithAllowedMimes(["text/plain"]);
|
||||
|
||||
const { ctx, result } = await applyWithDisabledMedia({
|
||||
body: "<media:file>",
|
||||
@ -659,9 +671,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(result.appliedFile).toBe(false);
|
||||
expect(ctx.Body).toBe("<media:file>");
|
||||
expect(ctx.Body).not.toContain("<file");
|
||||
expectFileNotApplied({ ctx, result, body: "<media:file>" });
|
||||
});
|
||||
|
||||
it("respects configured allowedMimes for text-like attachments", async () => {
|
||||
@ -671,27 +681,14 @@ describe("applyMediaUnderstanding", () => {
|
||||
content: tsvText,
|
||||
});
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
...createMediaDisabledConfig(),
|
||||
gateway: {
|
||||
http: {
|
||||
endpoints: {
|
||||
responses: {
|
||||
files: { allowedMimes: ["text/plain"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const cfg = createMediaDisabledConfigWithAllowedMimes(["text/plain"]);
|
||||
const { ctx, result } = await applyWithDisabledMedia({
|
||||
body: "<media:file>",
|
||||
mediaPath: tsvPath,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(result.appliedFile).toBe(false);
|
||||
expect(ctx.Body).toBe("<media:file>");
|
||||
expect(ctx.Body).not.toContain("<file");
|
||||
expectFileNotApplied({ ctx, result, body: "<media:file>" });
|
||||
});
|
||||
|
||||
it("escapes XML special characters in filenames to prevent injection", async () => {
|
||||
@ -824,9 +821,7 @@ describe("applyMediaUnderstanding", () => {
|
||||
mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
|
||||
expect(result.appliedFile).toBe(false);
|
||||
expect(ctx.Body).toBe("<media:file>");
|
||||
expect(ctx.Body).not.toContain("<file");
|
||||
expectFileNotApplied({ ctx, result, body: "<media:file>" });
|
||||
});
|
||||
|
||||
it("keeps vendor +json attachments eligible for text extraction", async () => {
|
||||
|
||||
@ -29,37 +29,50 @@ function createOpenAiAudioCfg(extra?: Partial<OpenClawConfig>): OpenClawConfig {
|
||||
} as unknown as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function runAutoAudioCase(params: {
|
||||
transcribeAudio: (req: { model?: string }) => Promise<{ text: string; model: string }>;
|
||||
cfgExtra?: Partial<OpenClawConfig>;
|
||||
}) {
|
||||
let runResult: Awaited<ReturnType<typeof runCapability>> | undefined;
|
||||
await withAudioFixture("openclaw-auto-audio", async ({ ctx, media, cache }) => {
|
||||
const providerRegistry = createOpenAiAudioProvider(params.transcribeAudio);
|
||||
const cfg = createOpenAiAudioCfg(params.cfgExtra);
|
||||
runResult = await runCapability({
|
||||
capability: "audio",
|
||||
cfg,
|
||||
ctx,
|
||||
attachments: cache,
|
||||
media,
|
||||
providerRegistry,
|
||||
});
|
||||
});
|
||||
if (!runResult) {
|
||||
throw new Error("Expected auto audio case result");
|
||||
}
|
||||
return runResult;
|
||||
}
|
||||
|
||||
describe("runCapability auto audio entries", () => {
|
||||
it("uses provider keys to auto-enable audio transcription", async () => {
|
||||
await withAudioFixture("openclaw-auto-audio", async ({ ctx, media, cache }) => {
|
||||
let seenModel: string | undefined;
|
||||
const providerRegistry = createOpenAiAudioProvider(async (req) => {
|
||||
let seenModel: string | undefined;
|
||||
const result = await runAutoAudioCase({
|
||||
transcribeAudio: async (req) => {
|
||||
seenModel = req.model;
|
||||
return { text: "ok", model: req.model ?? "unknown" };
|
||||
});
|
||||
const cfg = createOpenAiAudioCfg();
|
||||
|
||||
const result = await runCapability({
|
||||
capability: "audio",
|
||||
cfg,
|
||||
ctx,
|
||||
attachments: cache,
|
||||
media,
|
||||
providerRegistry,
|
||||
});
|
||||
expect(result.outputs[0]?.text).toBe("ok");
|
||||
expect(seenModel).toBe("gpt-4o-mini-transcribe");
|
||||
expect(result.decision.outcome).toBe("success");
|
||||
},
|
||||
});
|
||||
expect(result.outputs[0]?.text).toBe("ok");
|
||||
expect(seenModel).toBe("gpt-4o-mini-transcribe");
|
||||
expect(result.decision.outcome).toBe("success");
|
||||
});
|
||||
|
||||
it("skips auto audio when disabled", async () => {
|
||||
await withAudioFixture("openclaw-auto-audio", async ({ ctx, media, cache }) => {
|
||||
const providerRegistry = createOpenAiAudioProvider(async () => ({
|
||||
const result = await runAutoAudioCase({
|
||||
transcribeAudio: async () => ({
|
||||
text: "ok",
|
||||
model: "whisper-1",
|
||||
}));
|
||||
const cfg = createOpenAiAudioCfg({
|
||||
}),
|
||||
cfgExtra: {
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
@ -67,29 +80,20 @@ describe("runCapability auto audio entries", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCapability({
|
||||
capability: "audio",
|
||||
cfg,
|
||||
ctx,
|
||||
attachments: cache,
|
||||
media,
|
||||
providerRegistry,
|
||||
});
|
||||
expect(result.outputs).toHaveLength(0);
|
||||
expect(result.decision.outcome).toBe("disabled");
|
||||
},
|
||||
});
|
||||
expect(result.outputs).toHaveLength(0);
|
||||
expect(result.decision.outcome).toBe("disabled");
|
||||
});
|
||||
|
||||
it("prefers explicitly configured audio model entries", async () => {
|
||||
await withAudioFixture("openclaw-auto-audio", async ({ ctx, media, cache }) => {
|
||||
let seenModel: string | undefined;
|
||||
const providerRegistry = createOpenAiAudioProvider(async (req) => {
|
||||
let seenModel: string | undefined;
|
||||
const result = await runAutoAudioCase({
|
||||
transcribeAudio: async (req) => {
|
||||
seenModel = req.model;
|
||||
return { text: "ok", model: req.model ?? "unknown" };
|
||||
});
|
||||
const cfg = createOpenAiAudioCfg({
|
||||
},
|
||||
cfgExtra: {
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
@ -97,19 +101,10 @@ describe("runCapability auto audio entries", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCapability({
|
||||
capability: "audio",
|
||||
cfg,
|
||||
ctx,
|
||||
attachments: cache,
|
||||
media,
|
||||
providerRegistry,
|
||||
});
|
||||
|
||||
expect(result.outputs[0]?.text).toBe("ok");
|
||||
expect(seenModel).toBe("whisper-1");
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.outputs[0]?.text).toBe("ok");
|
||||
expect(seenModel).toBe("whisper-1");
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,6 +20,13 @@ const createFetchMock = () =>
|
||||
json: async () => ({ data: [{ embedding: [1, 2, 3] }] }),
|
||||
}));
|
||||
|
||||
const createGeminiFetchMock = () =>
|
||||
vi.fn(async (_input?: unknown, _init?: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ embedding: { values: [1, 2, 3] } }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
@ -57,6 +64,25 @@ function createLocalProvider(options?: { fallback?: "none" | "openai" }) {
|
||||
});
|
||||
}
|
||||
|
||||
function expectAutoSelectedProvider(
|
||||
result: Awaited<ReturnType<typeof createEmbeddingProvider>>,
|
||||
expectedId: "openai" | "gemini",
|
||||
) {
|
||||
expect(result.requestedProvider).toBe("auto");
|
||||
const provider = requireProvider(result);
|
||||
expect(provider.id).toBe(expectedId);
|
||||
return provider;
|
||||
}
|
||||
|
||||
function createAutoProvider(model = "") {
|
||||
return createEmbeddingProvider({
|
||||
config: {} as never,
|
||||
provider: "auto",
|
||||
model,
|
||||
fallback: "none",
|
||||
});
|
||||
}
|
||||
|
||||
describe("embedding provider remote overrides", () => {
|
||||
it("uses remote baseUrl/apiKey and merges headers", async () => {
|
||||
const fetchMock = createFetchMock();
|
||||
@ -143,11 +169,7 @@ describe("embedding provider remote overrides", () => {
|
||||
});
|
||||
|
||||
it("builds Gemini embeddings requests with api key header", async () => {
|
||||
const fetchMock = vi.fn(async (_input?: unknown, _init?: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ embedding: { values: [1, 2, 3] } }),
|
||||
}));
|
||||
const fetchMock = createGeminiFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
mockResolvedProviderKey("provider-key");
|
||||
|
||||
@ -194,24 +216,12 @@ describe("embedding provider auto selection", () => {
|
||||
throw new Error(`No API key found for provider "${provider}".`);
|
||||
});
|
||||
|
||||
const result = await createEmbeddingProvider({
|
||||
config: {} as never,
|
||||
provider: "auto",
|
||||
model: "",
|
||||
fallback: "none",
|
||||
});
|
||||
|
||||
expect(result.requestedProvider).toBe("auto");
|
||||
const provider = requireProvider(result);
|
||||
expect(provider.id).toBe("openai");
|
||||
const result = await createAutoProvider();
|
||||
expectAutoSelectedProvider(result, "openai");
|
||||
});
|
||||
|
||||
it("uses gemini when openai is missing", async () => {
|
||||
const fetchMock = vi.fn(async (_input?: unknown, _init?: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ embedding: { values: [1, 2, 3] } }),
|
||||
}));
|
||||
const fetchMock = createGeminiFetchMock();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
|
||||
if (provider === "openai") {
|
||||
@ -223,16 +233,8 @@ describe("embedding provider auto selection", () => {
|
||||
throw new Error(`Unexpected provider ${provider}`);
|
||||
});
|
||||
|
||||
const result = await createEmbeddingProvider({
|
||||
config: {} as never,
|
||||
provider: "auto",
|
||||
model: "",
|
||||
fallback: "none",
|
||||
});
|
||||
|
||||
expect(result.requestedProvider).toBe("auto");
|
||||
const provider = requireProvider(result);
|
||||
expect(provider.id).toBe("gemini");
|
||||
const result = await createAutoProvider();
|
||||
const provider = expectAutoSelectedProvider(result, "gemini");
|
||||
await provider.embedQuery("hello");
|
||||
const [url] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(url).toBe(
|
||||
|
||||
@ -331,6 +331,31 @@ function expectSingleDispatchedSlashBody(expectedBody: string) {
|
||||
expect(call.ctx?.Body).toBe(expectedBody);
|
||||
}
|
||||
|
||||
type ActionsBlockPayload = {
|
||||
blocks?: Array<{ type: string; block_id?: string }>;
|
||||
};
|
||||
|
||||
async function runCommandAndResolveActionsBlock(
|
||||
handler: (args: unknown) => Promise<void>,
|
||||
): Promise<{
|
||||
respond: ReturnType<typeof vi.fn>;
|
||||
payload: ActionsBlockPayload;
|
||||
blockId?: string;
|
||||
}> {
|
||||
const { respond } = await runCommandHandler(handler);
|
||||
const payload = respond.mock.calls[0]?.[0] as ActionsBlockPayload;
|
||||
const blockId = payload.blocks?.find((block) => block.type === "actions")?.block_id;
|
||||
return { respond, payload, blockId };
|
||||
}
|
||||
|
||||
async function getFirstActionElementFromCommand(handler: (args: unknown) => Promise<void>) {
|
||||
const { respond } = await runCommandHandler(handler);
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
||||
const actions = findFirstActionsBlock(payload);
|
||||
return actions?.elements?.[0];
|
||||
}
|
||||
|
||||
async function runArgMenuAction(
|
||||
handler: (args: unknown) => Promise<void>,
|
||||
params: {
|
||||
@ -416,35 +441,20 @@ describe("Slack native command argument menus", () => {
|
||||
});
|
||||
|
||||
it("falls back to buttons when static_select value limit would be exceeded", async () => {
|
||||
const { respond } = await runCommandHandler(reportLongHandler);
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
||||
const actions = findFirstActionsBlock(payload);
|
||||
const firstElement = actions?.elements?.[0];
|
||||
const firstElement = await getFirstActionElementFromCommand(reportLongHandler);
|
||||
expect(firstElement?.type).toBe("button");
|
||||
expect(firstElement?.confirm).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows an overflow menu when choices fit compact range", async () => {
|
||||
const { respond } = await runCommandHandler(reportCompactHandler);
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
||||
const actions = findFirstActionsBlock(payload);
|
||||
const element = actions?.elements?.[0];
|
||||
const element = await getFirstActionElementFromCommand(reportCompactHandler);
|
||||
expect(element?.type).toBe("overflow");
|
||||
expect(element?.action_id).toBe("openclaw_cmdarg");
|
||||
expect(element?.confirm).toBeTruthy();
|
||||
});
|
||||
|
||||
it("escapes mrkdwn characters in confirm dialog text", async () => {
|
||||
const { respond } = await runCommandHandler(unsafeConfirmHandler);
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
const payload = respond.mock.calls[0]?.[0] as { blocks?: Array<{ type: string }> };
|
||||
const actions = findFirstActionsBlock(payload);
|
||||
const element = actions?.elements?.[0] as
|
||||
const element = (await getFirstActionElementFromCommand(unsafeConfirmHandler)) as
|
||||
| { confirm?: { text?: { text?: string } } }
|
||||
| undefined;
|
||||
expect(element?.confirm?.text?.text).toContain(
|
||||
@ -494,29 +504,21 @@ describe("Slack native command argument menus", () => {
|
||||
});
|
||||
|
||||
it("shows an external_select menu when choices exceed static_select options max", async () => {
|
||||
const { respond } = await runCommandHandler(reportExternalHandler);
|
||||
const { respond, payload, blockId } =
|
||||
await runCommandAndResolveActionsBlock(reportExternalHandler);
|
||||
|
||||
expect(respond).toHaveBeenCalledTimes(1);
|
||||
const payload = respond.mock.calls[0]?.[0] as {
|
||||
blocks?: Array<{ type: string; block_id?: string }>;
|
||||
};
|
||||
const actions = findFirstActionsBlock(payload);
|
||||
const element = actions?.elements?.[0];
|
||||
expect(element?.type).toBe("external_select");
|
||||
expect(element?.action_id).toBe("openclaw_cmdarg");
|
||||
const blockId = payload.blocks?.find((block) => block.type === "actions")?.block_id;
|
||||
expect(blockId).toContain("openclaw_cmdarg_ext:");
|
||||
const token = (blockId ?? "").slice("openclaw_cmdarg_ext:".length);
|
||||
expect(token).toMatch(/^[A-Za-z0-9_-]{24}$/);
|
||||
});
|
||||
|
||||
it("serves filtered options for external_select menus", async () => {
|
||||
const { respond } = await runCommandHandler(reportExternalHandler);
|
||||
|
||||
const payload = respond.mock.calls[0]?.[0] as {
|
||||
blocks?: Array<{ type: string; block_id?: string }>;
|
||||
};
|
||||
const blockId = payload.blocks?.find((block) => block.type === "actions")?.block_id;
|
||||
const { blockId } = await runCommandAndResolveActionsBlock(reportExternalHandler);
|
||||
expect(blockId).toContain("openclaw_cmdarg_ext:");
|
||||
|
||||
const ackOptions = vi.fn().mockResolvedValue(undefined);
|
||||
@ -538,12 +540,7 @@ describe("Slack native command argument menus", () => {
|
||||
});
|
||||
|
||||
it("rejects external_select option requests without user identity", async () => {
|
||||
const { respond } = await runCommandHandler(reportExternalHandler);
|
||||
|
||||
const payload = respond.mock.calls[0]?.[0] as {
|
||||
blocks?: Array<{ type: string; block_id?: string }>;
|
||||
};
|
||||
const blockId = payload.blocks?.find((block) => block.type === "actions")?.block_id;
|
||||
const { blockId } = await runCommandAndResolveActionsBlock(reportExternalHandler);
|
||||
expect(blockId).toContain("openclaw_cmdarg_ext:");
|
||||
|
||||
const ackOptions = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@ -43,6 +43,20 @@ describe("configureGatewayForOnboarding", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function createQuickstartGateway(authMode: "token" | "password") {
|
||||
return {
|
||||
hasExisting: false,
|
||||
port: 18789,
|
||||
bind: "loopback" as const,
|
||||
authMode,
|
||||
tailscaleMode: "off" as const,
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
customBindHost: undefined,
|
||||
tailscaleResetOnExit: false,
|
||||
};
|
||||
}
|
||||
|
||||
it("generates a token when the prompt returns undefined", async () => {
|
||||
mocks.randomToken.mockReturnValue("generated-token");
|
||||
|
||||
@ -57,17 +71,7 @@ describe("configureGatewayForOnboarding", () => {
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: {
|
||||
hasExisting: false,
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
authMode: "token",
|
||||
tailscaleMode: "off",
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
customBindHost: undefined,
|
||||
tailscaleResetOnExit: false,
|
||||
},
|
||||
quickstartGateway: createQuickstartGateway("token"),
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
@ -97,17 +101,7 @@ describe("configureGatewayForOnboarding", () => {
|
||||
baseConfig: {},
|
||||
nextConfig: {},
|
||||
localPort: 18789,
|
||||
quickstartGateway: {
|
||||
hasExisting: false,
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
authMode: "password",
|
||||
tailscaleMode: "off",
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
customBindHost: undefined,
|
||||
tailscaleResetOnExit: false,
|
||||
},
|
||||
quickstartGateway: createQuickstartGateway("password"),
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user