mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor: deduplicate shared helpers and test setup
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
25
extensions/feishu/src/send-target.ts
Normal file
25
extensions/feishu/src/send-target.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user