refactor: deduplicate shared helpers and test setup

This commit is contained in:
Peter Steinberger
2026-02-23 20:40:38 +00:00
parent 1f5e6444ee
commit 75423a00d6
33 changed files with 999 additions and 1112 deletions

View File

@@ -176,6 +176,28 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
let next = cfg;
const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
const validateServerUrlInput = (value: unknown): string | undefined => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
try {
const normalized = normalizeBlueBubblesServerUrl(trimmed);
new URL(normalized);
return undefined;
} catch {
return "Invalid URL format";
}
};
const promptServerUrl = async (initialValue?: string): Promise<string> => {
const entered = await prompter.text({
message: "BlueBubbles server URL",
placeholder: "http://192.168.1.100:1234",
initialValue,
validate: validateServerUrlInput,
});
return String(entered).trim();
};
// Prompt for server URL
let serverUrl = resolvedAccount.config.serverUrl?.trim();
@@ -188,49 +210,14 @@ export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
].join("\n"),
"BlueBubbles server URL",
);
const entered = await prompter.text({
message: "BlueBubbles server URL",
placeholder: "http://192.168.1.100:1234",
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
try {
const normalized = normalizeBlueBubblesServerUrl(trimmed);
new URL(normalized);
return undefined;
} catch {
return "Invalid URL format";
}
},
});
serverUrl = String(entered).trim();
serverUrl = await promptServerUrl();
} else {
const keepUrl = await prompter.confirm({
message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
initialValue: true,
});
if (!keepUrl) {
const entered = await prompter.text({
message: "BlueBubbles server URL",
placeholder: "http://192.168.1.100:1234",
initialValue: serverUrl,
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) {
return "Required";
}
try {
const normalized = normalizeBlueBubblesServerUrl(trimmed);
new URL(normalized);
return undefined;
} catch {
return "Invalid URL format";
}
},
});
serverUrl = String(entered).trim();
serverUrl = await promptServerUrl(serverUrl);
}
}

View File

