diff --git a/extensions/qqbot/src/config-schema.ts b/extensions/qqbot/src/config-schema.ts index 56610e576ec..522ef8679ef 100644 --- a/extensions/qqbot/src/config-schema.ts +++ b/extensions/qqbot/src/config-schema.ts @@ -72,10 +72,22 @@ const QQBotAccountSchema = z }) .passthrough(); +const QQBotNamedAccountSchema = QQBotAccountSchema.superRefine((value, ctx) => { + for (const key of ["tts", "stt"] as const) { + if (key in value) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: `channels.qqbot.accounts entries do not support ${key} overrides`, + }); + } + } +}); + export const QQBotConfigSchema = QQBotAccountSchema.extend({ tts: QQBotTtsSchema, stt: QQBotSttSchema, - accounts: z.object({}).catchall(QQBotAccountSchema.passthrough()).optional(), + accounts: z.object({}).catchall(QQBotNamedAccountSchema).optional(), defaultAccount: z.string().optional(), }).passthrough(); export const qqbotChannelConfigSchema = buildChannelConfigSchema(QQBotConfigSchema); diff --git a/extensions/whatsapp/src/outbound-test-support.ts b/extensions/whatsapp/src/outbound-test-support.ts index 94dd78f9aca..67999391a5c 100644 --- a/extensions/whatsapp/src/outbound-test-support.ts +++ b/extensions/whatsapp/src/outbound-test-support.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { MockInstance } from "vitest"; export function createWhatsAppPollFixture() { const cfg = { marker: "resolved-cfg" } as OpenClawConfig; @@ -14,3 +15,29 @@ export function createWhatsAppPollFixture() { accountId: "work", }; } + +export function expectWhatsAppPollSent( + sendPollWhatsApp: MockInstance, + params: { + cfg: OpenClawConfig; + poll: { question: string; options: string[]; maxSelections: number }; + to?: string; + accountId?: string; + }, +) { + const expected = [ + params.to ?? "+1555", + params.poll, + { + verbose: false, + accountId: params.accountId ?? "work", + cfg: params.cfg, + }, + ]; + const actual = sendPollWhatsApp.mock.calls.at(-1); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected WhatsApp poll send ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 26c5d8033c7..05c006f6853 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -589,7 +589,7 @@ describe("spawnAcpDirect", () => { agentSessionKey: "agent:main:matrix:channel:!room:example", agentChannel: "matrix", agentAccountId: "default", - agentTo: "room:!room:example", + agentTo: "channel:!room:example", }, ); expect(result.status, JSON.stringify(result)).toBe("accepted"); diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index f14be0f225e..d7384d9e3f7 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -6,7 +6,6 @@ import type { CliBundleMcpMode } from "../plugins/types.js"; let createEmptyPluginRegistry: typeof import("../plugins/registry.js").createEmptyPluginRegistry; let resetPluginRuntimeStateForTest: typeof import("../plugins/runtime.js").resetPluginRuntimeStateForTest; let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry; -let normalizeClaudeBackendConfig: typeof import("./cli-backends.js").normalizeClaudeBackendConfig; let resolveCliBackendConfig: typeof import("./cli-backends.js").resolveCliBackendConfig; let resolveCliBackendLiveTest: typeof import("./cli-backends.js").resolveCliBackendLiveTest; @@ -34,7 +33,7 @@ function createBackendEntry(params: { : params.id === "codex-cli" ? "codex-cli/gpt-5.4" : params.id === "google-gemini-cli" - ? "google-gemini-cli/gemini-3.1-pro-preview" + ? "google-gemini-cli/gemini-3-flash-preview" : undefined, defaultImageProbe: true, defaultMcpProbe: true, @@ -93,6 +92,63 @@ const NORMALIZED_CLAUDE_FALLBACK_RESUME_ARGS = [ "bypassPermissions", ]; +function normalizeTestClaudeArgs(args?: string[]): string[] | undefined { + if (!args) { + return args; + } + const normalized: string[] = []; + let hasSettingSources = false; + let hasPermissionMode = false; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--dangerously-skip-permissions") { + continue; + } + if (arg === "--setting-sources") { + const maybeValue = args[i + 1]; + if (maybeValue && !maybeValue.startsWith("-")) { + hasSettingSources = true; + normalized.push(arg, "user"); + i += 1; + } + continue; + } + if (arg.startsWith("--setting-sources=")) { + hasSettingSources = true; + normalized.push("--setting-sources=user"); + continue; + } + if (arg === "--permission-mode") { + const maybeValue = args[i + 1]; + if (maybeValue && !maybeValue.startsWith("-")) { + hasPermissionMode = true; + normalized.push(arg, maybeValue); + i += 1; + } + continue; + } + if (arg.startsWith("--permission-mode=")) { + hasPermissionMode = true; + } + normalized.push(arg); + } + if (!hasSettingSources) { + normalized.push("--setting-sources", "user"); + } + if (!hasPermissionMode) { + normalized.push("--permission-mode", "bypassPermissions"); + } + return normalized; +} + +function normalizeTestClaudeBackendConfig(config: CliBackendConfig): CliBackendConfig { + return { + ...config, + args: normalizeTestClaudeArgs(config.args), + resumeArgs: normalizeTestClaudeArgs(config.resumeArgs), + }; +} + beforeAll(async () => { vi.doUnmock("../plugins/setup-registry.js"); vi.doUnmock("../plugins/cli-backends.runtime.js"); @@ -100,8 +156,7 @@ beforeAll(async () => { ({ createEmptyPluginRegistry } = await import("../plugins/registry.js")); ({ resetPluginRuntimeStateForTest, setActivePluginRegistry } = await import("../plugins/runtime.js")); - ({ normalizeClaudeBackendConfig, resolveCliBackendConfig, resolveCliBackendLiveTest } = - await import("./cli-backends.js")); + ({ resolveCliBackendConfig, resolveCliBackendLiveTest } = await import("./cli-backends.js")); }); afterEach(() => { @@ -165,7 +220,7 @@ beforeEach(() => { "CLAUDE_CODE_USE_VERTEX", ], }, - normalizeConfig: normalizeClaudeBackendConfig, + normalizeConfig: normalizeTestClaudeBackendConfig, }), createBackendEntry({ pluginId: "openai", diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index f1a65808550..d78a17a635d 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -99,8 +99,14 @@ describe("normalizeProviders", () => { }); it("replaces resolved env var value with env var name to prevent plaintext persistence", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); - const original = process.env.OPENAI_API_KEY; - process.env.OPENAI_API_KEY = "sk-test-secret-value-12345"; // pragma: allowlist secret + const env = { + ...process.env, + OPENAI_API_KEY: "sk-test-secret-value-12345", // pragma: allowlist secret + OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, + OPENCLAW_DISABLE_BUNDLED_PLUGINS: undefined, + OPENCLAW_SKIP_PROVIDERS: undefined, + OPENCLAW_TEST_MINIMAL_GATEWAY: undefined, + }; const secretRefManagedProviders = new Set(); try { const providers: NonNullable["providers"]> = { @@ -121,15 +127,15 @@ describe("normalizeProviders", () => { ], }, }; - const normalized = normalizeProviders({ providers, agentDir, secretRefManagedProviders }); + const normalized = normalizeProviders({ + providers, + agentDir, + env, + secretRefManagedProviders, + }); expect(normalized?.openai?.apiKey).toBe("OPENAI_API_KEY"); expect(secretRefManagedProviders.has("openai")).toBe(true); } finally { - if (original === undefined) { - delete process.env.OPENAI_API_KEY; - } else { - process.env.OPENAI_API_KEY = original; - } await fs.rm(agentDir, { recursive: true, force: true }); } }); diff --git a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts index 648511e39b8..65a04554664 100644 --- a/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts +++ b/src/agents/pi-embedded-runner/run/compaction-retry-aggregate-timeout.test.ts @@ -7,10 +7,12 @@ type TimeoutCallbackMock = ReturnType>; async function withFakeTimers(run: () => Promise) { vi.useFakeTimers(); + vi.clearAllTimers(); try { await run(); } finally { await vi.runOnlyPendingTimersAsync(); + vi.clearAllTimers(); vi.useRealTimers(); } } diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index 643169246d5..729845cc939 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -11,6 +11,7 @@ afterEach(() => { vi.doUnmock("../../plugins/bundled-plugin-metadata.js"); vi.doUnmock("../../plugins/discovery.js"); vi.doUnmock("../../plugins/manifest-registry.js"); + vi.doUnmock("../../plugins/channel-catalog-registry.js"); vi.doUnmock("../../infra/boundary-file-read.js"); vi.doUnmock("jiti"); }); diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 327c9765135..d24890d45fb 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -42,13 +42,10 @@ vi.mock("../channels/plugins/setup-registry.js", () => ({ })); vi.mock("../channels/registry.js", () => ({ + getChatChannelMeta: (channelId: string) => ({ id: channelId, label: channelId }), listChatChannels: () => [], - getChatChannelMeta: (channelId?: unknown) => ({ - id: typeof channelId === "string" ? channelId : "unknown", - label: typeof channelId === "string" ? channelId : "Unknown", - }), normalizeChatChannelId: (channelId?: unknown) => - typeof channelId === "string" ? channelId.trim().toLowerCase() : undefined, + typeof channelId === "string" ? channelId.trim().toLowerCase() || null : null, })); vi.mock("../commands/channel-setup/discovery.js", () => ({ diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index d602f98aa74..85704e56c0b 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -10,6 +10,7 @@ import { agentCommand, getFreePort, installGatewayTestHooks, + startGatewayServerWithRetries, testState, withGatewayServer, } from "./test-helpers.js"; @@ -22,12 +23,21 @@ let enabledPort: number; beforeAll(async () => { ({ startGatewayServer } = await import("./server.js")); - enabledPort = await getFreePort(); - enabledServer = await startServer(enabledPort); + const started = await startGatewayServerWithRetries({ + port: await getFreePort(), + opts: { + host: "127.0.0.1", + auth: { mode: "none" }, + controlUiEnabled: false, + openAiChatCompletionsEnabled: true, + }, + }); + enabledPort = started.port; + enabledServer = started.server; }); afterAll(async () => { - await enabledServer.close({ reason: "openai http enabled suite done" }); + await enabledServer?.close({ reason: "openai http enabled suite done" }); }); async function startServerWithDefaultConfig(port: number) { diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index e125af19bb8..b20359d051c 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -6,7 +6,12 @@ import { HISTORY_CONTEXT_MARKER } from "../auto-reply/reply/history.js"; import { CURRENT_MESSAGE_MARKER } from "../auto-reply/reply/mentions.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { buildAssistantDeltaResult } from "./test-helpers.agent-results.js"; -import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js"; +import { + agentCommand, + getFreePort, + installGatewayTestHooks, + startGatewayServerWithRetries, +} from "./test-helpers.js"; installGatewayTestHooks({ scope: "suite" }); @@ -30,12 +35,21 @@ let openResponsesTesting: { beforeAll(async () => { ({ __testing: openResponsesTesting } = await import("./openresponses-http.js")); - enabledPort = await getFreePort(); - enabledServer = await startServer(enabledPort, { openResponsesEnabled: true }); + const started = await startGatewayServerWithRetries({ + port: await getFreePort(), + opts: { + host: "127.0.0.1", + auth: { mode: "none" }, + controlUiEnabled: false, + openResponsesEnabled: true, + }, + }); + enabledPort = started.port; + enabledServer = started.server; }); afterAll(async () => { - await enabledServer.close({ reason: "openresponses enabled suite done" }); + await enabledServer?.close({ reason: "openresponses enabled suite done" }); }); beforeEach(() => { diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index baf7e9fe5d7..e4b78a9c6bd 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -373,31 +373,37 @@ describe("gateway server agent", () => { expectAgentRoutingCall({ channel: "webchat", deliver: false }); }); - test("agent routes bare /new through session reset before running greeting prompt", async () => { - await writeMainSessionEntry({ sessionId: "sess-main-before-reset" }); - const spy = vi.mocked(agentCommand); - const calls = spy.mock.calls; - const callsBefore = calls.length; - const res = await rpcReq( - ws, - "agent", - { - message: "/new", - sessionKey: "main", - idempotencyKey: "idem-agent-new", - }, - 20_000, - ); - expect(res.ok).toBe(true); + test( + "agent routes bare /new through session reset before running greeting prompt", + { + timeout: 45_000, + }, + async () => { + await writeMainSessionEntry({ sessionId: "sess-main-before-reset" }); + const spy = vi.mocked(agentCommand); + const calls = spy.mock.calls; + const callsBefore = calls.length; + const res = await rpcReq( + ws, + "agent", + { + message: "/new", + sessionKey: "main", + idempotencyKey: "idem-agent-new", + }, + 30_000, + ); + expect(res.ok).toBe(true); - await vi.waitFor(() => expect(calls.length).toBeGreaterThan(callsBefore)); - const call = (calls.at(-1)?.[0] ?? {}) as Record; - expect(call.message).toBeTypeOf("string"); - expect(call.message).toContain("Run your Session Startup sequence"); - expect(call.message).toContain("Current time:"); - expect(typeof call.sessionId).toBe("string"); - expect(call.sessionId).not.toBe("sess-main-before-reset"); - }); + await vi.waitFor(() => expect(calls.length).toBeGreaterThan(callsBefore)); + const call = (calls.at(-1)?.[0] ?? {}) as Record; + expect(call.message).toBeTypeOf("string"); + expect(call.message).toContain("Run your Session Startup sequence"); + expect(call.message).toContain("Current time:"); + expect(typeof call.sessionId).toBe("string"); + expect(call.sessionId).not.toBe("sess-main-before-reset"); + }, + ); test("write-scoped callers cannot reset conversations via agent", async () => { await withGatewayServer(async ({ port }) => { diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index b2ad7ecbdbe..b188306838b 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -607,7 +607,7 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio return server; } -async function startGatewayServerWithRetries(params: { +export async function startGatewayServerWithRetries(params: { port: number; opts?: GatewayServerOptions; }): Promise<{ port: number; server: Awaited> }> { diff --git a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts index cf9b8403518..ebef6f8f125 100644 --- a/src/infra/outbound/delivery-queue.reconnect-drain.test.ts +++ b/src/infra/outbound/delivery-queue.reconnect-drain.test.ts @@ -318,18 +318,27 @@ describe("drainPendingDeliveries for WhatsApp reconnect", () => { } }); - const nowSpy = vi.spyOn(Date, "now"); - nowSpy.mockReturnValueOnce(1_000); - await enqueueDelivery( + const blockerId = await enqueueDelivery( { channel: "demo-channel-a", to: "+1000", payloads: [{ text: "blocker" }] }, tmpDir, ); - nowSpy.mockReturnValueOnce(2_000); - await enqueueDelivery( + const whatsappId = await enqueueDelivery( { channel: "whatsapp", to: "+1555", payloads: [{ text: "hi" }], accountId: "acct1" }, tmpDir, ); - nowSpy.mockRestore(); + const queueDir = path.join(tmpDir, "delivery-queue"); + const blockerPath = path.join(queueDir, `${blockerId}.json`); + const whatsappPath = path.join(queueDir, `${whatsappId}.json`); + const blockerEntry = JSON.parse(fs.readFileSync(blockerPath, "utf-8")) as { + enqueuedAt: number; + }; + const whatsappEntry = JSON.parse(fs.readFileSync(whatsappPath, "utf-8")) as { + enqueuedAt: number; + }; + blockerEntry.enqueuedAt = 1; + whatsappEntry.enqueuedAt = 2; + fs.writeFileSync(blockerPath, JSON.stringify(blockerEntry, null, 2)); + fs.writeFileSync(whatsappPath, JSON.stringify(whatsappEntry, null, 2)); const startupRecovery = recoverPendingDeliveries({ cfg: stubCfg,