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 { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
loadConfig: vi.fn<() => OpenClawConfig>(), loadConfig: vi.fn<() => OpenClawConfig>(),
@@ -38,12 +39,12 @@ describe("ensureBrowserControlAuth", () => {
generatedToken?: string; generatedToken?: string;
auth: { token?: string }; auth: { token?: string };
}) => { }) => {
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1); expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
const persisted = mocks.writeConfigFile.mock.calls[0]?.[0]; expectGeneratedTokenPersistedToGatewayAuth({
expect(persisted?.gateway?.auth?.mode).toBe("token"); generatedToken: result.generatedToken,
expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken); authToken: result.auth.token,
persistedConfig: mocks.writeConfigFile.mock.calls[0]?.[0],
});
}; };
beforeEach(() => { beforeEach(() => {

View File

@@ -426,7 +426,7 @@ function createProfileContext(
return chosen; return chosen;
}; };
const focusTab = async (targetId: string): Promise<void> => { const resolveTargetIdOrThrow = async (targetId: string): Promise<string> => {
const tabs = await listTabs(); const tabs = await listTabs();
const resolved = resolveTargetIdFromTabs(targetId, tabs); const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) { if (!resolved.ok) {
@@ -435,6 +435,11 @@ function createProfileContext(
} }
throw new Error("tab not found"); throw new Error("tab not found");
} }
return resolved.targetId;
};
const focusTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (!profile.cdpIsLoopback) { if (!profile.cdpIsLoopback) {
const mod = await getPwAiModule({ mode: "strict" }); const mod = await getPwAiModule({ mode: "strict" });
@@ -443,28 +448,21 @@ function createProfileContext(
if (typeof focusPageByTargetIdViaPlaywright === "function") { if (typeof focusPageByTargetIdViaPlaywright === "function") {
await focusPageByTargetIdViaPlaywright({ await focusPageByTargetIdViaPlaywright({
cdpUrl: profile.cdpUrl, cdpUrl: profile.cdpUrl,
targetId: resolved.targetId, targetId: resolvedTargetId,
}); });
const profileState = getProfileState(); const profileState = getProfileState();
profileState.lastTargetId = resolved.targetId; profileState.lastTargetId = resolvedTargetId;
return; return;
} }
} }
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolved.targetId}`)); await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolvedTargetId}`));
const profileState = getProfileState(); const profileState = getProfileState();
profileState.lastTargetId = resolved.targetId; profileState.lastTargetId = resolvedTargetId;
}; };
const closeTab = async (targetId: string): Promise<void> => { const closeTab = async (targetId: string): Promise<void> => {
const tabs = await listTabs(); const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
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");
}
// For remote profiles, use Playwright's persistent connection to close tabs // For remote profiles, use Playwright's persistent connection to close tabs
if (!profile.cdpIsLoopback) { if (!profile.cdpIsLoopback) {
@@ -474,13 +472,13 @@ function createProfileContext(
if (typeof closePageByTargetIdViaPlaywright === "function") { if (typeof closePageByTargetIdViaPlaywright === "function") {
await closePageByTargetIdViaPlaywright({ await closePageByTargetIdViaPlaywright({
cdpUrl: profile.cdpUrl, cdpUrl: profile.cdpUrl,
targetId: resolved.targetId, targetId: resolvedTargetId,
}); });
return; return;
} }
} }
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolved.targetId}`)); await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolvedTargetId}`));
}; };
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => { const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {

View File

@@ -90,6 +90,40 @@ function createRuntimeWithExitSignal(exitCallOrder?: string[]) {
return { runtime, exited }; 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", () => { describe("runGatewayLoop", () => {
it("exits 0 on SIGTERM after graceful close", async () => { it("exits 0 on SIGTERM after graceful close", async () => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -221,15 +255,7 @@ describe("runGatewayLoop", () => {
}); });
const close = vi.fn(async () => {}); const close = vi.fn(async () => {});
let resolveStarted: (() => void) | null = null; const { start, started } = createSignaledStart(close);
const started = new Promise<void>((resolve) => {
resolveStarted = resolve;
});
const start = vi.fn(async () => {
resolveStarted?.();
return { close };
});
const exitCallOrder: string[] = []; const exitCallOrder: string[] = [];
const { runtime, exited } = createRuntimeWithExitSignal(exitCallOrder); const { runtime, exited } = createRuntimeWithExitSignal(exitCallOrder);
@@ -237,15 +263,9 @@ describe("runGatewayLoop", () => {
exitCallOrder.push("lockRelease"); exitCallOrder.push("lockRelease");
}); });
vi.resetModules(); const { loopPromise: _loopPromise } = await runLoopWithStart({ start, runtime });
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"],
});
await started; await waitForStart(started);
await new Promise<void>((resolve) => setImmediate(resolve));
process.emit("SIGUSR1"); process.emit("SIGUSR1");
@@ -272,26 +292,13 @@ describe("runGatewayLoop", () => {
}); });
const close = vi.fn(async () => {}); const close = vi.fn(async () => {});
let resolveStarted: (() => void) | null = null; const { start, started } = createSignaledStart(close);
const started = new Promise<void>((resolve) => {
resolveStarted = resolve;
});
const start = vi.fn(async () => {
resolveStarted?.();
return { close };
});
const { runtime, exited } = createRuntimeWithExitSignal(); const { runtime, exited } = createRuntimeWithExitSignal();
vi.resetModules(); const { loopPromise: _loopPromise } = await runLoopWithStart({ start, runtime });
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"],
});
await started; await waitForStart(started);
await new Promise<void>((resolve) => setImmediate(resolve));
process.emit("SIGUSR1"); process.emit("SIGUSR1");
await expect(exited).resolves.toBe(1); await expect(exited).resolves.toBe(1);

View File

