diff --git a/extensions/bluebubbles/src/onboarding.ts b/extensions/bluebubbles/src/onboarding.ts index ca6b42ab5df..78b2876b5e0 100644 --- a/extensions/bluebubbles/src/onboarding.ts +++ b/extensions/bluebubbles/src/onboarding.ts @@ -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 => { + 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); } } diff --git a/extensions/diagnostics-otel/src/service.test.ts b/extensions/diagnostics-otel/src/service.test.ts index 73a3bad0b4c..8189ecaec8c 100644 --- a/extensions/diagnostics-otel/src/service.test.ts +++ b/extensions/diagnostics-otel/src/service.test.ts @@ -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) => 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) { + 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) => 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) => 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) => 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({ diff --git a/extensions/diagnostics-otel/src/service.ts b/extensions/diagnostics-otel/src/service.ts index a36341c8421..0749708c881 100644 --- a/extensions/diagnostics-otel/src/service.ts +++ b/extensions/diagnostics-otel/src/service.ts @@ -506,6 +506,18 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { } }; + const addSessionIdentityAttrs = ( + spanAttrs: Record, + 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, ) => { @@ -521,12 +533,7 @@ export function createDiagnosticsOtelService(): OpenClawPluginService { return; } const spanAttrs: Record = { ...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 = { ...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 }); diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index bbe56bbb02a..73c5ff2652c 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -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 { 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 { 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) { diff --git a/extensions/feishu/src/send-target.ts b/extensions/feishu/src/send-target.ts new file mode 100644 index 00000000000..7d0d28663cc --- /dev/null +++ b/extensions/feishu/src/send-target.ts @@ -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), + }; +} diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index c97601ccccb..341ff3ed64d 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -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 { 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 { 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) { diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 93cf4166108..56f1fc36557 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -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 { + 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 { 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; } diff --git a/extensions/tlon/src/config-schema.ts b/extensions/tlon/src/config-schema.ts index 3dbc091ef6f..ea80212088d 100644 --- a/extensions/tlon/src/config-schema.ts +++ b/extensions/tlon/src/config-schema.ts @@ -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(), diff --git a/extensions/voice-call/src/cli.ts b/extensions/voice-call/src/cli.ts index eaf4e3fc0a5..83b68153021 100644 --- a/extensions/voice-call/src/cli.ts +++ b/extensions/voice-call/src/cli.ts @@ -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 diff --git a/extensions/voice-call/src/manager/outbound.ts b/extensions/voice-call/src/manager/outbound.ts index 38978b6791c..16f7f65942f 100644 --- a/extensions/voice-call/src/manager/outbound.ts +++ b/extensions/voice-call/src/manager/outbound.ts @@ -63,6 +63,15 @@ type ConnectedCallLookup = provider: NonNullable; }; +type ConnectedCallResolution = + | { ok: false; error: string } + | { + ok: true; + call: CallRecord; + providerCallId: string; + provider: NonNullable; + }; + 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" }; diff --git a/extensions/voice-call/src/providers/plivo.ts b/extensions/voice-call/src/providers/plivo.ts index 9739379cf58..2bd5a25a616 100644 --- a/extensions/voice-call/src/providers/plivo.ts +++ b/extensions/voice-call/src/providers/plivo.ts @@ -331,31 +331,40 @@ export class PlivoProvider implements VoiceCallProvider { }); } - async playTts(input: PlayTtsInput): Promise { - 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 { + 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 { + 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 { - 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", }); } diff --git a/src/agents/pi-extensions/compaction-safeguard.test.ts b/src/agents/pi-extensions/compaction-safeguard.test.ts index e0033b0bb75..1c75139df97 100644 --- a/src/agents/pi-extensions/compaction-safeguard.test.ts +++ b/src/agents/pi-extensions/compaction-safeguard.test.ts @@ -37,6 +37,67 @@ function stubSessionManager(): ExtensionContext["sessionManager"] { return stub; } +function createAnthropicModelFixture(overrides: Partial> = {}): Model { + 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; +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; +}) => + ({ + model: undefined, + sessionManager: params.sessionManager, + modelRegistry: { + getApiKey: params.getApiKeyMock, + }, + }) as unknown as Partial; + 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 = { - 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 = { - 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 = { - 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; - 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; + 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; - 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; + 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; }; diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index 5dc9819c4cd..492cfdfbf4a 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -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 () => { diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index d8e198184e0..15317ca70bb 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -39,6 +39,24 @@ function baseConfig(): OpenClawConfig { } as unknown as OpenClawConfig; } +function resolveModelSelectionForCommand(params: { + command: string; + allowedModelKeys: Set; + 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(); diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index 5c7caab7781..68f47253aff 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -14,6 +14,74 @@ vi.mock("./commands.js", () => ({ // Import after mocks. const { handleInlineActions } = await import("./get-reply-inline-actions.js"); +type HandleInlineActionsInput = Parameters[0]; + +const createTypingController = (): TypingController => ({ + onReplyStart: async () => {}, + startTypingLoop: async () => {}, + startTypingOnText: async () => {}, + refreshTypingTtl: () => {}, + isActive: () => false, + markRunComplete: () => {}, + markDispatchIdle: () => {}, + cleanup: vi.fn(), +}); + +const createHandleInlineActionsInput = (params: { + ctx: ReturnType; + typing: TypingController; + cleanedBody: string; + command?: Partial; + overrides?: Partial>; +}): 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); diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 59a698d6dff..71a82e42644 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -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); }); diff --git a/src/config/config.web-search-provider.test.ts b/src/config/config.web-search-provider.test.ts index ca3774e4ea1..1fe3d85a861 100644 --- a/src/config/config.web-search-provider.test.ts +++ b/src/config/config.web-search-provider.test.ts @@ -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"); diff --git a/src/config/io.write-config.test.ts b/src/config/io.write-config.test.ts index 17f1951de33..d8ac2bbc280 100644 --- a/src/config/io.write-config.test.ts +++ b/src/config/io.write-config.test.ts @@ -79,6 +79,35 @@ describe("config io write", () => { return { last, lines, configPath }; } + const createGatewayCommandsInput = (): Record => ({ + gateway: { mode: "local" }, + commands: { ownerDisplay: "hash" }, + }); + + const expectInputOwnerDisplayUnchanged = (input: Record) => { + expect((input.commands as Record).ownerDisplay).toBe("hash"); + }; + + const readPersistedCommands = async (configPath: string) => { + const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { + commands?: Record; + }; + 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).ownerDisplay).toBe("hash"); - const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { - commands?: Record; - }; - 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; await io.writeConfigFile(input, { unsetPaths: [["commands", "ownerDisplay"]] }); - expect((input.commands as Record).ownerDisplay).toBe("hash"); - const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { - commands?: Record; - }; - 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 = { - gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, - }; - await io.writeConfigFile(input, { unsetPaths: [["commands", "missingKey"]] }); - - expect((input.commands as Record).ownerDisplay).toBe("hash"); - const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { - commands?: Record; - }; - 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 = { - gateway: { mode: "local" }, - commands: { ownerDisplay: "hash" }, - }; - await io.writeConfigFile(input, { unsetPaths: [ ["commands", "__proto__"], ["commands", "constructor"], ["commands", "prototype"], ], }); - - expect((input.commands as Record).ownerDisplay).toBe("hash"); - const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as { - commands?: Record; - }; - expect(persisted.commands?.ownerDisplay).toBe("hash"); }); }); diff --git a/src/config/test-helpers.ts b/src/config/test-helpers.ts index 14e62ddfd74..69e7745a85b 100644 --- a/src/config/test-helpers.ts +++ b/src/config/test-helpers.ts @@ -51,3 +51,24 @@ export async function withEnvOverride( } } } + +export function buildWebSearchProviderConfig(params: { + provider: string; + enabled?: boolean; + providerConfig?: Record; +}): Record { + const search: Record = { provider: params.provider }; + if (params.enabled !== undefined) { + search.enabled = params.enabled; + } + if (params.providerConfig) { + search[params.provider] = params.providerConfig; + } + return { + tools: { + web: { + search, + }, + }, + }; +} diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index 426c4279a21..6b3240b58c6 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -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" diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts index ab9c2851959..23ef28c7ce3 100644 --- a/src/gateway/boot.test.ts +++ b/src/gateway/boot.test.ts @@ -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 }); }); }); }); diff --git a/src/gateway/client.test.ts b/src/gateway/client.test.ts index 263933d16bb..e9abd4a7600 100644 --- a/src/gateway/client.test.ts +++ b/src/gateway/client.test.ts @@ -132,6 +132,22 @@ function createClientWithIdentity( }); } +function expectSecurityConnectError( + onConnectError: ReturnType, + 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(); }); diff --git a/src/gateway/node-invoke-system-run-approval.test.ts b/src/gateway/node-invoke-system-run-approval.test.ts index d93656682aa..a5a7c3d9f0d 100644 --- a/src/gateway/node-invoke-system-run-approval.test.ts +++ b/src/gateway/node-invoke-system-run-approval.test.ts @@ -40,6 +40,18 @@ describe("sanitizeSystemRunParamsForForwarding", () => { }; } + function expectAllowOnceForwardingResult( + result: ReturnType, + ) { + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("unreachable"); + } + const params = result.params as Record; + 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; - 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; - expect(params.approved).toBe(true); - expect(params.approvalDecision).toBe("allow-once"); + expectAllowOnceForwardingResult(result); }); }); diff --git a/src/gateway/server-methods/doctor.test.ts b/src/gateway/server-methods/doctor.test.ts index 13b9b1e4603..eda301f5545 100644 --- a/src/gateway/server-methods/doctor.test.ts +++ b/src/gateway/server-methods/doctor.test.ts @@ -19,6 +19,31 @@ vi.mock("../../memory/index.js", () => ({ import { doctorHandlers } from "./doctor.js"; +const invokeDoctorMemoryStatus = async (respond: ReturnType) => { + 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, 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(); }); }); diff --git a/src/gateway/server-startup-memory.test.ts b/src/gateway/server-startup-memory.test.ts index 555a27ae8b5..2eeef82b9ed 100644 --- a/src/gateway/server-startup-memory.test.ts +++ b/src/gateway/server-startup-memory.test.ts @@ -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 }); diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index 10cd9dcefde..daa5303d2c6 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -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"); diff --git a/src/infra/exec-wrapper-resolution.ts b/src/infra/exec-wrapper-resolution.ts index 166657087c8..1c31d3713d4 100644 --- a/src/infra/exec-wrapper-resolution.ts +++ b/src/infra/exec-wrapper-resolution.ts @@ -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"; } diff --git a/src/media-understanding/apply.test.ts b/src/media-understanding/apply.test.ts index 3f627806506..1c0b8f142a8 100644 --- a/src/media-understanding/apply.test.ts +++ b/src/media-understanding/apply.test.ts @@ -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( { diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 3ef48b0ce4f..3e80caae9bc 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -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, diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index b93a2a61f2c..57e4410f821 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -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; expect(headers.Authorization).toBe("Bearer mistral-key"); diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index e2a16116575..d853f5af1fa 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -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), diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index d63f8890cf5..5a43702570e 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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) { + 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..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, diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index e73aa2f9dd5..a3c4c2fb249 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -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);