test: dedupe gateway browser discord and channel coverage

This commit is contained in:
Peter Steinberger 2026-02-22 17:11:42 +00:00
parent 34ea33f057
commit 296b19e413
29 changed files with 938 additions and 1041 deletions

View File

@ -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(() => {

View File

@ -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 }> => {

View File

@ -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);

View File

@ -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,
);

View File

@ -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,
);

View File

@ -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,

View File

@ -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);

View 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",
},
};
}

View File

@ -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);

View File

@ -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(() => {

View File

@ -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 () => {

View File

@ -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 });
});
});

View File

@ -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(

View File

@ -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 [];
}

View File

@ -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 });
},
});

View File

@ -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: ");
}
}
});
});
});

View File

@ -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.",
);

View File

@ -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",

View File

@ -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 () => {

View File

@ -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,

View File

@ -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);

View File

@ -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",

View File

@ -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,
});

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -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");
});
});

View File

@ -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(

View File

@ -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);

View File

@ -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,
});