@@ -138,6 +138,68 @@ function createDefaultThreadConfig(): LoadedConfig {
} as 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 } = {}) { function createThreadChannel(params: { includeStarter?: boolean } = {}) {
return { return {
type: ChannelType.GuildText, type: ChannelType.GuildText,
@@ -209,56 +271,18 @@ describe("discord tool result dispatch", () => {
it( it(
"accepts guild messages when mentionPatterns match", "accepts guild messages when mentionPatterns match",
async () => { async () => {
const cfg = { const cfg = createMentionRequiredGuildConfig({
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 } },
},
},
messages: { messages: {
responsePrefix: "PFX", responsePrefix: "PFX",
groupChat: { mentionPatterns: ["\\bopenclaw\\b"] }, groupChat: { mentionPatterns: ["\\bopenclaw\\b"] },
}, },
} as ReturnType<typeof import("../config/config.js").loadConfig>; });
const handler = await createHandler(cfg); const handler = await createHandler(cfg);
const client = createGuildTextClient();
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
}),
} as unknown as Client;
await handler( await handler(
{ createGuildMessageEvent({ messageId: "m2", content: "openclaw: hello" }),
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",
},
client, client,
); );
@@ -323,46 +347,16 @@ describe("discord tool result dispatch", () => {
); );
it("accepts guild reply-to-bot messages as implicit mentions", async () => { it("accepts guild reply-to-bot messages as implicit mentions", async () => {
const cfg = { const cfg = createMentionRequiredGuildConfig();
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 handler = await createHandler(cfg); const handler = await createHandler(cfg);
const client = createGuildTextClient();
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
}),
} as unknown as Client;
await handler( await handler(
{ createGuildMessageEvent({
message: { messageId: "m3",
id: "m3", content: "following up",
content: "following up", messagePatch: {
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u1", bot: false, username: "Ada" },
referencedMessage: { referencedMessage: {
id: "m2", id: "m2",
channelId: "c1", channelId: "c1",
@@ -377,21 +371,19 @@ describe("discord tool result dispatch", () => {
author: { id: "bot-id", bot: true, username: "OpenClaw" }, author: { id: "bot-id", bot: true, username: "OpenClaw" },
}, },
}, },
author: { id: "u1", bot: false, username: "Ada" }, eventPatch: {
member: { nickname: "Ada" }, channel: { id: "c1", type: ChannelType.GuildText },
guild: { id: "g1", name: "Guild" }, client,
guild_id: "g1", data: {
channel: { id: "c1", type: ChannelType.GuildText }, id: "m3",
client, content: "following up",
data: { channel_id: "c1",
id: "m3", guild_id: "g1",
content: "following up", type: MessageType.Default,
channel_id: "c1", mentions: [],
guild_id: "g1", },
type: MessageType.Default,
mentions: [],
}, },
}, }),
client, client,
); );

View File

@@ -51,15 +51,18 @@ const CATEGORY_GUILD_CFG = {
}, },
} satisfies Config; } satisfies Config;
async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown) => void }) { function createHandlerBaseConfig(
return createDiscordMessageHandler({ cfg: Config,
cfg: opts.cfg, runtimeError?: (err: unknown) => void,
discordConfig: opts.cfg.channels?.discord, ): Parameters<typeof createDiscordMessageHandler>[0] {
return {
cfg,
discordConfig: cfg.channels?.discord,
accountId: "default", accountId: "default",
token: "token", token: "token",
runtime: { runtime: {
log: vi.fn(), log: vi.fn(),
error: opts.runtimeError ?? vi.fn(), error: runtimeError ?? vi.fn(),
exit: (code: number): never => { exit: (code: number): never => {
throw new Error(`exit ${code}`); throw new Error(`exit ${code}`);
}, },
@@ -73,7 +76,11 @@ async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown
dmEnabled: true, dmEnabled: true,
groupDmEnabled: false, groupDmEnabled: false,
threadBindings: createNoopThreadBindingManager("default"), threadBindings: createNoopThreadBindingManager("default"),
}); };
}
async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown) => void }) {
return createDiscordMessageHandler(createHandlerBaseConfig(opts.cfg, opts.runtimeError));
} }
function createDmClient() { function createDmClient() {
@@ -87,29 +94,10 @@ function createDmClient() {
async function createCategoryGuildHandler() { async function createCategoryGuildHandler() {
return createDiscordMessageHandler({ return createDiscordMessageHandler({
cfg: CATEGORY_GUILD_CFG, ...createHandlerBaseConfig(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,
guildEntries: { guildEntries: {
"*": { requireMention: false, channels: { c1: { allow: true } } }, "*": { requireMention: false, channels: { c1: { allow: true } } },
}, },
threadBindings: createNoopThreadBindingManager("default"),
}); });
} }
@@ -124,6 +112,32 @@ function createCategoryGuildClient() {
} as unknown as Client; } 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", () => { describe("discord tool result dispatch", () => {
it("uses channel id allowlists for non-thread channels with categories", async () => { it("uses channel id allowlists for non-thread channels with categories", async () => {
let capturedCtx: { SessionKey?: string } | undefined; let capturedCtx: { SessionKey?: string } | undefined;
@@ -137,25 +151,10 @@ describe("discord tool result dispatch", () => {
const client = createCategoryGuildClient(); const client = createCategoryGuildClient();
await handler( await handler(
{ createCategoryGuildEvent({
message: { messageId: "m-category",
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" },
},
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" }, author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
member: { displayName: "Ada" }, }),
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client, client,
); );
@@ -174,25 +173,11 @@ describe("discord tool result dispatch", () => {
const client = createCategoryGuildClient(); const client = createCategoryGuildClient();
await handler( await handler(
{ createCategoryGuildEvent({
message: { messageId: "m-prefix",
id: "m-prefix", timestamp: new Date("2026-01-17T00:00:00Z").toISOString(),
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" },
},
author: { id: "u1", bot: false, username: "Ada", discriminator: "1234" }, author: { id: "u1", bot: false, username: "Ada", discriminator: "1234" },
member: { displayName: "Ada" }, }),
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client, client,
); );

View File

@@ -223,6 +223,12 @@ function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayload
return { components }; 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: { function createExecApprovalRequestContainer(params: {
request: ExecApprovalRequest; request: ExecApprovalRequest;
cfg: OpenClawConfig; cfg: OpenClawConfig;
@@ -230,8 +236,7 @@ function createExecApprovalRequestContainer(params: {
actionRow?: Row<Button>; actionRow?: Row<Button>;
}): ExecApprovalContainer { }): ExecApprovalContainer {
const commandText = params.request.request.command; const commandText = params.request.request.command;
const commandRaw = commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText; const commandPreview = formatCommandPreview(commandText, 1000);
const commandPreview = commandRaw.replace(/`/g, "\u200b`");
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000)); const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
return new ExecApprovalContainer({ return new ExecApprovalContainer({
@@ -255,8 +260,7 @@ function createResolvedContainer(params: {
accountId: string; accountId: string;
}): ExecApprovalContainer { }): ExecApprovalContainer {
const commandText = params.request.request.command; const commandText = params.request.request.command;
const commandRaw = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText; const commandPreview = formatCommandPreview(commandText, 500);
const commandPreview = commandRaw.replace(/`/g, "\u200b`");
const decisionLabel = const decisionLabel =
params.decision === "allow-once" params.decision === "allow-once"
@@ -289,8 +293,7 @@ function createExpiredContainer(params: {
accountId: string; accountId: string;
}): ExecApprovalContainer { }): ExecApprovalContainer {
const commandText = params.request.request.command; const commandText = params.request.request.command;
const commandRaw = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText; const commandPreview = formatCommandPreview(commandText, 500);
const commandPreview = commandRaw.replace(/`/g, "\u200b`");
return new ExecApprovalContainer({ return new ExecApprovalContainer({
cfg: params.cfg, cfg: params.cfg,

View File

@@ -10,17 +10,21 @@ const sendMocks = vi.hoisted(() => ({
reactMessageDiscord: vi.fn(async () => {}), reactMessageDiscord: vi.fn(async () => {}),
removeReactionDiscord: vi.fn(async () => {}), removeReactionDiscord: vi.fn(async () => {}),
})); }));
const deliveryMocks = vi.hoisted(() => ({ function createMockDraftStream() {
editMessageDiscord: vi.fn(async () => ({})), return {
deliverDiscordReply: vi.fn(async () => {}),
createDiscordDraftStream: vi.fn(() => ({
update: vi.fn<(text: string) => void>(() => {}), update: vi.fn<(text: string) => void>(() => {}),
flush: vi.fn(async () => {}), flush: vi.fn(async () => {}),
messageId: vi.fn(() => "preview-1"), messageId: vi.fn(() => "preview-1"),
clear: vi.fn(async () => {}), clear: vi.fn(async () => {}),
stop: vi.fn(async () => {}), stop: vi.fn(async () => {}),
forceNewMessage: vi.fn(() => {}), forceNewMessage: vi.fn(() => {}),
})), };
}
const deliveryMocks = vi.hoisted(() => ({
editMessageDiscord: vi.fn(async () => ({})),
deliverDiscordReply: vi.fn(async () => {}),
createDiscordDraftStream: vi.fn(() => createMockDraftStream()),
})); }));
const editMessageDiscord = deliveryMocks.editMessageDiscord; const editMessageDiscord = deliveryMocks.editMessageDiscord;
const deliverDiscordReply = deliveryMocks.deliverDiscordReply; const deliverDiscordReply = deliveryMocks.deliverDiscordReply;
@@ -373,17 +377,6 @@ describe("processDiscordMessage draft streaming", () => {
await processDiscordMessage(ctx as any); 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() { async function createBlockModeContext() {
return await createBaseContext({ return await createBaseContext({
cfg: { cfg: {
@@ -424,17 +417,7 @@ describe("processDiscordMessage draft streaming", () => {
}); });
it("falls back to standard send when final needs multiple chunks", async () => { it("falls back to standard send when final needs multiple chunks", async () => {
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => { await runSingleChunkFinalScenario({ streamMode: "partial", maxLinesPerMessage: 1 });
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);
expect(editMessageDiscord).not.toHaveBeenCalled(); expect(editMessageDiscord).not.toHaveBeenCalled();
expect(deliverDiscordReply).toHaveBeenCalledTimes(1); 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 { serializePayload } from "@buape/carbon";
import { ComponentType } from "discord-api-types/v10"; import { ComponentType } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest"; 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 * as modelsCommandModule from "../../auto-reply/reply/commands-models.js";
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { import {
@@ -20,21 +19,7 @@ import {
renderDiscordModelPickerRecentsView, renderDiscordModelPickerRecentsView,
toDiscordModelPickerMessagePayload, toDiscordModelPickerMessagePayload,
} from "./model-picker.js"; } from "./model-picker.js";
import { createModelsProviderData } from "./model-picker.test-utils.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",
},
};
}
type SerializedComponent = { type SerializedComponent = {
type: number; 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", () => { describe("loadDiscordModelPickerData", () => {
it("reuses buildModelsProviderData as source of truth", async () => { it("reuses buildModelsProviderData as source of truth", async () => {
const expected = createModelsProviderData({ openai: ["gpt-4o"] }); const expected = createModelsProviderData({ openai: ["gpt-4o"] });
@@ -467,7 +472,7 @@ describe("Discord model picker rendering", () => {
anthropic: ["claude-sonnet-4-5"], anthropic: ["claude-sonnet-4-5"],
}); });
const rendered = renderDiscordModelPickerModelsView({ const rows = renderModelsViewRows({
command: "model", command: "model",
userId: "42", userId: "42",
data, data,
@@ -477,12 +482,6 @@ describe("Discord model picker rendering", () => {
currentModel: "openai/gpt-4o", currentModel: "openai/gpt-4o",
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"], 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 buttonRow = rows[2];
const buttons = buttonRow?.components ?? []; const buttons = buttonRow?.components ?? [];
expect(buttons).toHaveLength(4); expect(buttons).toHaveLength(4);
@@ -497,7 +496,7 @@ describe("Discord model picker rendering", () => {
openai: ["gpt-4.1", "gpt-4o"], openai: ["gpt-4.1", "gpt-4o"],
}); });
const rendered = renderDiscordModelPickerModelsView({ const rows = renderModelsViewRows({
command: "model", command: "model",
userId: "42", userId: "42",
data, data,
@@ -506,12 +505,6 @@ describe("Discord model picker rendering", () => {
providerPage: 1, providerPage: 1,
currentModel: "openai/gpt-4o", currentModel: "openai/gpt-4o",
}); });
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
const buttonRow = rows[2]; const buttonRow = rows[2];
const buttons = buttonRow?.components ?? []; const buttons = buttonRow?.components ?? [];
expect(buttons).toHaveLength(3); expect(buttons).toHaveLength(3);
@@ -532,19 +525,13 @@ describe("Discord model picker recents view", () => {
// Default is openai/gpt-4.1 (first key in entries). // Default is openai/gpt-4.1 (first key in entries).
// Neither quickModel matches, so no deduping — 1 default + 2 recents + 1 back = 4 rows. // Neither quickModel matches, so no deduping — 1 default + 2 recents + 1 back = 4 rows.
const rendered = renderDiscordModelPickerRecentsView({ const rows = renderRecentsViewRows({
command: "model", command: "model",
userId: "42", userId: "42",
data, data,
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"], quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
currentModel: "openai/gpt-4o", currentModel: "openai/gpt-4o",
}); });
const payload = serializePayload(toDiscordModelPickerMessagePayload(rendered)) as {
components?: SerializedComponent[];
};
const rows = extractContainerRows(payload.components);
expect(rows).toHaveLength(4); expect(rows).toHaveLength(4);
// First row: default model button (slot 1). // First row: default model button (slot 1).
@@ -577,19 +564,13 @@ describe("Discord model picker recents view", () => {
openai: ["gpt-4o"], openai: ["gpt-4o"],
}); });
const rendered = renderDiscordModelPickerRecentsView({ const rows = renderRecentsViewRows({
command: "model", command: "model",
userId: "42", userId: "42",
data, data,
quickModels: ["openai/gpt-4o"], quickModels: ["openai/gpt-4o"],
currentModel: "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 }; const defaultBtn = rows[0]?.components?.[0] as { label?: string };
expect(defaultBtn?.label).toContain("(default)"); expect(defaultBtn?.label).toContain("(default)");
}); });
@@ -600,19 +581,13 @@ describe("Discord model picker recents view", () => {
anthropic: ["claude-sonnet-4-5"], anthropic: ["claude-sonnet-4-5"],
}); });
// Default is openai/gpt-4o (first key). quickModels contains the default. // Default is openai/gpt-4o (first key). quickModels contains the default.
const rendered = renderDiscordModelPickerRecentsView({ const rows = renderRecentsViewRows({
command: "model", command: "model",
userId: "42", userId: "42",
data, data,
quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"], quickModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-5"],
currentModel: "openai/gpt-4o", 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) // 1 default + 1 deduped recent + 1 back = 3 rows (openai/gpt-4o not shown twice)
expect(rows).toHaveLength(3); expect(rows).toHaveLength(3);

View File

@@ -91,7 +91,7 @@ vi.mock("../../config/sessions.js", async (importOriginal) => {
describe("agent components", () => { describe("agent components", () => {
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
const createDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => { const createBaseDmInteraction = (overrides: Record<string, unknown> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined); const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined); const defer = vi.fn().mockResolvedValue(undefined);
const interaction = { const interaction = {
@@ -100,22 +100,31 @@ describe("agent components", () => {
defer, defer,
reply, reply,
...overrides, ...overrides,
} as unknown as ButtonInteraction; };
return { interaction, defer, reply }; return { interaction, defer, reply };
}; };
const createDmSelectInteraction = (overrides: Partial<StringSelectMenuInteraction> = {}) => { const createDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined); const { interaction, defer, reply } = createBaseDmInteraction(
const defer = vi.fn().mockResolvedValue(undefined); overrides as Record<string, unknown>,
const interaction = { );
rawData: { channel_id: "dm-channel" }, return {
user: { id: "123456789", username: "Alice", discriminator: "1234" }, interaction: interaction as unknown as ButtonInteraction,
values: ["alpha"],
defer, defer,
reply, 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(() => { beforeEach(() => {

View File

@@ -12,28 +12,13 @@ import * as globalsModule from "../../globals.js";
import * as timeoutModule from "../../utils/with-timeout.js"; import * as timeoutModule from "../../utils/with-timeout.js";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js"; import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js"; import * as modelPickerModule from "./model-picker.js";
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
import { import {
createDiscordModelPickerFallbackButton, createDiscordModelPickerFallbackButton,
createDiscordModelPickerFallbackSelect, createDiscordModelPickerFallbackSelect,
} from "./native-command.js"; } from "./native-command.js";
import { createNoopThreadBindingManager, type ThreadBindingManager } from "./thread-bindings.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 ModelPickerContext = Parameters<typeof createDiscordModelPickerFallbackButton>[0];
type PickerButton = ReturnType<typeof createDiscordModelPickerFallbackButton>; type PickerButton = ReturnType<typeof createDiscordModelPickerFallbackButton>;
type PickerSelect = ReturnType<typeof createDiscordModelPickerFallbackSelect>; type PickerSelect = ReturnType<typeof createDiscordModelPickerFallbackSelect>;
@@ -55,6 +40,10 @@ type MockInteraction = {
client: object; client: object;
}; };
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
return createBaseModelsProviderData(entries, { defaultProviderOrder: "sorted" });
}
function createModelPickerContext(): ModelPickerContext { function createModelPickerContext(): ModelPickerContext {
const cfg = { const cfg = {
channels: { 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: { function createBoundThreadBindingManager(params: {
accountId: string; accountId: string;
threadId: string; threadId: string;
@@ -244,25 +263,18 @@ describe("Discord model picker interactions", () => {
expect(selectInteraction.update).toHaveBeenCalledTimes(1); expect(selectInteraction.update).toHaveBeenCalledTimes(1);
expect(dispatchSpy).not.toHaveBeenCalled(); expect(dispatchSpy).not.toHaveBeenCalled();
const button = createDiscordModelPickerFallbackButton(context); const submitInteraction = await runSubmitButton({
const submitInteraction = createInteraction({ userId: "owner" }); context,
const submitData = createModelsViewSubmitData(); data: createModelsViewSubmitData(),
});
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
expect(submitInteraction.update).toHaveBeenCalledTimes(1); expect(submitInteraction.update).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1); expect(dispatchSpy).toHaveBeenCalledTimes(1);
expectDispatchedModelSelection({
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as { dispatchSpy,
ctx?: { model: "openai/gpt-4o",
CommandBody?: string; requireTargetSessionKey: true,
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();
}); });
it("shows timeout status and skips recents write when apply is still processing", async () => { 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") .spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never); .mockResolvedValue({} as never);
const button = createDiscordModelPickerFallbackButton(context); // rs=2 -> first deduped recent (default is anthropic/claude-sonnet-4-5, so openai/gpt-4o remains)
const submitInteraction = createInteraction({ userId: "owner" }); const submitInteraction = await runSubmitButton({
// rs=2 → first deduped recent (default is anthropic/claude-sonnet-4-5, so openai/gpt-4o remains) context,
const submitData: PickerButtonData = { data: {
cmd: "model", cmd: "model",
act: "submit", act: "submit",
view: "recents", view: "recents",
u: "owner", u: "owner",
pg: "1", pg: "1",
rs: "2", rs: "2",
}; },
});
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
expect(submitInteraction.update).toHaveBeenCalledTimes(1); expect(submitInteraction.update).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1); expect(dispatchSpy).toHaveBeenCalledTimes(1);
expectDispatchedModelSelection({ dispatchSpy, model: "openai/gpt-4o" });
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");
}); });
it("verifies model state against the bound thread session", async () => { 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 () => { it("cleans up thread bindings when exec approvals startup fails", async () => {
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js"); const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
const { lifecycleParams, start, stop, threadStop } = createLifecycleHarness({ const { lifecycleParams, start, stop, threadStop } = createLifecycleHarness({
@@ -80,12 +94,7 @@ describe("runDiscordGatewayLifecycle", () => {
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow("startup failed"); await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow("startup failed");
expect(start).toHaveBeenCalledTimes(1); expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 0 });
expect(stop).toHaveBeenCalledTimes(1);
expect(waitForDiscordGatewayStopMock).not.toHaveBeenCalled();
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
expect(threadStop).toHaveBeenCalledTimes(1);
}); });
it("cleans up when gateway wait fails after startup", async () => { it("cleans up when gateway wait fails after startup", async () => {
@@ -97,12 +106,7 @@ describe("runDiscordGatewayLifecycle", () => {
"gateway wait failed", "gateway wait failed",
); );
expect(start).toHaveBeenCalledTimes(1); expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 1 });
expect(stop).toHaveBeenCalledTimes(1);
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(1);
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
expect(threadStop).toHaveBeenCalledTimes(1);
}); });
it("cleans up after successful gateway wait", async () => { it("cleans up after successful gateway wait", async () => {
@@ -111,11 +115,6 @@ describe("runDiscordGatewayLifecycle", () => {
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined(); await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
expect(start).toHaveBeenCalledTimes(1); expectLifecycleCleanup({ start, stop, threadStop, waitCalls: 1 });
expect(stop).toHaveBeenCalledTimes(1);
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(1);
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
expect(threadStop).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -70,6 +70,7 @@ import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js"; import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
import { resolveDiscordRestFetch } from "./rest-fetch.js"; import { resolveDiscordRestFetch } from "./rest-fetch.js";
import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js"; import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js";
import { formatThreadBindingTtlLabel } from "./thread-bindings.messages.js";
export type MonitorDiscordOpts = { export type MonitorDiscordOpts = {
token?: string; token?: string;
@@ -143,17 +144,8 @@ function resolveThreadBindingsEnabled(params: {
} }
function formatThreadBindingSessionTtlLabel(ttlMs: number): string { function formatThreadBindingSessionTtlLabel(ttlMs: number): string {
if (ttlMs <= 0) { const label = formatThreadBindingTtlLabel(ttlMs);
return "off"; return label === "disabled" ? "off" : label;
}
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`;
} }
function dedupeSkillCommandsForDiscord( function dedupeSkillCommandsForDiscord(

View File

@@ -22,6 +22,24 @@ import {
} from "./thread-bindings.state.js"; } from "./thread-bindings.state.js";
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.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[] { export function listThreadBindingsForAccount(accountId?: string): ThreadBindingRecord[] {
const manager = getThreadBindingManager(accountId); const manager = getThreadBindingManager(accountId);
if (!manager) { if (!manager) {
@@ -35,17 +53,7 @@ export function listThreadBindingsBySessionKey(params: {
accountId?: string; accountId?: string;
targetKind?: ThreadBindingTargetKind; targetKind?: ThreadBindingTargetKind;
}): ThreadBindingRecord[] { }): ThreadBindingRecord[] {
ensureBindingsLoaded(); const ids = resolveBindingIdsForTargetSession(params);
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
targetKind: params.targetKind,
});
return ids return ids
.map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey)) .map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey))
.filter((entry): entry is ThreadBindingRecord => Boolean(entry)); .filter((entry): entry is ThreadBindingRecord => Boolean(entry));
@@ -136,17 +144,7 @@ export function unbindThreadBindingsBySessionKey(params: {
sendFarewell?: boolean; sendFarewell?: boolean;
farewellText?: string; farewellText?: string;
}): ThreadBindingRecord[] { }): ThreadBindingRecord[] {
ensureBindingsLoaded(); const ids = resolveBindingIdsForTargetSession(params);
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
targetKind: params.targetKind,
});
if (ids.length === 0) { if (ids.length === 0) {
return []; return [];
} }
@@ -188,16 +186,7 @@ export function setThreadBindingTtlBySessionKey(params: {
accountId?: string; accountId?: string;
ttlMs: number; ttlMs: number;
}): ThreadBindingRecord[] { }): ThreadBindingRecord[] {
ensureBindingsLoaded(); const ids = resolveBindingIdsForTargetSession(params);
const targetSessionKey = params.targetSessionKey.trim();
if (!targetSessionKey) {
return [];
}
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
const ids = resolveBindingIdsForSession({
targetSessionKey,
accountId,
});
if (ids.length === 0) { if (ids.length === 0) {
return []; return [];
} }

View File

@@ -40,6 +40,32 @@ describe("handleControlUiHttpRequest", () => {
expect(params.end).toHaveBeenCalledWith("Not Found"); 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: { async function withBasePathRootFixture<T>(params: {
siblingDir: string; siblingDir: string;
fn: (paths: { root: string; sibling: string }) => Promise<T>; 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 () => { it("allows symlinked assets that resolve inside control-ui root", async () => {
await withControlUiRoot({ await withControlUiRoot({
fn: async (tmp) => { fn: async (tmp) => {
const assetsDir = path.join(tmp, "assets"); const { assetsDir, filePath } = await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
await fs.mkdir(assetsDir, { recursive: true }); await fs.symlink(filePath, path.join(assetsDir, "linked.txt"));
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 { res, end } = makeMockHttpResponse(); const { res, end, handled } = runControlUiRequest({
const handled = handleControlUiHttpRequest( url: "/assets/linked.txt",
{ url: "/assets/linked.txt", method: "GET" } as IncomingMessage, method: "GET",
res, rootPath: tmp,
{ });
root: { kind: "resolved", path: tmp },
},
);
expect(handled).toBe(true); expect(handled).toBe(true);
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
@@ -207,18 +228,13 @@ describe("handleControlUiHttpRequest", () => {
it("serves HEAD for in-root assets without writing a body", async () => { it("serves HEAD for in-root assets without writing a body", async () => {
await withControlUiRoot({ await withControlUiRoot({
fn: async (tmp) => { fn: async (tmp) => {
const assetsDir = path.join(tmp, "assets"); await writeAssetFile(tmp, "actual.txt", "inside-ok\n");
await fs.mkdir(assetsDir, { recursive: true });
await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n");
const { res, end } = makeMockHttpResponse(); const { res, end, handled } = runControlUiRequest({
const handled = handleControlUiHttpRequest( url: "/assets/actual.txt",
{ url: "/assets/actual.txt", method: "HEAD" } as IncomingMessage, method: "HEAD",
res, rootPath: tmp,
{ });
root: { kind: "resolved", path: tmp },
},
);
expect(handled).toBe(true); expect(handled).toBe(true);
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
@@ -237,14 +253,11 @@ describe("handleControlUiHttpRequest", () => {
await fs.rm(path.join(tmp, "index.html")); await fs.rm(path.join(tmp, "index.html"));
await fs.symlink(outsideIndex, path.join(tmp, "index.html")); await fs.symlink(outsideIndex, path.join(tmp, "index.html"));
const { res, end } = makeMockHttpResponse(); const { res, end, handled } = runControlUiRequest({
const handled = handleControlUiHttpRequest( url: "/app/route",
{ url: "/app/route", method: "GET" } as IncomingMessage, method: "GET",
res, rootPath: tmp,
{ });
root: { kind: "resolved", path: tmp },
},
);
expectNotFoundResponse({ handled, res, end }); expectNotFoundResponse({ handled, res, end });
} finally { } finally {
await fs.rm(outsideDir, { recursive: true, force: true }); await fs.rm(outsideDir, { recursive: true, force: true });
@@ -262,16 +275,12 @@ describe("handleControlUiHttpRequest", () => {
const secretPathUrl = secretPath.split(path.sep).join("/"); const secretPathUrl = secretPath.split(path.sep).join("/");
const absolutePathUrl = secretPathUrl.startsWith("/") ? secretPathUrl : `/${secretPathUrl}`; const absolutePathUrl = secretPathUrl.startsWith("/") ? secretPathUrl : `/${secretPathUrl}`;
const { res, end } = makeMockHttpResponse(); const { res, end, handled } = runControlUiRequest({
url: `/openclaw/${absolutePathUrl}`,
const handled = handleControlUiHttpRequest( method: "GET",
{ url: `/openclaw/${absolutePathUrl}`, method: "GET" } as IncomingMessage, rootPath: root,
res, basePath: "/openclaw",
{ });
basePath: "/openclaw",
root: { kind: "resolved", path: root },
},
);
expectNotFoundResponse({ handled, res, end }); expectNotFoundResponse({ handled, res, end });
}, },
}); });
@@ -295,15 +304,12 @@ describe("handleControlUiHttpRequest", () => {
throw error; throw error;
} }
const { res, end } = makeMockHttpResponse(); const { res, end, handled } = runControlUiRequest({
const handled = handleControlUiHttpRequest( url: "/openclaw/assets/leak.txt",
{ url: "/openclaw/assets/leak.txt", method: "GET" } as IncomingMessage, method: "GET",
res, rootPath: root,
{ basePath: "/openclaw",
basePath: "/openclaw", });
root: { kind: "resolved", path: root },
},
);
expectNotFoundResponse({ handled, res, end }); 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: { async function applyNullTransformFromTempConfig(params: {
configDir: string; configDir: string;
transformsDir?: string; transformsDir?: string;
@@ -91,11 +125,7 @@ describe("hooks mapping", () => {
}), }),
], ],
}); });
expect(result?.ok).toBe(true); expectAgentMessage(result, "Subject: Hello");
if (result?.ok && result.action?.kind === "agent") {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe("Subject: Hello");
}
}); });
it("passes model override from mapping", async () => { it("passes model override from mapping", async () => {
@@ -342,11 +372,7 @@ describe("hooks mapping", () => {
}), }),
], ],
}); });
expect(result?.ok).toBe(true); expectAgentMessage(result, "Override subject: Hello");
if (result?.ok && result.action?.kind === "agent") {
expect(result.action.kind).toBe("agent");
expect(result.action.message).toBe("Override subject: Hello");
}
}); });
it("passes agentId from mapping", async () => { it("passes agentId from mapping", async () => {
@@ -461,75 +487,30 @@ describe("hooks mapping", () => {
describe("prototype pollution protection", () => { describe("prototype pollution protection", () => {
it("blocks __proto__ traversal in webhook payload", async () => { it("blocks __proto__ traversal in webhook payload", async () => {
const mappings = resolveHookMappings({ await expectBlockedPrototypeTraversal({
mappings: [ id: "proto-test",
createGmailAgentMapping({ messageTemplate: "value: {{__proto__}}",
id: "proto-test",
messageTemplate: "value: {{__proto__}}",
}),
],
});
const result = await applyHookMappings(mappings, {
payload: { __proto__: { polluted: true } } as Record<string, unknown>, payload: { __proto__: { polluted: true } } as Record<string, unknown>,
headers: {}, expectedMessage: "value: ",
url: baseUrl,
path: "gmail",
}); });
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 () => { it("blocks constructor traversal in webhook payload", async () => {
const mappings = resolveHookMappings({ await expectBlockedPrototypeTraversal({
mappings: [ id: "constructor-test",
createGmailAgentMapping({ messageTemplate: "type: {{constructor.name}}",
id: "constructor-test",
messageTemplate: "type: {{constructor.name}}",
}),
],
});
const result = await applyHookMappings(mappings, {
payload: { constructor: { name: "INJECTED" } } as Record<string, unknown>, payload: { constructor: { name: "INJECTED" } } as Record<string, unknown>,
headers: {}, expectedMessage: "type: ",
url: baseUrl,
path: "gmail",
}); });
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 () => { it("blocks prototype traversal in webhook payload", async () => {
const mappings = resolveHookMappings({ await expectBlockedPrototypeTraversal({
mappings: [ id: "prototype-test",
createGmailAgentMapping({ messageTemplate: "val: {{prototype}}",
id: "prototype-test",
messageTemplate: "val: {{prototype}}",
}),
],
});
const result = await applyHookMappings(mappings, {
payload: { prototype: "leaked" } as Record<string, unknown>, payload: { prototype: "leaked" } as Record<string, unknown>,
headers: {}, expectedMessage: "val: ",
url: baseUrl,
path: "gmail",
}); });
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"); 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", () => { it("emits chat delta for assistant text-only events", () => {
const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText( const { broadcast, nodeSendToSession, nowSpy } = emitRun1AssistantText(
createHarness({ now: 1_000 }), createHarness({ now: 1_000 }),
@@ -152,18 +212,9 @@ describe("agent event handler", () => {
ts: Date.now(), ts: Date.now(),
data: { text: "NO_REPLY" }, data: { text: "NO_REPLY" },
}); });
handler({ emitLifecycleEnd(handler, "run-2");
runId: "run-2",
seq: 2,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
const chatCalls = chatBroadcastCalls(broadcast); const payload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
expect(chatCalls).toHaveLength(1);
const payload = chatCalls[0]?.[1] as { state?: string; message?: unknown };
expect(payload.state).toBe("final");
expect(payload.message).toBeUndefined(); expect(payload.message).toBeUndefined();
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1); expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
nowSpy?.mockRestore(); nowSpy?.mockRestore();
@@ -305,28 +356,10 @@ describe("agent event handler", () => {
resolveSessionKeyForRun: () => "session-fallback", resolveSessionKeyForRun: () => "session-fallback",
}); });
handler({ emitFallbackLifecycle({ handler, runId: "run-fallback" });
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",
},
});
expect(broadcastToConnIds).not.toHaveBeenCalled(); expect(broadcastToConnIds).not.toHaveBeenCalled();
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent"); const payload = expectSingleAgentBroadcastPayload(broadcast);
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as {
sessionKey?: string;
stream?: string;
data?: Record<string, unknown>;
};
expect(payload.stream).toBe("lifecycle"); expect(payload.stream).toBe("lifecycle");
expect(payload.data?.phase).toBe("fallback"); expect(payload.data?.phase).toBe("fallback");
expect(payload.sessionKey).toBe("session-fallback"); expect(payload.sessionKey).toBe("session-fallback");
@@ -345,28 +378,9 @@ describe("agent event handler", () => {
clientRunId: "run-fallback-client", clientRunId: "run-fallback-client",
}); });
handler({ emitFallbackLifecycle({ handler, runId: "run-fallback-internal" });
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",
},
});
const broadcastAgentCalls = broadcast.mock.calls.filter(([event]) => event === "agent"); const payload = expectSingleAgentBroadcastPayload(broadcast);
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as {
runId?: string;
sessionKey?: string;
stream?: string;
data?: Record<string, unknown>;
};
expect(payload.runId).toBe("run-fallback-client"); expect(payload.runId).toBe("run-fallback-client");
expect(payload.stream).toBe("lifecycle"); expect(payload.stream).toBe("lifecycle");
expect(payload.data?.phase).toBe("fallback"); expect(payload.data?.phase).toBe("fallback");
@@ -382,24 +396,13 @@ describe("agent event handler", () => {
resolveSessionKeyForRun: () => undefined, resolveSessionKeyForRun: () => undefined,
}); });
handler({ emitFallbackLifecycle({
handler,
runId: "run-fallback-session-key", runId: "run-fallback-session-key",
seq: 1,
stream: "lifecycle",
ts: Date.now(),
sessionKey: "session-from-event", 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"); const payload = expectSingleAgentBroadcastPayload(broadcast);
expect(broadcastAgentCalls).toHaveLength(1);
const payload = broadcastAgentCalls[0]?.[1] as { sessionKey?: string };
expect(payload.sessionKey).toBe("session-from-event"); expect(payload.sessionKey).toBe("session-from-event");
}); });
@@ -464,18 +467,9 @@ describe("agent event handler", () => {
expect(chatBroadcastCalls(broadcast)).toHaveLength(0); expect(chatBroadcastCalls(broadcast)).toHaveLength(0);
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0); expect(sessionChatCalls(nodeSendToSession)).toHaveLength(0);
handler({ emitLifecycleEnd(handler, "run-heartbeat");
runId: "run-heartbeat",
seq: 2,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
const chatCalls = chatBroadcastCalls(broadcast); const finalPayload = expectSingleFinalChatPayload(broadcast) as { message?: unknown };
expect(chatCalls).toHaveLength(1);
const finalPayload = chatCalls[0]?.[1] as { state?: string; message?: unknown };
expect(finalPayload.state).toBe("final");
expect(finalPayload.message).toBeUndefined(); expect(finalPayload.message).toBeUndefined();
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1); expect(sessionChatCalls(nodeSendToSession)).toHaveLength(1);
}); });
@@ -506,21 +500,11 @@ describe("agent event handler", () => {
}, },
}); });
handler({ emitLifecycleEnd(handler, "run-heartbeat-alert");
runId: "run-heartbeat-alert",
seq: 2,
stream: "lifecycle",
ts: Date.now(),
data: { phase: "end" },
});
const chatCalls = chatBroadcastCalls(broadcast); const payload = expectSingleFinalChatPayload(broadcast) as {
expect(chatCalls).toHaveLength(1);
const payload = chatCalls[0]?.[1] as {
state?: string;
message?: { content?: Array<{ text?: string }> }; message?: { content?: Array<{ text?: string }> };
}; };
expect(payload.state).toBe("final");
expect(payload.message?.content?.[0]?.text).toBe( expect(payload.message?.content?.[0]?.text).toBe(
"Disk usage crossed 95 percent on /data and needs cleanup now.", "Disk usage crossed 95 percent on /data and needs cleanup now.",
); );

View File

@@ -150,6 +150,29 @@ function readLastAgentCommandCall():
| undefined; | 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( async function invokeAgent(
params: AgentParams, params: AgentParams,
options?: { options?: {
@@ -321,20 +344,7 @@ describe("gateway agent handler", () => {
}); });
it("handles bare /new by resetting the same session and sending reset greeting prompt", async () => { it("handles bare /new by resetting the same session and sending reset greeting prompt", async () => {
mocks.sessionsResetHandler.mockImplementation( mockSessionResetSuccess({ reason: "new" });
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" },
});
},
);
primeMainAgentRun({ sessionId: "reset-session-id" }); primeMainAgentRun({ sessionId: "reset-session-id" });
@@ -366,20 +376,7 @@ describe("gateway agent handler", () => {
}, },
}, },
}; };
mocks.sessionsResetHandler.mockImplementation( mockSessionResetSuccess({ reason: "reset" });
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" },
});
},
);
mocks.sessionsResetHandler.mockClear(); mocks.sessionsResetHandler.mockClear();
primeMainAgentRun({ primeMainAgentRun({
sessionId: "reset-session-id", 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", () => { describe("push.test handler", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(loadApnsRegistration).mockClear(); vi.mocked(loadApnsRegistration).mockClear();
@@ -45,20 +55,14 @@ describe("push.test handler", () => {
it("rejects invalid params", async () => { it("rejects invalid params", async () => {
const { respond, invoke } = createInvokeParams({ title: "hello" }); const { respond, invoke } = createInvokeParams({ title: "hello" });
await invoke(); await invoke();
const call = respond.mock.calls[0] as RespondCall | undefined; expectInvalidRequestResponse(respond, "invalid push.test params");
expect(call?.[0]).toBe(false);
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
expect(call?.[2]?.message).toContain("invalid push.test params");
}); });
it("returns invalid request when node has no APNs registration", async () => { it("returns invalid request when node has no APNs registration", async () => {
vi.mocked(loadApnsRegistration).mockResolvedValue(null); vi.mocked(loadApnsRegistration).mockResolvedValue(null);
const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1" }); const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1" });
await invoke(); await invoke();
const call = respond.mock.calls[0] as RespondCall | undefined; expectInvalidRequestResponse(respond, "has no APNs registration");
expect(call?.[0]).toBe(false);
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
expect(call?.[2]?.message).toContain("has no APNs registration");
}); });
it("sends push test when registration and auth are available", async () => { it("sends push test when registration and auth are available", async () => {

View File

@@ -42,6 +42,48 @@ const getInflightMap = (context: GatewayRequestContext) => {
return inflight; 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 = { export const sendHandlers: GatewayRequestHandlers = {
send: async ({ params, respond, context }) => { send: async ({ params, respond, context }) => {
const p = params; const p = params;
@@ -104,38 +146,16 @@ export const sendHandlers: GatewayRequestHandlers = {
); );
return; return;
} }
const channelInput = typeof request.channel === "string" ? request.channel : undefined; const resolvedChannel = await resolveRequestedChannel({
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null; requestChannel: request.channel,
if (channelInput && !normalizedChannel) { unsupportedMessage: (input) => `unsupported channel: ${input}`,
const normalizedInput = channelInput.trim().toLowerCase(); rejectWebchatAsInternalOnly: true,
if (normalizedInput === "webchat") { });
respond( if ("error" in resolvedChannel) {
false, respond(false, undefined, resolvedChannel.error);
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}`),
);
return; return;
} }
const cfg = loadConfig(); const { cfg, channel } = resolvedChannel;
let channel = normalizedChannel;
if (!channel) {
try {
channel = (await resolveMessageChannelSelection({ cfg })).channel;
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
const accountId = const accountId =
typeof request.accountId === "string" && request.accountId.trim().length typeof request.accountId === "string" && request.accountId.trim().length
? request.accountId.trim() ? request.accountId.trim()
@@ -322,26 +342,15 @@ export const sendHandlers: GatewayRequestHandlers = {
return; return;
} }
const to = request.to.trim(); const to = request.to.trim();
const channelInput = typeof request.channel === "string" ? request.channel : undefined; const resolvedChannel = await resolveRequestedChannel({
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null; requestChannel: request.channel,
if (channelInput && !normalizedChannel) { unsupportedMessage: (input) => `unsupported poll channel: ${input}`,
respond( });
false, if ("error" in resolvedChannel) {
undefined, respond(false, undefined, resolvedChannel.error);
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported poll channel: ${channelInput}`),
);
return; return;
} }
const cfg = loadConfig(); const { cfg, channel } = resolvedChannel;
let channel = normalizedChannel;
if (!channel) {
try {
channel = (await resolveMessageChannelSelection({ cfg })).channel;
} catch (err) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
if (typeof request.durationSeconds === "number" && channel !== "telegram") { if (typeof request.durationSeconds === "number" && channel !== "telegram") {
respond( respond(
false, false,

View File

@@ -109,6 +109,23 @@ async function runSessionsUsageLogs(params: Record<string, unknown>) {
return respond; 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", () => { describe("sessions.usage", () => {
beforeEach(() => { beforeEach(() => {
vi.useRealTimers(); vi.useRealTimers();
@@ -116,28 +133,20 @@ describe("sessions.usage", () => {
}); });
it("discovers sessions across configured agents and keeps agentId in key", async () => { it("discovers sessions across configured agents and keeps agentId in key", async () => {
const respond = await runSessionsUsage({ const respond = await runSessionsUsage(BASE_USAGE_RANGE);
startDate: "2026-02-01",
endDate: "2026-02-02",
limit: 10,
});
expect(vi.mocked(discoverAllSessions)).toHaveBeenCalledTimes(2); expect(vi.mocked(discoverAllSessions)).toHaveBeenCalledTimes(2);
expect(vi.mocked(discoverAllSessions).mock.calls[0]?.[0]?.agentId).toBe("main"); expect(vi.mocked(discoverAllSessions).mock.calls[0]?.[0]?.agentId).toBe("main");
expect(vi.mocked(discoverAllSessions).mock.calls[1]?.[0]?.agentId).toBe("opus"); expect(vi.mocked(discoverAllSessions).mock.calls[1]?.[0]?.agentId).toBe("opus");
expect(respond).toHaveBeenCalledTimes(1); const sessions = expectSuccessfulSessionsUsage(respond);
expect(respond.mock.calls[0]?.[0]).toBe(true); expect(sessions).toHaveLength(2);
const result = respond.mock.calls[0]?.[1] as unknown as {
sessions: Array<{ key: string; agentId: string }>;
};
expect(result.sessions).toHaveLength(2);
// Sorted by most recent first (mtime=200 -> opus first). // Sorted by most recent first (mtime=200 -> opus first).
expect(result.sessions[0].key).toBe("agent:opus:s-opus"); expect(sessions[0].key).toBe("agent:opus:s-opus");
expect(result.sessions[0].agentId).toBe("opus"); expect(sessions[0].agentId).toBe("opus");
expect(result.sessions[1].key).toBe("agent:main:s-main"); expect(sessions[1].key).toBe("agent:main:s-main");
expect(result.sessions[1].agentId).toBe("main"); expect(sessions[1].agentId).toBe("main");
}); });
it("resolves store entries by sessionId when queried via discovered agent-prefixed key", async () => { 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> // Query via discovered key: agent:<id>:<sessionId>
const respond = await runSessionsUsage({ const respond = await runSessionsUsage({ ...BASE_USAGE_RANGE, key: "agent:opus:s-opus" });
startDate: "2026-02-01", const sessions = expectSuccessfulSessionsUsage(respond);
endDate: "2026-02-02", expect(sessions).toHaveLength(1);
key: "agent:opus:s-opus", expect(sessions[0]?.key).toBe(storeKey);
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);
expect(vi.mocked(loadSessionCostSummary)).toHaveBeenCalled(); expect(vi.mocked(loadSessionCostSummary)).toHaveBeenCalled();
expect( expect(
vi.mocked(loadSessionCostSummary).mock.calls.some((call) => call[0]?.agentId === "opus"), 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 () => { it("rejects traversal-style keys in specific session usage lookups", async () => {
const respond = await runSessionsUsage({ const respond = await runSessionsUsage({
startDate: "2026-02-01", ...BASE_USAGE_RANGE,
endDate: "2026-02-02",
key: "agent:opus:../../etc/passwd", key: "agent:opus:../../etc/passwd",
limit: 10,
}); });
expect(respond).toHaveBeenCalledTimes(1); expect(respond).toHaveBeenCalledTimes(1);

View File

@@ -21,6 +21,54 @@ let gatewayPort: number;
const gatewayToken = "test-token"; const gatewayToken = "test-token";
let envSnapshot: ReturnType<typeof captureEnv>; 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 () => { beforeAll(async () => {
envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PORT", "OPENCLAW_GATEWAY_TOKEN"]); envSnapshot = captureEnv(["OPENCLAW_GATEWAY_PORT", "OPENCLAW_GATEWAY_TOKEN"]);
gatewayPort = await getFreePort(); gatewayPort = await getFreePort();
@@ -52,52 +100,24 @@ afterAll(async () => {
describe("sessions_send gateway loopback", () => { describe("sessions_send gateway loopback", () => {
it("returns reply when lifecycle ends before agent.wait", async () => { it("returns reply when lifecycle ends before agent.wait", async () => {
const spy = agentCommand as unknown as Mock<(opts: unknown) => Promise<void>>; const spy = agentCommand as unknown as Mock<(opts: unknown) => Promise<void>>;
spy.mockImplementation(async (opts: unknown) => { spy.mockImplementation(async (opts: unknown) =>
const params = opts as { emitLifecycleAssistantReply({
sessionId?: string; opts,
runId?: string; defaultSessionId: "main",
extraSystemPrompt?: string; includeTimestamp: true,
}; resolveText: (extraSystemPrompt) => {
const sessionId = params.sessionId ?? "main"; if (extraSystemPrompt?.includes("Agent-to-agent reply step")) {
const runId = params.runId ?? sessionId; return "REPLY_SKIP";
const sessionFile = resolveSessionTranscriptPath(sessionId); }
await fs.mkdir(path.dirname(sessionFile), { recursive: true }); if (extraSystemPrompt?.includes("Agent-to-agent announce step")) {
return "ANNOUNCE_SKIP";
const startedAt = Date.now(); }
emitAgentEvent({ return "pong";
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(),
}, },
}); }),
}); );
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send"); const tool = getSessionsSendTool();
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-loopback", { const result = await tool.execute("call-loopback", {
sessionKey: "main", sessionKey: "main",
@@ -139,37 +159,13 @@ describe("sessions_send label lookup", () => {
); );
const spy = agentCommand as unknown as Mock<(opts: unknown) => Promise<void>>; const spy = agentCommand as unknown as Mock<(opts: unknown) => Promise<void>>;
spy.mockImplementation(async (opts: unknown) => { spy.mockImplementation(async (opts: unknown) =>
const params = opts as { emitLifecycleAssistantReply({
sessionId?: string; opts,
runId?: string; defaultSessionId: "test-labeled",
extraSystemPrompt?: string; resolveText: () => "labeled response",
}; }),
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() },
});
});
// First, create a session with a label via sessions.patch // First, create a session with a label via sessions.patch
const { callGateway } = await import("./call.js"); const { callGateway } = await import("./call.js");
@@ -179,10 +175,7 @@ describe("sessions_send label lookup", () => {
timeoutMs: 5000, timeoutMs: 5000,
}); });
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send"); const tool = getSessionsSendTool();
if (!tool) {
throw new Error("missing sessions_send tool");
}
// Send using label instead of sessionKey // Send using label instead of sessionKey
const result = await tool.execute("call-by-label", { 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 () => { it("returns error when label not found", { timeout: 60_000 }, async () => {
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send"); const tool = getSessionsSendTool();
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-missing-label", { const result = await tool.execute("call-missing-label", {
label: "nonexistent-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 () => { it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => {
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send"); const tool = getSessionsSendTool();
if (!tool) {
throw new Error("missing sessions_send tool");
}
const result = await tool.execute("call-no-key", { const result = await tool.execute("call-no-key", {
message: "hello", message: "hello",

View File

@@ -9,6 +9,8 @@ import { withServer } from "./test-with-server.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks({ scope: "suite" });
type GatewaySocket = Parameters<Parameters<typeof withServer>[0]>[0];
async function createFreshOperatorDevice(scopes: string[], nonce: string) { async function createFreshOperatorDevice(scopes: string[], nonce: string) {
const { randomUUID } = await import("node:crypto"); const { randomUUID } = await import("node:crypto");
const { tmpdir } = await import("node:os"); 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", () => { describe("gateway talk.config", () => {
it("returns redacted talk config for read scope", async () => { it("returns redacted talk config for read scope", async () => {
const { writeConfigFile } = await import("../config/config.js"); const { writeConfigFile } = await import("../config/config.js");
@@ -58,13 +75,7 @@ describe("gateway talk.config", () => {
}); });
await withServer(async (ws) => { await withServer(async (ws) => {
const nonce = await readConnectChallengeNonce(ws); await connectOperator(ws, ["operator.read"]);
expect(nonce).toBeTruthy();
await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
device: await createFreshOperatorDevice(["operator.read"], String(nonce)),
});
const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>( const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>(
ws, ws,
"talk.config", "talk.config",
@@ -77,21 +88,10 @@ describe("gateway talk.config", () => {
}); });
it("requires operator.talk.secrets for includeSecrets", async () => { it("requires operator.talk.secrets for includeSecrets", async () => {
const { writeConfigFile } = await import("../config/config.js"); await writeTalkConfig({ apiKey: "secret-key-abc" });
await writeConfigFile({
talk: {
apiKey: "secret-key-abc",
},
});
await withServer(async (ws) => { await withServer(async (ws) => {
const nonce = await readConnectChallengeNonce(ws); await connectOperator(ws, ["operator.read"]);
expect(nonce).toBeTruthy();
await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
device: await createFreshOperatorDevice(["operator.read"], String(nonce)),
});
const res = await rpcReq(ws, "talk.config", { includeSecrets: true }); const res = await rpcReq(ws, "talk.config", { includeSecrets: true });
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
expect(res.error?.message).toContain("missing scope: operator.talk.secrets"); 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 () => { it("returns secrets for operator.talk.secrets scope", async () => {
const { writeConfigFile } = await import("../config/config.js"); await writeTalkConfig({ apiKey: "secret-key-abc" });
await writeConfigFile({
talk: {
apiKey: "secret-key-abc",
},
});
await withServer(async (ws) => { await withServer(async (ws) => {
const nonce = await readConnectChallengeNonce(ws); await connectOperator(ws, ["operator.read", "operator.write", "operator.talk.secrets"]);
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),
),
});
const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", { const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", {
includeSecrets: true, includeSecrets: true,
}); });

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js";
import { expectGeneratedTokenPersistedToGatewayAuth } from "../test-utils/auth-token-assertions.js";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}), writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}),
@@ -62,11 +63,12 @@ describe("ensureGatewayStartupAuth", () => {
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/); expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(true); expect(result.persistedGeneratedToken).toBe(true);
expect(result.auth.mode).toBe("token"); expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1); expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
const persisted = mocks.writeConfigFile.mock.calls[0]?.[0]; expectGeneratedTokenPersistedToGatewayAuth({
expect(persisted?.gateway?.auth?.mode).toBe("token"); generatedToken: result.generatedToken,
expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken); authToken: result.auth.token,
persistedConfig: mocks.writeConfigFile.mock.calls[0]?.[0],
});
}); });
it("does not generate when token already exists", async () => { 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 }) { async function createTempMediaFile(params: { fileName: string; content: Buffer | string }) {
const dir = await createTempMediaDir(); const dir = await createTempMediaDir();
const mediaPath = path.join(dir, params.fileName); const mediaPath = path.join(dir, params.fileName);
@@ -135,6 +150,16 @@ async function applyWithDisabledMedia(params: {
return { ctx, result }; 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", () => { describe("applyMediaUnderstanding", () => {
const mockedResolveApiKey = vi.mocked(resolveApiKeyForProvider); const mockedResolveApiKey = vi.mocked(resolveApiKeyForProvider);
const mockedFetchRemoteMedia = vi.mocked(fetchRemoteMedia); const mockedFetchRemoteMedia = vi.mocked(fetchRemoteMedia);
@@ -627,9 +652,7 @@ describe("applyMediaUnderstanding", () => {
mediaType: "audio/mpeg", mediaType: "audio/mpeg",
}); });
expect(result.appliedFile).toBe(false); expectFileNotApplied({ ctx, result, body: "<media:audio>" });
expect(ctx.Body).toBe("<media:audio>");
expect(ctx.Body).not.toContain("<file");
}); });
it("does not reclassify PDF attachments as text/plain", async () => { it("does not reclassify PDF attachments as text/plain", async () => {
@@ -639,18 +662,7 @@ describe("applyMediaUnderstanding", () => {
content: pseudoPdf, content: pseudoPdf,
}); });
const cfg: OpenClawConfig = { const cfg = createMediaDisabledConfigWithAllowedMimes(["text/plain"]);
...createMediaDisabledConfig(),
gateway: {
http: {
endpoints: {
responses: {
files: { allowedMimes: ["text/plain"] },
},
},
},
},
};
const { ctx, result } = await applyWithDisabledMedia({ const { ctx, result } = await applyWithDisabledMedia({
body: "<media:file>", body: "<media:file>",
@@ -659,9 +671,7 @@ describe("applyMediaUnderstanding", () => {
cfg, cfg,
}); });
expect(result.appliedFile).toBe(false); expectFileNotApplied({ ctx, result, body: "<media:file>" });
expect(ctx.Body).toBe("<media:file>");
expect(ctx.Body).not.toContain("<file");
}); });
it("respects configured allowedMimes for text-like attachments", async () => { it("respects configured allowedMimes for text-like attachments", async () => {
@@ -671,27 +681,14 @@ describe("applyMediaUnderstanding", () => {
content: tsvText, content: tsvText,
}); });
const cfg: OpenClawConfig = { const cfg = createMediaDisabledConfigWithAllowedMimes(["text/plain"]);
...createMediaDisabledConfig(),
gateway: {
http: {
endpoints: {
responses: {
files: { allowedMimes: ["text/plain"] },
},
},
},
},
};
const { ctx, result } = await applyWithDisabledMedia({ const { ctx, result } = await applyWithDisabledMedia({
body: "<media:file>", body: "<media:file>",
mediaPath: tsvPath, mediaPath: tsvPath,
cfg, cfg,
}); });
expect(result.appliedFile).toBe(false); expectFileNotApplied({ ctx, result, body: "<media:file>" });
expect(ctx.Body).toBe("<media:file>");
expect(ctx.Body).not.toContain("<file");
}); });
it("escapes XML special characters in filenames to prevent injection", async () => { it("escapes XML special characters in filenames to prevent injection", async () => {
@@ -824,9 +821,7 @@ describe("applyMediaUnderstanding", () => {
mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", mediaType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}); });
expect(result.appliedFile).toBe(false); expectFileNotApplied({ ctx, result, body: "<media:file>" });
expect(ctx.Body).toBe("<media:file>");
expect(ctx.Body).not.toContain("<file");
}); });
it("keeps vendor +json attachments eligible for text extraction", async () => { 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; } 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", () => { describe("runCapability auto audio entries", () => {
it("uses provider keys to auto-enable audio transcription", async () => { it("uses provider keys to auto-enable audio transcription", async () => {
await withAudioFixture("openclaw-auto-audio", async ({ ctx, media, cache }) => { let seenModel: string | undefined;
let seenModel: string | undefined; const result = await runAutoAudioCase({
const providerRegistry = createOpenAiAudioProvider(async (req) => { transcribeAudio: async (req) => {
seenModel = req.model; seenModel = req.model;
return { text: "ok", model: req.model ?? "unknown" }; 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 () => { it("skips auto audio when disabled", async () => {
await withAudioFixture("openclaw-auto-audio", async ({ ctx, media, cache }) => { const result = await runAutoAudioCase({
const providerRegistry = createOpenAiAudioProvider(async () => ({ transcribeAudio: async () => ({
text: "ok", text: "ok",
model: "whisper-1", model: "whisper-1",
})); }),
const cfg = createOpenAiAudioCfg({ cfgExtra: {
tools: { tools: {
media: { media: {
audio: { 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 () => { it("prefers explicitly configured audio model entries", async () => {
await withAudioFixture("openclaw-auto-audio", async ({ ctx, media, cache }) => { let seenModel: string | undefined;
let seenModel: string | undefined; const result = await runAutoAudioCase({
const providerRegistry = createOpenAiAudioProvider(async (req) => { transcribeAudio: async (req) => {
seenModel = req.model; seenModel = req.model;
return { text: "ok", model: req.model ?? "unknown" }; return { text: "ok", model: req.model ?? "unknown" };
}); },
const cfg = createOpenAiAudioCfg({ cfgExtra: {
tools: { tools: {
media: { media: {
audio: { 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] }] }), 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(() => { afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.unstubAllGlobals(); 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", () => { describe("embedding provider remote overrides", () => {
it("uses remote baseUrl/apiKey and merges headers", async () => { it("uses remote baseUrl/apiKey and merges headers", async () => {
const fetchMock = createFetchMock(); const fetchMock = createFetchMock();
@@ -143,11 +169,7 @@ describe("embedding provider remote overrides", () => {
}); });
it("builds Gemini embeddings requests with api key header", async () => { it("builds Gemini embeddings requests with api key header", async () => {
const fetchMock = vi.fn(async (_input?: unknown, _init?: unknown) => ({ const fetchMock = createGeminiFetchMock();
ok: true,
status: 200,
json: async () => ({ embedding: { values: [1, 2, 3] } }),
}));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
mockResolvedProviderKey("provider-key"); mockResolvedProviderKey("provider-key");
@@ -194,24 +216,12 @@ describe("embedding provider auto selection", () => {
throw new Error(`No API key found for provider "${provider}".`); throw new Error(`No API key found for provider "${provider}".`);
}); });
const result = await createEmbeddingProvider({ const result = await createAutoProvider();
config: {} as never, expectAutoSelectedProvider(result, "openai");
provider: "auto",
model: "",
fallback: "none",
});
expect(result.requestedProvider).toBe("auto");
const provider = requireProvider(result);
expect(provider.id).toBe("openai");
}); });
it("uses gemini when openai is missing", async () => { it("uses gemini when openai is missing", async () => {
const fetchMock = vi.fn(async (_input?: unknown, _init?: unknown) => ({ const fetchMock = createGeminiFetchMock();
ok: true,
status: 200,
json: async () => ({ embedding: { values: [1, 2, 3] } }),
}));
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => {
if (provider === "openai") { if (provider === "openai") {
@@ -223,16 +233,8 @@ describe("embedding provider auto selection", () => {
throw new Error(`Unexpected provider ${provider}`); throw new Error(`Unexpected provider ${provider}`);
}); });
const result = await createEmbeddingProvider({ const result = await createAutoProvider();
config: {} as never, const provider = expectAutoSelectedProvider(result, "gemini");
provider: "auto",
model: "",
fallback: "none",
});
expect(result.requestedProvider).toBe("auto");
const provider = requireProvider(result);
expect(provider.id).toBe("gemini");
await provider.embedQuery("hello"); await provider.embedQuery("hello");
const [url] = fetchMock.mock.calls[0] ?? []; const [url] = fetchMock.mock.calls[0] ?? [];
expect(url).toBe( expect(url).toBe(

View File

@@ -331,6 +331,31 @@ function expectSingleDispatchedSlashBody(expectedBody: string) {
expect(call.ctx?.Body).toBe(expectedBody); 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( async function runArgMenuAction(
handler: (args: unknown) => Promise<void>, handler: (args: unknown) => Promise<void>,
params: { 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 () => { it("falls back to buttons when static_select value limit would be exceeded", async () => {
const { respond } = await runCommandHandler(reportLongHandler); const firstElement = await getFirstActionElementFromCommand(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];
expect(firstElement?.type).toBe("button"); expect(firstElement?.type).toBe("button");
expect(firstElement?.confirm).toBeTruthy(); expect(firstElement?.confirm).toBeTruthy();
}); });
it("shows an overflow menu when choices fit compact range", async () => { it("shows an overflow menu when choices fit compact range", async () => {
const { respond } = await runCommandHandler(reportCompactHandler); const element = await getFirstActionElementFromCommand(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];
expect(element?.type).toBe("overflow"); expect(element?.type).toBe("overflow");
expect(element?.action_id).toBe("openclaw_cmdarg"); expect(element?.action_id).toBe("openclaw_cmdarg");
expect(element?.confirm).toBeTruthy(); expect(element?.confirm).toBeTruthy();
}); });
it("escapes mrkdwn characters in confirm dialog text", async () => { it("escapes mrkdwn characters in confirm dialog text", async () => {
const { respond } = await runCommandHandler(unsafeConfirmHandler); const element = (await getFirstActionElementFromCommand(unsafeConfirmHandler)) as
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
| { confirm?: { text?: { text?: string } } } | { confirm?: { text?: { text?: string } } }
| undefined; | undefined;
expect(element?.confirm?.text?.text).toContain( 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 () => { 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); expect(respond).toHaveBeenCalledTimes(1);
const payload = respond.mock.calls[0]?.[0] as {
blocks?: Array<{ type: string; block_id?: string }>;
};
const actions = findFirstActionsBlock(payload); const actions = findFirstActionsBlock(payload);
const element = actions?.elements?.[0]; const element = actions?.elements?.[0];
expect(element?.type).toBe("external_select"); expect(element?.type).toBe("external_select");
expect(element?.action_id).toBe("openclaw_cmdarg"); expect(element?.action_id).toBe("openclaw_cmdarg");
const blockId = payload.blocks?.find((block) => block.type === "actions")?.block_id;
expect(blockId).toContain("openclaw_cmdarg_ext:"); expect(blockId).toContain("openclaw_cmdarg_ext:");
const token = (blockId ?? "").slice("openclaw_cmdarg_ext:".length); const token = (blockId ?? "").slice("openclaw_cmdarg_ext:".length);
expect(token).toMatch(/^[A-Za-z0-9_-]{24}$/); expect(token).toMatch(/^[A-Za-z0-9_-]{24}$/);
}); });
it("serves filtered options for external_select menus", async () => { it("serves filtered options for external_select menus", async () => {
const { respond } = await runCommandHandler(reportExternalHandler); const { blockId } = await runCommandAndResolveActionsBlock(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;
expect(blockId).toContain("openclaw_cmdarg_ext:"); expect(blockId).toContain("openclaw_cmdarg_ext:");
const ackOptions = vi.fn().mockResolvedValue(undefined); 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 () => { it("rejects external_select option requests without user identity", async () => {
const { respond } = await runCommandHandler(reportExternalHandler); const { blockId } = await runCommandAndResolveActionsBlock(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;
expect(blockId).toContain("openclaw_cmdarg_ext:"); expect(blockId).toContain("openclaw_cmdarg_ext:");
const ackOptions = vi.fn().mockResolvedValue(undefined); 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 () => { it("generates a token when the prompt returns undefined", async () => {
mocks.randomToken.mockReturnValue("generated-token"); mocks.randomToken.mockReturnValue("generated-token");
@@ -57,17 +71,7 @@ describe("configureGatewayForOnboarding", () => {
baseConfig: {}, baseConfig: {},
nextConfig: {}, nextConfig: {},
localPort: 18789, localPort: 18789,
quickstartGateway: { quickstartGateway: createQuickstartGateway("token"),
hasExisting: false,
port: 18789,
bind: "loopback",
authMode: "token",
tailscaleMode: "off",
token: undefined,
password: undefined,
customBindHost: undefined,
tailscaleResetOnExit: false,
},
prompter, prompter,
runtime, runtime,
}); });
@@ -97,17 +101,7 @@ describe("configureGatewayForOnboarding", () => {
baseConfig: {}, baseConfig: {},
nextConfig: {}, nextConfig: {},
localPort: 18789, localPort: 18789,
quickstartGateway: { quickstartGateway: createQuickstartGateway("password"),
hasExisting: false,
port: 18789,
bind: "loopback",
authMode: "password",
tailscaleMode: "off",
token: undefined,
password: undefined,
customBindHost: undefined,
tailscaleResetOnExit: false,
},
prompter, prompter,
runtime, runtime,
}); });