@@ -110,6 +110,10 @@ import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
import { emitDiagnosticEvent } from "openclaw/plugin-sdk";
import { createDiagnosticsOtelService } from "./service.js";
const OTEL_TEST_STATE_DIR = "/tmp/openclaw-diagnostics-otel-test";
const OTEL_TEST_ENDPOINT = "http://otel-collector:4318";
const OTEL_TEST_PROTOCOL = "http/protobuf";
function createLogger() {
return {
info: vi.fn(),
@@ -119,7 +123,15 @@ function createLogger() {
};
}
function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext {
type OtelContextFlags = {
traces?: boolean;
metrics?: boolean;
logs?: boolean;
};
function createOtelContext(
endpoint: string,
{ traces = false, metrics = false, logs = false }: OtelContextFlags = {},
): OpenClawPluginServiceContext {
return {
config: {
diagnostics: {
@@ -127,17 +139,46 @@ function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext
otel: {
enabled: true,
endpoint,
protocol: "http/protobuf",
traces: true,
metrics: false,
logs: false,
protocol: OTEL_TEST_PROTOCOL,
traces,
metrics,
logs,
},
},
},
logger: createLogger(),
stateDir: "/tmp/openclaw-diagnostics-otel-test",
stateDir: OTEL_TEST_STATE_DIR,
};
}
function createTraceOnlyContext(endpoint: string): OpenClawPluginServiceContext {
return createOtelContext(endpoint, { traces: true });
}
type RegisteredLogTransport = (logObj: Record<string, unknown>) => void;
function setupRegisteredTransports() {
const registeredTransports: RegisteredLogTransport[] = [];
const stopTransport = vi.fn();
registerLogTransportMock.mockImplementation((transport) => {
registeredTransports.push(transport);
return stopTransport;
});
return { registeredTransports, stopTransport };
}
async function emitAndCaptureLog(logObj: Record<string, unknown>) {
const { registeredTransports } = setupRegisteredTransports();
const service = createDiagnosticsOtelService();
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { logs: true });
await service.start(ctx);
expect(registeredTransports).toHaveLength(1);
registeredTransports[0]?.(logObj);
expect(logEmit).toHaveBeenCalled();
const emitCall = logEmit.mock.calls[0]?.[0];
await service.stop?.(ctx);
return emitCall;
}
describe("diagnostics-otel service", () => {
beforeEach(() => {
telemetryState.counters.clear();
@@ -154,31 +195,10 @@ describe("diagnostics-otel service", () => {
});
test("records message-flow metrics and spans", async () => {
const registeredTransports: Array<(logObj: Record<string, unknown>) => void> = [];
const stopTransport = vi.fn();
registerLogTransportMock.mockImplementation((transport) => {
registeredTransports.push(transport);
return stopTransport;
});
const { registeredTransports } = setupRegisteredTransports();
const service = createDiagnosticsOtelService();
const ctx: OpenClawPluginServiceContext = {
config: {
diagnostics: {
enabled: true,
otel: {
enabled: true,
endpoint: "http://otel-collector:4318",
protocol: "http/protobuf",
traces: true,
metrics: true,
logs: true,
},
},
},
logger: createLogger(),
stateDir: "/tmp/openclaw-diagnostics-otel-test",
};
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { traces: true, metrics: true, logs: true });
await service.start(ctx);
emitDiagnosticEvent({
@@ -295,105 +315,33 @@ describe("diagnostics-otel service", () => {
});
test("redacts sensitive data from log messages before export", async () => {
const registeredTransports: Array<(logObj: Record<string, unknown>) => void> = [];
const stopTransport = vi.fn();
registerLogTransportMock.mockImplementation((transport) => {
registeredTransports.push(transport);
return stopTransport;
});
const service = createDiagnosticsOtelService();
const ctx: OpenClawPluginServiceContext = {
config: {
diagnostics: {
enabled: true,
otel: {
enabled: true,
endpoint: "http://otel-collector:4318",
protocol: "http/protobuf",
logs: true,
},
},
},
logger: createLogger(),
stateDir: "/tmp/openclaw-diagnostics-otel-test",
};
await service.start(ctx);
expect(registeredTransports).toHaveLength(1);
registeredTransports[0]?.({
const emitCall = await emitAndCaptureLog({
0: "Using API key sk-1234567890abcdef1234567890abcdef",
_meta: { logLevelName: "INFO", date: new Date() },
});
expect(logEmit).toHaveBeenCalled();
const emitCall = logEmit.mock.calls[0]?.[0];
expect(emitCall?.body).not.toContain("sk-1234567890abcdef1234567890abcdef");
expect(emitCall?.body).toContain("sk-123");
expect(emitCall?.body).toContain("…");
await service.stop?.(ctx);
});
test("redacts sensitive data from log attributes before export", async () => {
const registeredTransports: Array<(logObj: Record<string, unknown>) => void> = [];
const stopTransport = vi.fn();
registerLogTransportMock.mockImplementation((transport) => {
registeredTransports.push(transport);
return stopTransport;
});
const service = createDiagnosticsOtelService();
const ctx: OpenClawPluginServiceContext = {
config: {
diagnostics: {
enabled: true,
otel: {
enabled: true,
endpoint: "http://otel-collector:4318",
protocol: "http/protobuf",
logs: true,
},
},
},
logger: createLogger(),
stateDir: "/tmp/openclaw-diagnostics-otel-test",
};
await service.start(ctx);
expect(registeredTransports).toHaveLength(1);
registeredTransports[0]?.({
const emitCall = await emitAndCaptureLog({
0: '{"token":"ghp_abcdefghijklmnopqrstuvwxyz123456"}',
1: "auth configured",
_meta: { logLevelName: "DEBUG", date: new Date() },
});
expect(logEmit).toHaveBeenCalled();
const emitCall = logEmit.mock.calls[0]?.[0];
const tokenAttr = emitCall?.attributes?.["openclaw.token"];
expect(tokenAttr).not.toBe("ghp_abcdefghijklmnopqrstuvwxyz123456");
if (typeof tokenAttr === "string") {
expect(tokenAttr).toContain("…");
}
await service.stop?.(ctx);
});
test("redacts sensitive reason in session.state metric attributes", async () => {
const service = createDiagnosticsOtelService();
const ctx: OpenClawPluginServiceContext = {
config: {
diagnostics: {
enabled: true,
otel: {
enabled: true,
endpoint: "http://otel-collector:4318",
protocol: "http/protobuf",
metrics: true,
traces: false,
logs: false,
},
},
},
logger: createLogger(),
stateDir: "/tmp/openclaw-diagnostics-otel-test",
};
const ctx = createOtelContext(OTEL_TEST_ENDPOINT, { metrics: true });
await service.start(ctx);
emitDiagnosticEvent({

View File

@@ -506,6 +506,18 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
}
};
const addSessionIdentityAttrs = (
spanAttrs: Record<string, string | number>,
evt: { sessionKey?: string; sessionId?: string },
) => {
if (evt.sessionKey) {
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
}
if (evt.sessionId) {
spanAttrs["openclaw.sessionId"] = evt.sessionId;
}
};
const recordMessageProcessed = (
evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>,
) => {
@@ -521,12 +533,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
return;
}
const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.sessionKey) {
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
}
if (evt.sessionId) {
spanAttrs["openclaw.sessionId"] = evt.sessionId;
}
addSessionIdentityAttrs(spanAttrs, evt);
if (evt.chatId !== undefined) {
spanAttrs["openclaw.chatId"] = String(evt.chatId);
}
@@ -584,12 +591,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
return;
}
const spanAttrs: Record<string, string | number> = { ...attrs };
if (evt.sessionKey) {
spanAttrs["openclaw.sessionKey"] = evt.sessionKey;
}
if (evt.sessionId) {
spanAttrs["openclaw.sessionId"] = evt.sessionId;
}
addSessionIdentityAttrs(spanAttrs, evt);
spanAttrs["openclaw.queueDepth"] = evt.queueDepth ?? 0;
spanAttrs["openclaw.ageMs"] = evt.ageMs;
const span = tracer.startSpan("openclaw.session.stuck", { attributes: spanAttrs });

View File

@@ -7,7 +7,7 @@ import { createFeishuClient } from "./client.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
import { resolveFeishuSendTarget } from "./send-target.js";
export type DownloadImageResult = {
buffer: Buffer;
@@ -268,18 +268,11 @@ export async function sendImageFeishu(params: {
accountId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, imageKey, replyToMessageId, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const receiveId = normalizeFeishuTarget(to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${to}`);
}
const receiveIdType = resolveReceiveIdType(receiveId);
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
cfg,
to,
accountId,
});
const content = JSON.stringify({ image_key: imageKey });
if (replyToMessageId) {
@@ -320,18 +313,11 @@ export async function sendFileFeishu(params: {
}): Promise<SendMediaResult> {
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
const msgType = params.msgType ?? "file";
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const receiveId = normalizeFeishuTarget(to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${to}`);
}
const receiveIdType = resolveReceiveIdType(receiveId);
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
cfg,
to,
accountId,
});
const content = JSON.stringify({ file_key: fileKey });
if (replyToMessageId) {

View File

@@ -0,0 +1,25 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
export function resolveFeishuSendTarget(params: {
cfg: ClawdbotConfig;
to: string;
accountId?: string;
}) {
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const receiveId = normalizeFeishuTarget(params.to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${params.to}`);
}
return {
client,
receiveId,
receiveIdType: resolveReceiveIdType(receiveId),
};
}

View File

@@ -5,8 +5,8 @@ import type { MentionTarget } from "./mention.js";
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js";
import { resolveFeishuSendTarget } from "./send-target.js";
import type { FeishuSendResult } from "./types.js";
export type FeishuMessageInfo = {
messageId: string;
@@ -128,18 +128,7 @@ export async function sendMessageFeishu(
params: SendFeishuMessageParams,
): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const receiveId = normalizeFeishuTarget(to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${to}`);
}
const receiveIdType = resolveReceiveIdType(receiveId);
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "feishu",
@@ -188,18 +177,7 @@ export type SendFeishuCardParams = {
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
const { cfg, to, card, replyToMessageId, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const receiveId = normalizeFeishuTarget(to);
if (!receiveId) {
throw new Error(`Invalid Feishu target: ${to}`);
}
const receiveIdType = resolveReceiveIdType(receiveId);
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
const content = JSON.stringify(card);
if (replyToMessageId) {

View File

@@ -132,6 +132,26 @@ export class FeishuStreamingSession {
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
}
private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise<void> {
if (!this.state) {
return;
}
const apiBase = resolveApiBase(this.creds.domain);
this.state.sequence += 1;
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: text,
sequence: this.state.sequence,
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
}),
}).catch((error) => onError?.(error));
}
async update(text: string): Promise<void> {
if (!this.state || this.closed) {
return;
@@ -150,20 +170,7 @@ export class FeishuStreamingSession {
return;
}
this.state.currentText = text;
this.state.sequence += 1;
const apiBase = resolveApiBase(this.creds.domain);
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: text,
sequence: this.state.sequence,
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
}),
}).catch((e) => this.log?.(`Update failed: ${String(e)}`));
await this.updateCardContent(text, (e) => this.log?.(`Update failed: ${String(e)}`));
});
await this.queue;
}
@@ -181,19 +188,7 @@ export class FeishuStreamingSession {
// Only send final update if content differs from what's already displayed
if (text && text !== this.state.currentText) {
this.state.sequence += 1;
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: text,
sequence: this.state.sequence,
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
}),
}).catch(() => {});
await this.updateCardContent(text);
this.state.currentText = text;
}

View File

@@ -13,7 +13,7 @@ export const TlonAuthorizationSchema = z.object({
channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
});
export const TlonAccountSchema = z.object({
const tlonCommonConfigFields = {
name: z.string().optional(),
enabled: z.boolean().optional(),
ship: ShipSchema.optional(),
@@ -25,20 +25,14 @@ export const TlonAccountSchema = z.object({
autoDiscoverChannels: z.boolean().optional(),
showModelSignature: z.boolean().optional(),
responsePrefix: z.string().optional(),
} satisfies z.ZodRawShape;
export const TlonAccountSchema = z.object({
...tlonCommonConfigFields,
});
export const TlonConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
ship: ShipSchema.optional(),
url: z.string().optional(),
code: z.string().optional(),
allowPrivateNetwork: z.boolean().optional(),
groupChannels: z.array(ChannelNestSchema).optional(),
dmAllowlist: z.array(ShipSchema).optional(),
autoDiscoverChannels: z.boolean().optional(),
showModelSignature: z.boolean().optional(),
responsePrefix: z.string().optional(),
...tlonCommonConfigFields,
authorization: TlonAuthorizationSchema.optional(),
defaultAuthorizedShips: z.array(ShipSchema).optional(),
accounts: z.record(z.string(), TlonAccountSchema).optional(),

View File

@@ -81,6 +81,27 @@ function summarizeSeries(values: number[]): {
};
}
function resolveCallMode(mode?: string): "notify" | "conversation" | undefined {
return mode === "notify" || mode === "conversation" ? mode : undefined;
}
async function initiateCallAndPrintId(params: {
runtime: VoiceCallRuntime;
to: string;
message?: string;
mode?: string;
}) {
const result = await params.runtime.manager.initiateCall(params.to, undefined, {
message: params.message,
mode: resolveCallMode(params.mode),
});
if (!result.success) {
throw new Error(result.error || "initiate failed");
}
// eslint-disable-next-line no-console
console.log(JSON.stringify({ callId: result.callId }, null, 2));
}
export function registerVoiceCallCli(params: {
program: Command;
config: VoiceCallConfig;
@@ -112,16 +133,12 @@ export function registerVoiceCallCli(params: {
if (!to) {
throw new Error("Missing --to and no toNumber configured");
}
const result = await rt.manager.initiateCall(to, undefined, {
await initiateCallAndPrintId({
runtime: rt,
to,
message: options.message,
mode:
options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined,
mode: options.mode,
});
if (!result.success) {
throw new Error(result.error || "initiate failed");
}
// eslint-disable-next-line no-console
console.log(JSON.stringify({ callId: result.callId }, null, 2));
});
root
@@ -136,16 +153,12 @@ export function registerVoiceCallCli(params: {
)
.action(async (options: { to: string; message?: string; mode?: string }) => {
const rt = await ensureRuntime();
const result = await rt.manager.initiateCall(options.to, undefined, {
await initiateCallAndPrintId({
runtime: rt,
to: options.to,
message: options.message,
mode:
options.mode === "notify" || options.mode === "conversation" ? options.mode : undefined,
mode: options.mode,
});
if (!result.success) {
throw new Error(result.error || "initiate failed");
}
// eslint-disable-next-line no-console
console.log(JSON.stringify({ callId: result.callId }, null, 2));
});
root

View File

@@ -63,6 +63,15 @@ type ConnectedCallLookup =
provider: NonNullable<ConnectedCallContext["provider"]>;
};
type ConnectedCallResolution =
| { ok: false; error: string }
| {
ok: true;
call: CallRecord;
providerCallId: string;
provider: NonNullable<ConnectedCallContext["provider"]>;
};
function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallLookup {
const call = ctx.activeCalls.get(callId);
if (!call) {
@@ -77,6 +86,22 @@ function lookupConnectedCall(ctx: ConnectedCallContext, callId: CallId): Connect
return { kind: "ok", call, providerCallId: call.providerCallId, provider: ctx.provider };
}
function requireConnectedCall(ctx: ConnectedCallContext, callId: CallId): ConnectedCallResolution {
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { ok: false, error: lookup.error };
}
if (lookup.kind === "ended") {
return { ok: false, error: "Call has ended" };
}
return {
ok: true,
call: lookup.call,
providerCallId: lookup.providerCallId,
provider: lookup.provider,
};
}
export async function initiateCall(
ctx: InitiateContext,
to: string,
@@ -175,14 +200,11 @@ export async function speak(
callId: CallId,
text: string,
): Promise<{ success: boolean; error?: string }> {
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) {
return { success: false, error: connected.error };
}
if (lookup.kind === "ended") {
return { success: false, error: "Call has ended" };
}
const { call, providerCallId, provider } = lookup;
const { call, providerCallId, provider } = connected;
try {
transitionState(call, "speaking");
@@ -257,14 +279,11 @@ export async function continueCall(
callId: CallId,
prompt: string,
): Promise<{ success: boolean; transcript?: string; error?: string }> {
const lookup = lookupConnectedCall(ctx, callId);
if (lookup.kind === "error") {
return { success: false, error: lookup.error };
const connected = requireConnectedCall(ctx, callId);
if (!connected.ok) {
return { success: false, error: connected.error };
}
if (lookup.kind === "ended") {
return { success: false, error: "Call has ended" };
}
const { call, providerCallId, provider } = lookup;
const { call, providerCallId, provider } = connected;
if (ctx.activeTurnCalls.has(callId) || ctx.transcriptWaiters.has(callId)) {
return { success: false, error: "Already waiting for transcript" };

View File

@@ -331,31 +331,40 @@ export class PlivoProvider implements VoiceCallProvider {
});
}
async playTts(input: PlayTtsInput): Promise<void> {
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId;
private resolveCallContext(params: {
providerCallId: string;
callId: string;
operation: string;
}): {
callUuid: string;
webhookBase: string;
} {
const callUuid = this.requestUuidToCallUuid.get(params.providerCallId) ?? params.providerCallId;
const webhookBase =
this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId);
this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(params.callId);
if (!webhookBase) {
throw new Error("Missing webhook URL for this call (provider state missing)");
}
if (!callUuid) {
throw new Error("Missing Plivo CallUUID for playTts");
throw new Error(`Missing Plivo CallUUID for ${params.operation}`);
}
return { callUuid, webhookBase };
}
const transferUrl = new URL(webhookBase);
private async transferCallLeg(params: {
callUuid: string;
webhookBase: string;
callId: string;
flow: "xml-speak" | "xml-listen";
}): Promise<void> {
const transferUrl = new URL(params.webhookBase);
transferUrl.searchParams.set("provider", "plivo");
transferUrl.searchParams.set("flow", "xml-speak");
transferUrl.searchParams.set("callId", input.callId);
this.pendingSpeakByCallId.set(input.callId, {
text: input.text,
locale: input.locale,
});
transferUrl.searchParams.set("flow", params.flow);
transferUrl.searchParams.set("callId", params.callId);
await this.apiRequest({
method: "POST",
endpoint: `/Call/${callUuid}/`,
endpoint: `/Call/${params.callUuid}/`,
body: {
legs: "aleg",
aleg_url: transferUrl.toString(),
@@ -364,35 +373,42 @@ export class PlivoProvider implements VoiceCallProvider {
});
}
async playTts(input: PlayTtsInput): Promise<void> {
const { callUuid, webhookBase } = this.resolveCallContext({
providerCallId: input.providerCallId,
callId: input.callId,
operation: "playTts",
});
this.pendingSpeakByCallId.set(input.callId, {
text: input.text,
locale: input.locale,
});
await this.transferCallLeg({
callUuid,
webhookBase,
callId: input.callId,
flow: "xml-speak",
});
}
async startListening(input: StartListeningInput): Promise<void> {
const callUuid = this.requestUuidToCallUuid.get(input.providerCallId) ?? input.providerCallId;
const webhookBase =
this.callUuidToWebhookUrl.get(callUuid) || this.callIdToWebhookUrl.get(input.callId);
if (!webhookBase) {
throw new Error("Missing webhook URL for this call (provider state missing)");
}
if (!callUuid) {
throw new Error("Missing Plivo CallUUID for startListening");
}
const transferUrl = new URL(webhookBase);
transferUrl.searchParams.set("provider", "plivo");
transferUrl.searchParams.set("flow", "xml-listen");
transferUrl.searchParams.set("callId", input.callId);
const { callUuid, webhookBase } = this.resolveCallContext({
providerCallId: input.providerCallId,
callId: input.callId,
operation: "startListening",
});
this.pendingListenByCallId.set(input.callId, {
language: input.language,
});
await this.apiRequest({
method: "POST",
endpoint: `/Call/${callUuid}/`,
body: {
legs: "aleg",
aleg_url: transferUrl.toString(),
aleg_method: "POST",
},
await this.transferCallLeg({
callUuid,
webhookBase,
callId: input.callId,
flow: "xml-listen",
});
}

View File

@@ -37,6 +37,67 @@ function stubSessionManager(): ExtensionContext["sessionManager"] {
return stub;
}
function createAnthropicModelFixture(overrides: Partial<Model<Api>> = {}): Model<Api> {
return {
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "anthropic",
api: "anthropic" as const,
baseUrl: "https://api.anthropic.com",
contextWindow: 200000,
maxTokens: 4096,
reasoning: false,
input: ["text"] as const,
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
...overrides,
};
}
type CompactionHandler = (event: unknown, ctx: unknown) => Promise<unknown>;
const createCompactionHandler = () => {
let compactionHandler: CompactionHandler | undefined;
const mockApi = {
on: vi.fn((event: string, handler: CompactionHandler) => {
if (event === "session_before_compact") {
compactionHandler = handler;
}
}),
} as unknown as ExtensionAPI;
compactionSafeguardExtension(mockApi);
expect(compactionHandler).toBeDefined();
return compactionHandler as CompactionHandler;
};
const createCompactionEvent = (params: { messageText: string; tokensBefore: number }) => ({
preparation: {
messagesToSummarize: [
{ role: "user", content: params.messageText, timestamp: Date.now() },
] as AgentMessage[],
turnPrefixMessages: [] as AgentMessage[],
firstKeptEntryId: "entry-1",
tokensBefore: params.tokensBefore,
fileOps: {
read: [],
edited: [],
written: [],
},
},
customInstructions: "",
signal: new AbortController().signal,
});
const createCompactionContext = (params: {
sessionManager: ExtensionContext["sessionManager"];
getApiKeyMock: ReturnType<typeof vi.fn>;
}) =>
({
model: undefined,
sessionManager: params.sessionManager,
modelRegistry: {
getApiKey: params.getApiKeyMock,
},
}) as unknown as Partial<ExtensionContext>;
describe("compaction-safeguard tool failures", () => {
it("formats tool failures with meta and summary", () => {
const messages: AgentMessage[] = [
@@ -272,18 +333,7 @@ describe("compaction-safeguard runtime registry", () => {
it("stores and retrieves model from runtime (fallback for compact.ts workflow)", () => {
const sm = {};
const model: Model<Api> = {
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "anthropic",
api: "anthropic" as const,
baseUrl: "https://api.anthropic.com",
contextWindow: 200000,
maxTokens: 4096,
reasoning: false,
input: ["text"] as const,
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
};
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sm, { model });
const retrieved = getCompactionSafeguardRuntime(sm);
expect(retrieved?.model).toEqual(model);
@@ -298,18 +348,7 @@ describe("compaction-safeguard runtime registry", () => {
it("stores and retrieves combined runtime values", () => {
const sm = {};
const model: Model<Api> = {
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "anthropic",
api: "anthropic" as const,
baseUrl: "https://api.anthropic.com",
contextWindow: 200000,
maxTokens: 4096,
reasoning: false,
input: ["text"] as const,
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
};
const model = createAnthropicModelFixture();
setCompactionSafeguardRuntime(sm, {
maxHistoryShare: 0.6,
contextWindowTokens: 200000,
@@ -329,72 +368,25 @@ describe("compaction-safeguard extension model fallback", () => {
// This test verifies the root-cause fix: when extensionRunner.initialize() is not called
// (as happens in compact.ts), ctx.model is undefined but runtime.model is available.
const sessionManager = stubSessionManager();
const model: Model<Api> = {
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "anthropic",
api: "anthropic" as const,
baseUrl: "https://api.anthropic.com",
contextWindow: 200000,
maxTokens: 4096,
reasoning: false,
input: ["text"] as const,
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
};
const model = createAnthropicModelFixture();
// Set up runtime with model (mimics buildEmbeddedExtensionPaths behavior)
setCompactionSafeguardRuntime(sessionManager, { model });
type CompactionHandler = (event: unknown, ctx: unknown) => Promise<unknown>;
let compactionHandler: CompactionHandler | undefined;
// Create a minimal mock ExtensionAPI that captures the handler
const mockApi = {
on: vi.fn((event: string, handler: CompactionHandler) => {
if (event === "session_before_compact") {
compactionHandler = handler;
}
}),
} as unknown as ExtensionAPI;
// Register the extension
compactionSafeguardExtension(mockApi);
// Verify handler was registered
expect(compactionHandler).toBeDefined();
// Now trigger the handler with mock data
const mockEvent = {
preparation: {
messagesToSummarize: [
{ role: "user", content: "test message", timestamp: Date.now() },
] as AgentMessage[],
turnPrefixMessages: [] as AgentMessage[],
firstKeptEntryId: "entry-1",
tokensBefore: 1000,
fileOps: {
read: [],
edited: [],
written: [],
},
},
customInstructions: "",
signal: new AbortController().signal,
};
const compactionHandler = createCompactionHandler();
const mockEvent = createCompactionEvent({
messageText: "test message",
tokensBefore: 1000,
});
const getApiKeyMock = vi.fn().mockResolvedValue(null);
// oxlint-disable-next-line typescript/no-explicit-any
const mockContext = {
model: undefined, // ctx.model is undefined (simulates compact.ts workflow)
const mockContext = createCompactionContext({
sessionManager,
modelRegistry: {
getApiKey: getApiKeyMock, // No API key should now cancel compaction
},
} as unknown as Partial<ExtensionContext>;
getApiKeyMock,
});
// Call the handler and wait for result
// oxlint-disable-next-line typescript/no-non-null-assertion
const result = (await compactionHandler!(mockEvent, mockContext)) as {
const result = (await compactionHandler(mockEvent, mockContext)) as {
cancel?: boolean;
};
@@ -414,51 +406,19 @@ describe("compaction-safeguard extension model fallback", () => {
// Do NOT set runtime.model (both ctx.model and runtime.model will be undefined)
type CompactionHandler = (event: unknown, ctx: unknown) => Promise<unknown>;
let compactionHandler: CompactionHandler | undefined;
const mockApi = {
on: vi.fn((event: string, handler: CompactionHandler) => {
if (event === "session_before_compact") {
compactionHandler = handler;
}
}),
} as unknown as ExtensionAPI;
compactionSafeguardExtension(mockApi);
expect(compactionHandler).toBeDefined();
const mockEvent = {
preparation: {
messagesToSummarize: [
{ role: "user", content: "test", timestamp: Date.now() },
] as AgentMessage[],
turnPrefixMessages: [] as AgentMessage[],
firstKeptEntryId: "entry-1",
tokensBefore: 500,
fileOps: {
read: [],
edited: [],
written: [],
},
},
customInstructions: "",
signal: new AbortController().signal,
};
const compactionHandler = createCompactionHandler();
const mockEvent = createCompactionEvent({
messageText: "test",
tokensBefore: 500,
});
const getApiKeyMock = vi.fn().mockResolvedValue(null);
// oxlint-disable-next-line typescript/no-explicit-any
const mockContext = {
model: undefined, // ctx.model is undefined
const mockContext = createCompactionContext({
sessionManager,
modelRegistry: {
getApiKey: getApiKeyMock, // Should NOT be called (early return)
},
} as unknown as Partial<ExtensionContext>;
getApiKeyMock,
});
// oxlint-disable-next-line typescript/no-non-null-assertion
const result = (await compactionHandler!(mockEvent, mockContext)) as {
const result = (await compactionHandler(mockEvent, mockContext)) as {
cancel?: boolean;
};

View File

@@ -71,6 +71,45 @@ async function expectThinkStatusForReasoningModel(params: {
});
}
function mockReasoningCapableCatalog() {
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: true,
},
]);
}
async function runReasoningDefaultCase(params: {
home: string;
expectedThinkLevel: "low" | "off";
expectedReasoningLevel: "off" | "on";
thinkingDefault?: "off" | "low" | "medium" | "high";
}) {
mockEmbeddedTextResult("done");
mockReasoningCapableCatalog();
await getReplyFromConfig(
{
Body: "hello",
From: "+1004",
To: "+2000",
},
{},
makeWhatsAppDirectiveConfig(params.home, {
model: { primary: "anthropic/claude-opus-4-5" },
...(params.thinkingDefault ? { thinkingDefault: params.thinkingDefault } : {}),
}),
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.thinkLevel).toBe(params.expectedThinkLevel);
expect(call?.reasoningLevel).toBe(params.expectedReasoningLevel);
}
describe("directive behavior", () => {
installDirectiveBehaviorE2EHooks();
@@ -246,61 +285,21 @@ describe("directive behavior", () => {
});
it("defaults thinking to low for reasoning-capable models without auto-enabling reasoning", async () => {
await withTempHome(async (home) => {
mockEmbeddedTextResult("done");
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: true,
},
]);
await getReplyFromConfig(
{
Body: "hello",
From: "+1004",
To: "+2000",
},
{},
makeWhatsAppDirectiveConfig(home, { model: { primary: "anthropic/claude-opus-4-5" } }),
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.thinkLevel).toBe("low");
expect(call?.reasoningLevel).toBe("off");
await runReasoningDefaultCase({
home,
expectedThinkLevel: "low",
expectedReasoningLevel: "off",
});
});
});
it("keeps auto-reasoning enabled when thinking is explicitly off", async () => {
await withTempHome(async (home) => {
mockEmbeddedTextResult("done");
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
{
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: true,
},
]);
await getReplyFromConfig(
{
Body: "hello",
From: "+1004",
To: "+2000",
},
{},
makeWhatsAppDirectiveConfig(home, {
model: { primary: "anthropic/claude-opus-4-5" },
thinkingDefault: "off",
}),
);
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0];
expect(call?.thinkLevel).toBe("off");
expect(call?.reasoningLevel).toBe("on");
await runReasoningDefaultCase({
home,
expectedThinkLevel: "off",
expectedReasoningLevel: "on",
thinkingDefault: "off",
});
});
});
it("passes elevated defaults when sender is approved", async () => {

View File

@@ -39,6 +39,24 @@ function baseConfig(): OpenClawConfig {
} as unknown as OpenClawConfig;
}
function resolveModelSelectionForCommand(params: {
command: string;
allowedModelKeys: Set<string>;
allowedModelCatalog: Array<{ provider: string; id: string }>;
}) {
return resolveModelSelectionFromDirective({
directives: parseInlineDirectives(params.command),
cfg: { commands: { text: true } } as unknown as OpenClawConfig,
agentDir: "/tmp/agent",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys: params.allowedModelKeys,
allowedModelCatalog: params.allowedModelCatalog,
provider: "anthropic",
});
}
describe("/model chat UX", () => {
it("shows summary for /model with no args", async () => {
const directives = parseInlineDirectives("/model");
@@ -114,19 +132,10 @@ describe("/model chat UX", () => {
});
it("rejects numeric /model selections with a guided error", () => {
const directives = parseInlineDirectives("/model 99");
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;
const resolved = resolveModelSelectionFromDirective({
directives,
cfg,
agentDir: "/tmp/agent",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
const resolved = resolveModelSelectionForCommand({
command: "/model 99",
allowedModelKeys: new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]),
allowedModelCatalog: [],
provider: "anthropic",
});
expect(resolved.modelSelection).toBeUndefined();
@@ -135,19 +144,10 @@ describe("/model chat UX", () => {
});
it("treats explicit default /model selection as resettable default", () => {
const directives = parseInlineDirectives("/model anthropic/claude-opus-4-5");
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;
const resolved = resolveModelSelectionFromDirective({
directives,
cfg,
agentDir: "/tmp/agent",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
const resolved = resolveModelSelectionForCommand({
command: "/model anthropic/claude-opus-4-5",
allowedModelKeys: new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]),
allowedModelCatalog: [],
provider: "anthropic",
});
expect(resolved.errorText).toBeUndefined();
@@ -159,19 +159,10 @@ describe("/model chat UX", () => {
});
it("keeps openrouter provider/model split for exact selections", () => {
const directives = parseInlineDirectives("/model openrouter/anthropic/claude-opus-4-5");
const cfg = { commands: { text: true } } as unknown as OpenClawConfig;
const resolved = resolveModelSelectionFromDirective({
directives,
cfg,
agentDir: "/tmp/agent",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
const resolved = resolveModelSelectionForCommand({
command: "/model openrouter/anthropic/claude-opus-4-5",
allowedModelKeys: new Set(["openrouter/anthropic/claude-opus-4-5"]),
allowedModelCatalog: [],
provider: "anthropic",
});
expect(resolved.errorText).toBeUndefined();

View File

@@ -14,6 +14,74 @@ vi.mock("./commands.js", () => ({
// Import after mocks.
const { handleInlineActions } = await import("./get-reply-inline-actions.js");
type HandleInlineActionsInput = Parameters<typeof handleInlineActions>[0];
const createTypingController = (): TypingController => ({
onReplyStart: async () => {},
startTypingLoop: async () => {},
startTypingOnText: async () => {},
refreshTypingTtl: () => {},
isActive: () => false,
markRunComplete: () => {},
markDispatchIdle: () => {},
cleanup: vi.fn(),
});
const createHandleInlineActionsInput = (params: {
ctx: ReturnType<typeof buildTestCtx>;
typing: TypingController;
cleanedBody: string;
command?: Partial<HandleInlineActionsInput["command"]>;
overrides?: Partial<Omit<HandleInlineActionsInput, "ctx" | "sessionCtx" | "typing" | "command">>;
}): HandleInlineActionsInput => {
const baseCommand: HandleInlineActionsInput["command"] = {
surface: "whatsapp",
channel: "whatsapp",
channelId: "whatsapp",
ownerList: [],
senderIsOwner: false,
isAuthorizedSender: false,
senderId: undefined,
abortKey: "whatsapp:+999",
rawBodyNormalized: params.cleanedBody,
commandBodyNormalized: params.cleanedBody,
from: "whatsapp:+999",
to: "whatsapp:+999",
};
return {
ctx: params.ctx,
sessionCtx: params.ctx as unknown as TemplateContext,
cfg: {},
agentId: "main",
sessionKey: "s:main",
workspaceDir: "/tmp",
isGroup: false,
typing: params.typing,
allowTextCommands: false,
inlineStatusRequested: false,
command: {
...baseCommand,
...params.command,
},
directives: clearInlineDirectives(params.cleanedBody),
cleanedBody: params.cleanedBody,
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: () => "always",
resolvedThinkLevel: undefined,
resolvedVerboseLevel: undefined,
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
resolveDefaultThinkingLevel: async () => "off",
provider: "openai",
model: "gpt-4o-mini",
contextTokens: 0,
abortedLastRun: false,
sessionScope: "per-sender",
...params.overrides,
};
};
describe("handleInlineActions", () => {
beforeEach(() => {
@@ -21,65 +89,21 @@ describe("handleInlineActions", () => {
});
it("skips whatsapp replies when config is empty and From !== To", async () => {
const typing: TypingController = {
onReplyStart: async () => {},
startTypingLoop: async () => {},
startTypingOnText: async () => {},
refreshTypingTtl: () => {},
isActive: () => false,
markRunComplete: () => {},
markDispatchIdle: () => {},
cleanup: vi.fn(),
};
const typing = createTypingController();
const ctx = buildTestCtx({
From: "whatsapp:+999",
To: "whatsapp:+123",
Body: "hi",
});
const result = await handleInlineActions({
ctx,
sessionCtx: ctx as unknown as TemplateContext,
cfg: {},
agentId: "main",
sessionKey: "s:main",
workspaceDir: "/tmp",
isGroup: false,
typing,
allowTextCommands: false,
inlineStatusRequested: false,
command: {
surface: "whatsapp",
channel: "whatsapp",
channelId: "whatsapp",
ownerList: [],
senderIsOwner: false,
isAuthorizedSender: false,
senderId: undefined,
abortKey: "whatsapp:+999",
rawBodyNormalized: "hi",
commandBodyNormalized: "hi",
from: "whatsapp:+999",
to: "whatsapp:+123",
},
directives: clearInlineDirectives("hi"),
cleanedBody: "hi",
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: () => "always",
resolvedThinkLevel: undefined,
resolvedVerboseLevel: undefined,
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
resolveDefaultThinkingLevel: async () => "off",
provider: "openai",
model: "gpt-4o-mini",
contextTokens: 0,
abortedLastRun: false,
sessionScope: "per-sender",
});
const result = await handleInlineActions(
createHandleInlineActionsInput({
ctx,
typing,
cleanedBody: "hi",
command: { to: "whatsapp:+123" },
}),
);
expect(result).toEqual({ kind: "reply", reply: undefined });
expect(typing.cleanup).toHaveBeenCalled();
@@ -87,16 +111,7 @@ describe("handleInlineActions", () => {
});
it("forwards agentDir into handleCommands", async () => {
const typing: TypingController = {
onReplyStart: async () => {},
startTypingLoop: async () => {},
startTypingOnText: async () => {},
refreshTypingTtl: () => {},
isActive: () => false,
markRunComplete: () => {},
markDispatchIdle: () => {},
cleanup: vi.fn(),
};
const typing = createTypingController();
handleCommandsMock.mockResolvedValue({ shouldContinue: false, reply: { text: "done" } });
@@ -106,49 +121,22 @@ describe("handleInlineActions", () => {
});
const agentDir = "/tmp/inline-agent";
const result = await handleInlineActions({
ctx,
sessionCtx: ctx as unknown as TemplateContext,
cfg: { commands: { text: true } },
agentId: "main",
agentDir,
sessionKey: "s:main",
workspaceDir: "/tmp",
isGroup: false,
typing,
allowTextCommands: false,
inlineStatusRequested: false,
command: {
surface: "whatsapp",
channel: "whatsapp",
channelId: "whatsapp",
ownerList: [],
senderIsOwner: false,
isAuthorizedSender: true,
senderId: "sender-1",
abortKey: "sender-1",
rawBodyNormalized: "/status",
commandBodyNormalized: "/status",
from: "whatsapp:+999",
to: "whatsapp:+999",
},
directives: clearInlineDirectives("/status"),
cleanedBody: "/status",
elevatedEnabled: false,
elevatedAllowed: false,
elevatedFailures: [],
defaultActivation: () => "always",
resolvedThinkLevel: undefined,
resolvedVerboseLevel: undefined,
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
resolveDefaultThinkingLevel: async () => "off",
provider: "openai",
model: "gpt-4o-mini",
contextTokens: 0,
abortedLastRun: false,
sessionScope: "per-sender",
});
const result = await handleInlineActions(
createHandleInlineActionsInput({
ctx,
typing,
cleanedBody: "/status",
command: {
isAuthorizedSender: true,
senderId: "sender-1",
abortKey: "sender-1",
},
overrides: {
cfg: { commands: { text: true } },
agentDir,
},
}),
);
expect(result).toEqual({ kind: "reply", reply: { text: "done" } });
expect(handleCommandsMock).toHaveBeenCalledTimes(1);

View File

@@ -8,7 +8,7 @@ import {
unsetConfigValueAtPath,
} from "./config-paths.js";
import { readConfigFileSnapshot, validateConfigObject } from "./config.js";
import { withTempHome } from "./test-helpers.js";
import { buildWebSearchProviderConfig, withTempHome } from "./test-helpers.js";
import { OpenClawSchema } from "./zod-schema.js";
describe("$schema key in config (#14998)", () => {
@@ -51,41 +51,17 @@ describe("ui.seamColor", () => {
});
describe("web search provider config", () => {
it("accepts perplexity provider and config", () => {
const res = validateConfigObject({
tools: {
web: {
search: {
enabled: true,
provider: "perplexity",
perplexity: {
apiKey: "test-key",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("accepts kimi provider and config", () => {
const res = validateConfigObject({
tools: {
web: {
search: {
provider: "kimi",
kimi: {
apiKey: "test-key",
baseUrl: "https://api.moonshot.ai/v1",
model: "moonshot-v1-128k",
},
},
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "kimi",
providerConfig: {
apiKey: "test-key",
baseUrl: "https://api.moonshot.ai/v1",
model: "moonshot-v1-128k",
},
},
});
}),
);
expect(res.ok).toBe(true);
});

View File

@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { validateConfigObject } from "./config.js";
import { buildWebSearchProviderConfig } from "./test-helpers.js";
vi.mock("../runtime.js", () => ({
defaultRuntime: { log: vi.fn(), error: vi.fn() },
@@ -10,54 +11,42 @@ const { resolveSearchProvider } = __testing;
describe("web search provider config", () => {
it("accepts perplexity provider and config", () => {
const res = validateConfigObject({
tools: {
web: {
search: {
enabled: true,
provider: "perplexity",
perplexity: {
apiKey: "test-key",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
},
},
const res = validateConfigObject(
buildWebSearchProviderConfig({
enabled: true,
provider: "perplexity",
providerConfig: {
apiKey: "test-key",
baseUrl: "https://api.perplexity.ai",
model: "perplexity/sonar-pro",
},
},
});
}),
);
expect(res.ok).toBe(true);
});
it("accepts gemini provider and config", () => {
const res = validateConfigObject({
tools: {
web: {
search: {
enabled: true,
provider: "gemini",
gemini: {
apiKey: "test-key",
model: "gemini-2.5-flash",
},
},
const res = validateConfigObject(
buildWebSearchProviderConfig({
enabled: true,
provider: "gemini",
providerConfig: {
apiKey: "test-key",
model: "gemini-2.5-flash",
},
},
});
}),
);
expect(res.ok).toBe(true);
});
it("accepts gemini provider with no extra config", () => {
const res = validateConfigObject({
tools: {
web: {
search: {
provider: "gemini",
},
},
},
});
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "gemini",
}),
);
expect(res.ok).toBe(true);
});
@@ -69,6 +58,8 @@ describe("web search provider auto-detection", () => {
beforeEach(() => {
delete process.env.BRAVE_API_KEY;
delete process.env.GEMINI_API_KEY;
delete process.env.KIMI_API_KEY;
delete process.env.MOONSHOT_API_KEY;
delete process.env.PERPLEXITY_API_KEY;
delete process.env.OPENROUTER_API_KEY;
delete process.env.XAI_API_KEY;
@@ -95,6 +86,11 @@ describe("web search provider auto-detection", () => {
expect(resolveSearchProvider({})).toBe("gemini");
});
it("auto-detects kimi when only KIMI_API_KEY is set", () => {
process.env.KIMI_API_KEY = "test-kimi-key";
expect(resolveSearchProvider({})).toBe("kimi");
});
it("auto-detects perplexity when only PERPLEXITY_API_KEY is set", () => {
process.env.PERPLEXITY_API_KEY = "test-perplexity-key";
expect(resolveSearchProvider({})).toBe("perplexity");

View File

@@ -79,6 +79,35 @@ describe("config io write", () => {
return { last, lines, configPath };
}
const createGatewayCommandsInput = (): Record<string, unknown> => ({
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
const expectInputOwnerDisplayUnchanged = (input: Record<string, unknown>) => {
expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
};
const readPersistedCommands = async (configPath: string) => {
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
commands?: Record<string, unknown>;
};
return persisted.commands;
};
async function runUnsetNoopCase(params: { home: string; unsetPaths: string[][] }) {
const { configPath, io } = await writeConfigAndCreateIo({
home: params.home,
initialConfig: createGatewayCommandsInput(),
});
const input = createGatewayCommandsInput();
await io.writeConfigFile(input, { unsetPaths: params.unsetPaths });
expectInputOwnerDisplayUnchanged(input);
expect((await readPersistedCommands(configPath))?.ownerDisplay).toBe("hash");
}
it("persists caller changes onto resolved config without leaking runtime defaults", async () => {
await withTempHome("openclaw-config-io-", async (home) => {
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
@@ -144,11 +173,8 @@ describe("config io write", () => {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
});
expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
commands?: Record<string, unknown>;
};
expect(persisted.commands ?? {}).not.toHaveProperty("ownerDisplay");
expectInputOwnerDisplayUnchanged(input);
expect((await readPersistedCommands(configPath)) ?? {}).not.toHaveProperty("ownerDisplay");
});
});
@@ -165,11 +191,8 @@ describe("config io write", () => {
const input = structuredClone(snapshot.config) as Record<string, unknown>;
await io.writeConfigFile(input, { unsetPaths: [["commands", "ownerDisplay"]] });
expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
commands?: Record<string, unknown>;
};
expect(persisted.commands ?? {}).not.toHaveProperty("ownerDisplay");
expectInputOwnerDisplayUnchanged(input);
expect((await readPersistedCommands(configPath)) ?? {}).not.toHaveProperty("ownerDisplay");
});
});
@@ -196,55 +219,23 @@ describe("config io write", () => {
it("treats missing unset paths as no-op without mutating caller config", async () => {
await withTempHome("openclaw-config-io-", async (home) => {
const { configPath, io } = await writeConfigAndCreateIo({
await runUnsetNoopCase({
home,
initialConfig: {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
},
unsetPaths: [["commands", "missingKey"]],
});
const input: Record<string, unknown> = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
};
await io.writeConfigFile(input, { unsetPaths: [["commands", "missingKey"]] });
expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
commands?: Record<string, unknown>;
};
expect(persisted.commands?.ownerDisplay).toBe("hash");
});
});
it("ignores blocked prototype-key unset path segments", async () => {
await withTempHome("openclaw-config-io-", async (home) => {
const { configPath, io } = await writeConfigAndCreateIo({
await runUnsetNoopCase({
home,
initialConfig: {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
},
});
const input: Record<string, unknown> = {
gateway: { mode: "local" },
commands: { ownerDisplay: "hash" },
};
await io.writeConfigFile(input, {
unsetPaths: [
["commands", "__proto__"],
["commands", "constructor"],
["commands", "prototype"],
],
});
expect((input.commands as Record<string, unknown>).ownerDisplay).toBe("hash");
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
commands?: Record<string, unknown>;
};
expect(persisted.commands?.ownerDisplay).toBe("hash");
});
});

View File

@@ -51,3 +51,24 @@ export async function withEnvOverride<T>(
}
}
}
export function buildWebSearchProviderConfig(params: {
provider: string;
enabled?: boolean;
providerConfig?: Record<string, unknown>;
}): Record<string, unknown> {
const search: Record<string, unknown> = { provider: params.provider };
if (params.enabled !== undefined) {
search.enabled = params.enabled;
}
if (params.providerConfig) {
search[params.provider] = params.providerConfig;
}
return {
tools: {
web: {
search,
},
},
};
}

View File

@@ -278,6 +278,32 @@ function parseAllRunLogEntries(raw: string, opts?: { jobId?: string }): CronRunL
return parsed;
}
function filterRunLogEntries(
entries: CronRunLogEntry[],
opts: {
statuses: CronRunStatus[] | null;
deliveryStatuses: CronDeliveryStatus[] | null;
query: string;
queryTextForEntry: (entry: CronRunLogEntry) => string;
},
): CronRunLogEntry[] {
return entries.filter((entry) => {
if (opts.statuses && (!entry.status || !opts.statuses.includes(entry.status))) {
return false;
}
if (opts.deliveryStatuses) {
const deliveryStatus = entry.deliveryStatus ?? "not-requested";
if (!opts.deliveryStatuses.includes(deliveryStatus)) {
return false;
}
}
if (!opts.query) {
return true;
}
return opts.queryTextForEntry(entry).toLowerCase().includes(opts.query);
});
}
export async function readCronRunLogEntriesPage(
filePath: string,
opts?: ReadCronRunLogPageOptions,
@@ -289,21 +315,11 @@ export async function readCronRunLogEntriesPage(
const query = opts?.query?.trim().toLowerCase() ?? "";
const sortDir: CronRunLogSortDir = opts?.sortDir === "asc" ? "asc" : "desc";
const all = parseAllRunLogEntries(raw, { jobId: opts?.jobId });
const filtered = all.filter((entry) => {
if (statuses && (!entry.status || !statuses.includes(entry.status))) {
return false;
}
if (deliveryStatuses) {
const deliveryStatus = entry.deliveryStatus ?? "not-requested";
if (!deliveryStatuses.includes(deliveryStatus)) {
return false;
}
}
if (!query) {
return true;
}
const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId].join(" ").toLowerCase();
return haystack.includes(query);
const filtered = filterRunLogEntries(all, {
statuses,
deliveryStatuses,
query,
queryTextForEntry: (entry) => [entry.summary ?? "", entry.error ?? "", entry.jobId].join(" "),
});
const sorted =
sortDir === "asc"
@@ -353,24 +369,14 @@ export async function readCronRunLogEntriesPageAll(
}),
);
const all = chunks.flat();
const filtered = all.filter((entry) => {
if (statuses && (!entry.status || !statuses.includes(entry.status))) {
return false;
}
if (deliveryStatuses) {
const deliveryStatus = entry.deliveryStatus ?? "not-requested";
if (!deliveryStatuses.includes(deliveryStatus)) {
return false;
}
}
if (!query) {
return true;
}
const jobName = opts.jobNameById?.[entry.jobId] ?? "";
const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId, jobName]
.join(" ")
.toLowerCase();
return haystack.includes(query);
const filtered = filterRunLogEntries(all, {
statuses,
deliveryStatuses,
query,
queryTextForEntry: (entry) => {
const jobName = opts.jobNameById?.[entry.jobId] ?? "";
return [entry.summary ?? "", entry.error ?? "", entry.jobId, jobName].join(" ");
},
});
const sorted =
sortDir === "asc"

View File

@@ -76,6 +76,19 @@ describe("runBootOnce", () => {
});
};
const expectMainSessionRestored = (params: {
storePath: string;
sessionKey: string;
expectedSessionId?: string;
}) => {
const restored = loadSessionStore(params.storePath, { skipCache: true });
if (params.expectedSessionId === undefined) {
expect(restored[params.sessionKey]).toBeUndefined();
return;
}
expect(restored[params.sessionKey]?.sessionId).toBe(params.expectedSessionId);
};
it("skips when BOOT.md is missing", async () => {
await withBootWorkspace({}, async (workspaceDir) => {
await expect(runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir })).resolves.toEqual({
@@ -226,8 +239,7 @@ describe("runBootOnce", () => {
status: "ran",
});
const restored = loadSessionStore(storePath, { skipCache: true });
expect(restored[sessionKey]?.sessionId).toBe(existingSessionId);
expectMainSessionRestored({ storePath, sessionKey, expectedSessionId: existingSessionId });
});
});
@@ -242,8 +254,7 @@ describe("runBootOnce", () => {
status: "ran",
});
const restored = loadSessionStore(storePath, { skipCache: true });
expect(restored[sessionKey]).toBeUndefined();
expectMainSessionRestored({ storePath, sessionKey });
});
});
});

View File

@@ -132,6 +132,22 @@ function createClientWithIdentity(
});
}
function expectSecurityConnectError(
onConnectError: ReturnType<typeof vi.fn>,
params?: { expectTailscaleHint?: boolean },
) {
expect(onConnectError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("SECURITY ERROR"),
}),
);
const error = onConnectError.mock.calls[0]?.[0] as Error;
expect(error.message).toContain("openclaw doctor --fix");
if (params?.expectTailscaleHint) {
expect(error.message).toContain("Tailscale Serve/Funnel");
}
}
describe("GatewayClient security checks", () => {
beforeEach(() => {
wsInstances.length = 0;
@@ -146,14 +162,7 @@ describe("GatewayClient security checks", () => {
client.start();
expect(onConnectError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("SECURITY ERROR"),
}),
);
const error = onConnectError.mock.calls[0]?.[0] as Error;
expect(error.message).toContain("openclaw doctor --fix");
expect(error.message).toContain("Tailscale Serve/Funnel");
expectSecurityConnectError(onConnectError, { expectTailscaleHint: true });
expect(wsInstances.length).toBe(0); // No WebSocket created
client.stop();
});
@@ -168,13 +177,7 @@ describe("GatewayClient security checks", () => {
// Should not throw
expect(() => client.start()).not.toThrow();
expect(onConnectError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("SECURITY ERROR"),
}),
);
const error = onConnectError.mock.calls[0]?.[0] as Error;
expect(error.message).toContain("openclaw doctor --fix");
expectSecurityConnectError(onConnectError);
expect(wsInstances.length).toBe(0); // No WebSocket created
client.stop();
});

View File

@@ -40,6 +40,18 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
};
}
function expectAllowOnceForwardingResult(
result: ReturnType<typeof sanitizeSystemRunParamsForForwarding>,
) {
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("unreachable");
}
const params = result.params as Record<string, unknown>;
expect(params.approved).toBe(true);
expect(params.approvalDecision).toBe("allow-once");
}
test("rejects cmd.exe /c trailing-arg mismatch against rawCommand", () => {
const result = sanitizeSystemRunParamsForForwarding({
rawParams: {
@@ -74,13 +86,7 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
execApprovalManager: manager(makeRecord("echo SAFE&&whoami")),
nowMs: now,
});
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("unreachable");
}
const params = result.params as Record<string, unknown>;
expect(params.approved).toBe(true);
expect(params.approvalDecision).toBe("allow-once");
expectAllowOnceForwardingResult(result);
});
test("rejects env-assignment shell wrapper when approval command omits env prelude", () => {
@@ -117,12 +123,6 @@ describe("sanitizeSystemRunParamsForForwarding", () => {
),
nowMs: now,
});
expect(result.ok).toBe(true);
if (!result.ok) {
throw new Error("unreachable");
}
const params = result.params as Record<string, unknown>;
expect(params.approved).toBe(true);
expect(params.approvalDecision).toBe("allow-once");
expectAllowOnceForwardingResult(result);
});
});

View File

@@ -19,6 +19,31 @@ vi.mock("../../memory/index.js", () => ({
import { doctorHandlers } from "./doctor.js";
const invokeDoctorMemoryStatus = async (respond: ReturnType<typeof vi.fn>) => {
await doctorHandlers["doctor.memory.status"]({
req: {} as never,
params: {} as never,
respond: respond as never,
context: {} as never,
client: null,
isWebchatConnect: () => false,
});
};
const expectEmbeddingErrorResponse = (respond: ReturnType<typeof vi.fn>, error: string) => {
expect(respond).toHaveBeenCalledWith(
true,
{
agentId: "main",
embedding: {
ok: false,
error,
},
},
undefined,
);
};
describe("doctor.memory.status", () => {
beforeEach(() => {
loadConfig.mockClear();
@@ -37,14 +62,7 @@ describe("doctor.memory.status", () => {
});
const respond = vi.fn();
await doctorHandlers["doctor.memory.status"]({
req: {} as never,
params: {} as never,
respond: respond as never,
context: {} as never,
client: null,
isWebchatConnect: () => false,
});
await invokeDoctorMemoryStatus(respond);
expect(getMemorySearchManager).toHaveBeenCalledWith({
cfg: expect.any(Object),
@@ -70,26 +88,9 @@ describe("doctor.memory.status", () => {
});
const respond = vi.fn();
await doctorHandlers["doctor.memory.status"]({
req: {} as never,
params: {} as never,
respond: respond as never,
context: {} as never,
client: null,
isWebchatConnect: () => false,
});
await invokeDoctorMemoryStatus(respond);
expect(respond).toHaveBeenCalledWith(
true,
{
agentId: "main",
embedding: {
ok: false,
error: "memory search unavailable",
},
},
undefined,
);
expectEmbeddingErrorResponse(respond, "memory search unavailable");
});
it("returns probe failure when manager probe throws", async () => {
@@ -103,26 +104,9 @@ describe("doctor.memory.status", () => {
});
const respond = vi.fn();
await doctorHandlers["doctor.memory.status"]({
req: {} as never,
params: {} as never,
respond: respond as never,
context: {} as never,
client: null,
isWebchatConnect: () => false,
});
await invokeDoctorMemoryStatus(respond);
expect(respond).toHaveBeenCalledWith(
true,
{
agentId: "main",
embedding: {
ok: false,
error: "gateway memory probe failed: timeout",
},
},
undefined,
);
expectEmbeddingErrorResponse(respond, "gateway memory probe failed: timeout");
expect(close).toHaveBeenCalled();
});
});

View File

@@ -11,6 +11,17 @@ vi.mock("../memory/index.js", () => ({
import { startGatewayMemoryBackend } from "./server-startup-memory.js";
function createQmdConfig(agents: OpenClawConfig["agents"]): OpenClawConfig {
return {
agents,
memory: { backend: "qmd", qmd: {} },
} as OpenClawConfig;
}
function createGatewayLogMock() {
return { info: vi.fn(), warn: vi.fn() };
}
describe("startGatewayMemoryBackend", () => {
beforeEach(() => {
getMemorySearchManagerMock.mockClear();
@@ -31,11 +42,8 @@ describe("startGatewayMemoryBackend", () => {
});
it("initializes qmd backend for each configured agent", async () => {
const cfg = {
agents: { list: [{ id: "ops", default: true }, { id: "main" }] },
memory: { backend: "qmd", qmd: {} },
} as OpenClawConfig;
const log = { info: vi.fn(), warn: vi.fn() };
const cfg = createQmdConfig({ list: [{ id: "ops", default: true }, { id: "main" }] });
const log = createGatewayLogMock();
getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } });
await startGatewayMemoryBackend({ cfg, log });
@@ -55,11 +63,8 @@ describe("startGatewayMemoryBackend", () => {
});
it("logs a warning when qmd manager init fails and continues with other agents", async () => {
const cfg = {
agents: { list: [{ id: "main", default: true }, { id: "ops" }] },
memory: { backend: "qmd", qmd: {} },
} as OpenClawConfig;
const log = { info: vi.fn(), warn: vi.fn() };
const cfg = createQmdConfig({ list: [{ id: "main", default: true }, { id: "ops" }] });
const log = createGatewayLogMock();
getMemorySearchManagerMock
.mockResolvedValueOnce({ manager: null, error: "qmd missing" })
.mockResolvedValueOnce({ manager: { search: vi.fn() } });
@@ -75,17 +80,14 @@ describe("startGatewayMemoryBackend", () => {
});
it("skips agents with memory search disabled", async () => {
const cfg = {
agents: {
defaults: { memorySearch: { enabled: true } },
list: [
{ id: "main", default: true },
{ id: "ops", memorySearch: { enabled: false } },
],
},
memory: { backend: "qmd", qmd: {} },
} as OpenClawConfig;
const log = { info: vi.fn(), warn: vi.fn() };
const cfg = createQmdConfig({
defaults: { memorySearch: { enabled: true } },
list: [
{ id: "main", default: true },
{ id: "ops", memorySearch: { enabled: false } },
],
});
const log = createGatewayLogMock();
getMemorySearchManagerMock.mockResolvedValue({ manager: { search: vi.fn() } });
await startGatewayMemoryBackend({ cfg, log });

View File

@@ -107,16 +107,33 @@ async function cleanupCronTestRun(params: {
process.env.OPENCLAW_SKIP_CRON = params.prevSkipCron;
}
async function setupCronTestRun(params: {
tempPrefix: string;
cronEnabled?: boolean;
sessionConfig?: { mainKey: string };
jobs?: unknown[];
}): Promise<{ prevSkipCron: string | undefined; dir: string }> {
const prevSkipCron = process.env.OPENCLAW_SKIP_CRON;
process.env.OPENCLAW_SKIP_CRON = "0";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), params.tempPrefix));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.sessionConfig = params.sessionConfig;
testState.cronEnabled = params.cronEnabled;
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: params.jobs ?? [] }),
);
return { prevSkipCron, dir };
}
describe("gateway server cron", () => {
test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => {
const prevSkipCron = process.env.OPENCLAW_SKIP_CRON;
process.env.OPENCLAW_SKIP_CRON = "0";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.sessionConfig = { mainKey: "primary" };
testState.cronEnabled = false;
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { prevSkipCron, dir } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-",
sessionConfig: { mainKey: "primary" },
cronEnabled: false,
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -385,13 +402,9 @@ describe("gateway server cron", () => {
});
test("writes cron run history and auto-runs due jobs", async () => {
const prevSkipCron = process.env.OPENCLAW_SKIP_CRON;
process.env.OPENCLAW_SKIP_CRON = "0";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-cron-log-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.cronEnabled = undefined;
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { prevSkipCron, dir } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-log-",
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -489,13 +502,6 @@ describe("gateway server cron", () => {
}, 45_000);
test("posts webhooks for delivery mode and legacy notify fallback only when summary exists", async () => {
const prevSkipCron = process.env.OPENCLAW_SKIP_CRON;
process.env.OPENCLAW_SKIP_CRON = "0";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-cron-webhook-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.cronEnabled = false;
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
const legacyNotifyJob = {
id: "legacy-notify-job",
name: "legacy notify job",
@@ -509,10 +515,11 @@ describe("gateway server cron", () => {
payload: { kind: "systemEvent", text: "legacy webhook" },
state: {},
};
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [legacyNotifyJob] }),
);
const { prevSkipCron, dir } = await setupCronTestRun({
tempPrefix: "openclaw-gw-cron-webhook-",
cronEnabled: false,
jobs: [legacyNotifyJob],
});
const configPath = process.env.OPENCLAW_CONFIG_PATH;
expect(typeof configPath).toBe("string");

View File

@@ -63,6 +63,17 @@ const ENV_OPTIONS_WITH_VALUE = new Set([
"--ignore-signal",
"--block-signal",
]);
const ENV_INLINE_VALUE_PREFIXES = [
"-u",
"-c",
"-s",
"--unset=",
"--chdir=",
"--split-string=",
"--default-signal=",
"--ignore-signal=",
"--block-signal=",
] as const;
const ENV_FLAG_OPTIONS = new Set(["-i", "--ignore-environment", "-0", "--null"]);
const NICE_OPTIONS_WITH_VALUE = new Set(["-n", "--adjustment", "--priority"]);
const STDBUF_OPTIONS_WITH_VALUE = new Set(["-i", "--input", "-o", "--output", "-e", "--error"]);
@@ -125,6 +136,15 @@ export function isEnvAssignment(token: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token);
}
function hasEnvInlineValuePrefix(lower: string): boolean {
for (const prefix of ENV_INLINE_VALUE_PREFIXES) {
if (lower.startsWith(prefix)) {
return true;
}
}
return false;
}
type WrapperScanDirective = "continue" | "consume-next" | "stop" | "invalid";
function scanWrapperInvocation(
@@ -191,17 +211,7 @@ export function unwrapEnvInvocation(argv: string[]): string[] | null {
if (ENV_OPTIONS_WITH_VALUE.has(flag)) {
return lower.includes("=") ? "continue" : "consume-next";
}
if (
lower.startsWith("-u") ||
lower.startsWith("-c") ||
lower.startsWith("-s") ||
lower.startsWith("--unset=") ||
lower.startsWith("--chdir=") ||
lower.startsWith("--split-string=") ||
lower.startsWith("--default-signal=") ||
lower.startsWith("--ignore-signal=") ||
lower.startsWith("--block-signal=")
) {
if (hasEnvInlineValuePrefix(lower)) {
return "continue";
}
return "invalid";
@@ -244,17 +254,7 @@ function envInvocationUsesModifiers(argv: string[]): boolean {
idx += 1;
continue;
}
if (
lower.startsWith("-u") ||
lower.startsWith("-c") ||
lower.startsWith("-s") ||
lower.startsWith("--unset=") ||
lower.startsWith("--chdir=") ||
lower.startsWith("--split-string=") ||
lower.startsWith("--default-signal=") ||
lower.startsWith("--ignore-signal=") ||
lower.startsWith("--block-signal=")
) {
if (hasEnvInlineValuePrefix(lower)) {
return true;
}
// Unknown env flags are treated conservatively as modifiers.
@@ -265,13 +265,8 @@ function envInvocationUsesModifiers(argv: string[]): boolean {
}
function unwrapNiceInvocation(argv: string[]): string[] | null {
return scanWrapperInvocation(argv, {
separators: new Set(["--"]),
onToken: (token, lower) => {
if (!token.startsWith("-") || token === "-") {
return "stop";
}
const [flag] = lower.split("=", 2);
return unwrapDashOptionInvocation(argv, {
onFlag: (flag, lower) => {
if (/^-\d+$/.test(lower)) {
return "continue";
}
@@ -298,7 +293,13 @@ function unwrapNohupInvocation(argv: string[]): string[] | null {
});
}
function unwrapStdbufInvocation(argv: string[]): string[] | null {
function unwrapDashOptionInvocation(
argv: string[],
params: {
onFlag: (flag: string, lowerToken: string) => WrapperScanDirective;
adjustCommandIndex?: (commandIndex: number, argv: string[]) => number | null;
},
): string[] | null {
return scanWrapperInvocation(argv, {
separators: new Set(["--"]),
onToken: (token, lower) => {
@@ -306,22 +307,26 @@ function unwrapStdbufInvocation(argv: string[]): string[] | null {
return "stop";
}
const [flag] = lower.split("=", 2);
if (STDBUF_OPTIONS_WITH_VALUE.has(flag)) {
return lower.includes("=") ? "continue" : "consume-next";
return params.onFlag(flag, lower);
},
adjustCommandIndex: params.adjustCommandIndex,
});
}
function unwrapStdbufInvocation(argv: string[]): string[] | null {
return unwrapDashOptionInvocation(argv, {
onFlag: (flag, lower) => {
if (!STDBUF_OPTIONS_WITH_VALUE.has(flag)) {
return "invalid";
}
return "invalid";
return lower.includes("=") ? "continue" : "consume-next";
},
});
}
function unwrapTimeoutInvocation(argv: string[]): string[] | null {
return scanWrapperInvocation(argv, {
separators: new Set(["--"]),
onToken: (token, lower) => {
if (!token.startsWith("-") || token === "-") {
return "stop";
}
const [flag] = lower.split("=", 2);
return unwrapDashOptionInvocation(argv, {
onFlag: (flag, lower) => {
if (TIMEOUT_FLAG_OPTIONS.has(flag)) {
return "continue";
}

View File

@@ -160,6 +160,24 @@ async function createAudioCtx(params?: {
} satisfies MsgContext;
}
async function setupAudioAutoDetectCase(stdout: string): Promise<{
ctx: MsgContext;
cfg: OpenClawConfig;
}> {
const ctx = await createAudioCtx({
fileName: "sample.wav",
mediaType: "audio/wav",
content: "audio",
});
const cfg: OpenClawConfig = { tools: { media: { audio: {} } } };
const execModule = await import("../process/exec.js");
vi.mocked(execModule.runExec).mockResolvedValueOnce({
stdout,
stderr: "",
});
return { ctx, cfg };
}
async function applyWithDisabledMedia(params: {
body: string;
mediaPath: string;
@@ -395,19 +413,9 @@ describe("applyMediaUnderstanding", () => {
await fs.writeFile(path.join(modelDir, "decoder.onnx"), "a");
await fs.writeFile(path.join(modelDir, "joiner.onnx"), "a");
const ctx = await createAudioCtx({
fileName: "sample.wav",
mediaType: "audio/wav",
content: "audio",
});
const cfg: OpenClawConfig = { tools: { media: { audio: {} } } };
const { ctx, cfg } = await setupAudioAutoDetectCase('{"text":"sherpa ok"}');
const execModule = await import("../process/exec.js");
const mockedRunExec = vi.mocked(execModule.runExec);
mockedRunExec.mockResolvedValueOnce({
stdout: '{"text":"sherpa ok"}',
stderr: "",
});
await withMediaAutoDetectEnv(
{
@@ -435,19 +443,9 @@ describe("applyMediaUnderstanding", () => {
const modelPath = path.join(modelDir, "tiny.bin");
await fs.writeFile(modelPath, "model");
const ctx = await createAudioCtx({
fileName: "sample.wav",
mediaType: "audio/wav",
content: "audio",
});
const cfg: OpenClawConfig = { tools: { media: { audio: {} } } };
const { ctx, cfg } = await setupAudioAutoDetectCase("whisper cpp ok\n");
const execModule = await import("../process/exec.js");
const mockedRunExec = vi.mocked(execModule.runExec);
mockedRunExec.mockResolvedValueOnce({
stdout: "whisper cpp ok\n",
stderr: "",
});
await withMediaAutoDetectEnv(
{

View File

@@ -320,6 +320,29 @@ async function resolveProviderExecutionAuth(params: {
};
}
async function resolveProviderExecutionContext(params: {
providerId: string;
cfg: OpenClawConfig;
entry: MediaUnderstandingModelConfig;
config?: MediaUnderstandingConfig;
agentDir?: string;
}) {
const { apiKeys, providerConfig } = await resolveProviderExecutionAuth({
providerId: params.providerId,
cfg: params.cfg,
entry: params.entry,
agentDir: params.agentDir,
});
const baseUrl = params.entry.baseUrl ?? params.config?.baseUrl ?? providerConfig?.baseUrl;
const mergedHeaders = {
...providerConfig?.headers,
...params.config?.headers,
...params.entry.headers,
};
const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined;
return { apiKeys, baseUrl, headers };
}
export function formatDecisionSummary(decision: MediaUnderstandingDecision): string {
const total = decision.attachments.length;
const success = decision.attachments.filter(
@@ -428,19 +451,13 @@ export async function runProviderEntry(params: {
maxBytes,
timeoutMs,
});
const { apiKeys, providerConfig } = await resolveProviderExecutionAuth({
const { apiKeys, baseUrl, headers } = await resolveProviderExecutionContext({
providerId,
cfg,
entry,
config: params.config,
agentDir: params.agentDir,
});
const baseUrl = entry.baseUrl ?? params.config?.baseUrl ?? providerConfig?.baseUrl;
const mergedHeaders = {
...providerConfig?.headers,
...params.config?.headers,
...entry.headers,
};
const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined;
const providerQuery = resolveProviderQuery({
providerId,
config: params.config,
@@ -491,19 +508,13 @@ export async function runProviderEntry(params: {
`Video attachment ${params.attachmentIndex + 1} base64 payload ${estimatedBase64Bytes} exceeds ${maxBase64Bytes}`,
);
}
const { apiKeys, providerConfig } = await resolveProviderExecutionAuth({
const { apiKeys, baseUrl, headers } = await resolveProviderExecutionContext({
providerId,
cfg,
entry,
config: params.config,
agentDir: params.agentDir,
});
const baseUrl = entry.baseUrl ?? params.config?.baseUrl ?? providerConfig?.baseUrl;
const mergedHeaders = {
...providerConfig?.headers,
...params.config?.headers,
...entry.headers,
};
const headers = Object.keys(mergedHeaders).length > 0 ? mergedHeaders : undefined;
const result = await executeWithApiKeyRotation({
provider: providerId,
apiKeys,

View File

@@ -27,6 +27,11 @@ const createGeminiFetchMock = () =>
json: async () => ({ embedding: { values: [1, 2, 3] } }),
}));
function readFirstFetchRequest(fetchMock: { mock: { calls: unknown[][] } }) {
const [url, init] = fetchMock.mock.calls[0] ?? [];
return { url, init: init as RequestInit | undefined };
}
afterEach(() => {
vi.resetAllMocks();
vi.unstubAllGlobals();
@@ -196,8 +201,7 @@ describe("embedding provider remote overrides", () => {
const provider = requireProvider(result);
await provider.embedQuery("hello");
const url = fetchMock.mock.calls[0]?.[0];
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
const { url, init } = readFirstFetchRequest(fetchMock);
expect(url).toBe(
"https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent",
);
@@ -234,8 +238,7 @@ describe("embedding provider remote overrides", () => {
const provider = requireProvider(result);
await provider.embedQuery("hello");
const url = fetchMock.mock.calls[0]?.[0];
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
const { url, init } = readFirstFetchRequest(fetchMock);
expect(url).toBe("https://api.mistral.ai/v1/embeddings");
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe("Bearer mistral-key");

View File

@@ -1,22 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const mockPrimary = {
search: vi.fn(async () => []),
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
status: vi.fn(() => ({
backend: "qmd" as const,
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
function createManagerStatus(params: {
backend: "qmd" | "builtin";
provider: string;
model: string;
requestedProvider: string;
withMemorySourceCounts?: boolean;
}) {
const base = {
backend: params.backend,
provider: params.provider,
model: params.model,
requestedProvider: params.requestedProvider,
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp",
dbPath: "/tmp/index.sqlite",
};
if (!params.withMemorySourceCounts) {
return base;
}
return {
...base,
sources: ["memory" as const],
sourceCounts: [{ source: "memory" as const, files: 0, chunks: 0 }],
})),
};
}
const qmdManagerStatus = createManagerStatus({
backend: "qmd",
provider: "qmd",
model: "qmd",
requestedProvider: "qmd",
withMemorySourceCounts: true,
});
const fallbackManagerStatus = createManagerStatus({
backend: "builtin",
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
});
const mockPrimary = {
search: vi.fn(async () => []),
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
status: vi.fn(() => qmdManagerStatus),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),
@@ -37,17 +68,7 @@ const fallbackSearch = vi.fn(async () => [
const fallbackManager = {
search: fallbackSearch,
readFile: vi.fn(async () => ({ text: "", path: "MEMORY.md" })),
status: vi.fn(() => ({
backend: "builtin" as const,
provider: "openai",
model: "text-embedding-3-small",
requestedProvider: "openai",
files: 0,
chunks: 0,
dirty: false,
workspaceDir: "/tmp",
dbPath: "/tmp/index.sqlite",
})),
status: vi.fn(() => fallbackManagerStatus),
sync: vi.fn(async () => {}),
probeEmbeddingAvailability: vi.fn(async () => ({ ok: true })),
probeVectorAvailability: vi.fn(async () => true),

View File

@@ -12,6 +12,26 @@ const fixtureRoot = path.join(os.tmpdir(), `openclaw-plugin-${randomUUID()}`);
let tempDirIndex = 0;
const prevBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
const BUNDLED_TELEGRAM_PLUGIN_BODY = `export default { id: "telegram", register(api) {
api.registerChannel({
plugin: {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "telegram channel"
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" })
},
outbound: { deliveryMode: "direct" }
}
});
} };`;
function makeTempDir() {
const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`);
@@ -94,6 +114,23 @@ function loadBundledMemoryPluginRegistry(options?: {
});
}
function setupBundledTelegramPlugin() {
const bundledDir = makeTempDir();
writePlugin({
id: "telegram",
body: BUNDLED_TELEGRAM_PLUGIN_BODY,
dir: bundledDir,
filename: "telegram.js",
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
}
function expectTelegramLoaded(registry: ReturnType<typeof loadOpenClawPlugins>) {
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
expect(telegram?.status).toBe("loaded");
expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true);
}
afterEach(() => {
if (prevBundledDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
@@ -150,33 +187,7 @@ describe("loadOpenClawPlugins", () => {
});
it("loads bundled telegram plugin when enabled", () => {
const bundledDir = makeTempDir();
writePlugin({
id: "telegram",
body: `export default { id: "telegram", register(api) {
api.registerChannel({
plugin: {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "telegram channel"
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" })
},
outbound: { deliveryMode: "direct" }
}
});
} };`,
dir: bundledDir,
filename: "telegram.js",
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
setupBundledTelegramPlugin();
const registry = loadOpenClawPlugins({
cache: false,
@@ -190,39 +201,11 @@ describe("loadOpenClawPlugins", () => {
},
});
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
expect(telegram?.status).toBe("loaded");
expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true);
expectTelegramLoaded(registry);
});
it("loads bundled channel plugins when channels.<id>.enabled=true", () => {
const bundledDir = makeTempDir();
writePlugin({
id: "telegram",
body: `export default { id: "telegram", register(api) {
api.registerChannel({
plugin: {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "telegram channel"
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" })
},
outbound: { deliveryMode: "direct" }
}
});
} };`,
dir: bundledDir,
filename: "telegram.js",
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
setupBundledTelegramPlugin();
const registry = loadOpenClawPlugins({
cache: false,
@@ -238,39 +221,11 @@ describe("loadOpenClawPlugins", () => {
},
});
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
expect(telegram?.status).toBe("loaded");
expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true);
expectTelegramLoaded(registry);
});
it("still respects explicit disable via plugins.entries for bundled channels", () => {
const bundledDir = makeTempDir();
writePlugin({
id: "telegram",
body: `export default { id: "telegram", register(api) {
api.registerChannel({
plugin: {
id: "telegram",
meta: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram",
docsPath: "/channels/telegram",
blurb: "telegram channel"
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: () => [],
resolveAccount: () => ({ accountId: "default" })
},
outbound: { deliveryMode: "direct" }
}
});
} };`,
dir: bundledDir,
filename: "telegram.js",
});
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
setupBundledTelegramPlugin();
const registry = loadOpenClawPlugins({
cache: false,

View File

@@ -52,6 +52,25 @@ function setRegistry(entries: MockRegistryToolEntry[]) {
return registry;
}
function setMultiToolRegistry() {
return setRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
factory: () => [makeTool("message"), makeTool("other_tool")],
},
]);
}
function resolveWithConflictingCoreName(options?: { suppressNameConflicts?: boolean }) {
return resolvePluginTools({
context: createContext() as never,
existingToolNames: new Set(["message"]),
...(options?.suppressNameConflicts ? { suppressNameConflicts: true } : {}),
});
}
describe("resolvePluginTools optional tools", () => {
beforeEach(() => {
loadOpenClawPluginsMock.mockClear();
@@ -136,19 +155,8 @@ describe("resolvePluginTools optional tools", () => {
});
it("skips conflicting tool names but keeps other tools", () => {
const registry = setRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
factory: () => [makeTool("message"), makeTool("other_tool")],
},
]);
const tools = resolvePluginTools({
context: createContext() as never,
existingToolNames: new Set(["message"]),
});
const registry = setMultiToolRegistry();
const tools = resolveWithConflictingCoreName();
expect(tools.map((tool) => tool.name)).toEqual(["other_tool"]);
expect(registry.diagnostics).toHaveLength(1);
@@ -156,20 +164,8 @@ describe("resolvePluginTools optional tools", () => {
});
it("suppresses conflict diagnostics when requested", () => {
const registry = setRegistry([
{
pluginId: "multi",
optional: false,
source: "/tmp/multi.js",
factory: () => [makeTool("message"), makeTool("other_tool")],
},
]);
const tools = resolvePluginTools({
context: createContext() as never,
existingToolNames: new Set(["message"]),
suppressNameConflicts: true,
});
const registry = setMultiToolRegistry();
const tools = resolveWithConflictingCoreName({ suppressNameConflicts: true });
expect(tools.map((tool) => tool.name)).toEqual(["other_tool"]);
expect(registry.diagnostics).toHaveLength(0);