test: stabilize gateway chat and method suites

This commit is contained in:
Peter Steinberger
2026-04-05 08:43:01 +01:00
parent 3635b2b8d6
commit 377ccbcf1d
6 changed files with 162 additions and 44 deletions

View File

@@ -38,10 +38,15 @@ const mocks = vi.hoisted(() => ({
writeFileWithinRoot: vi.fn(async () => {}),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: () => mocks.loadConfigReturn,
writeConfigFile: mocks.writeConfigFile,
}));
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
loadConfig: () => mocks.loadConfigReturn,
writeConfigFile: mocks.writeConfigFile,
};
});
vi.mock("../../commands/agents.config.js", () => ({
applyAgentConfig: mocks.applyAgentConfig,
@@ -71,13 +76,17 @@ vi.mock("../../config/sessions/paths.js", () => ({
resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent,
}));
vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({
vi.mock("../../../extensions/browser/runtime-api.js", () => ({
movePathToTrash: mocks.movePathToTrash,
}));
vi.mock("../../utils.js", () => ({
resolveUserPath: (p: string) => `/resolved${p.startsWith("/") ? "" : "/"}${p}`,
}));
vi.mock("../../utils.js", async () => {
const actual = await vi.importActual<typeof import("../../utils.js")>("../../utils.js");
return {
...actual,
resolveUserPath: (p: string) => `/resolved${p.startsWith("/") ? "" : "/"}${p}`,
};
});
vi.mock("../session-utils.js", () => ({
listAgentsForGateway: mocks.listAgentsForGateway,

View File

@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { GatewayRequestHandlerOptions } from "./types.js";
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(),
loadConfig: vi.fn(() => ({})),
applyPluginAutoEnable: vi.fn(),
listChannelPlugins: vi.fn(),
buildChannelUiCatalog: vi.fn(),
@@ -10,14 +10,14 @@ const mocks = vi.hoisted(() => ({
getChannelActivity: vi.fn(),
}));
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
loadConfig: mocks.loadConfig,
};
});
vi.mock("../../config/config.js", () => ({
loadConfig: mocks.loadConfig,
readConfigFileSnapshot: vi.fn(async () => ({
config: {},
path: "openclaw.config.json",
raw: "{}",
})),
}));
vi.mock("../../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: mocks.applyPluginAutoEnable,

View File

@@ -1,13 +1,14 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import { extractFirstTextBlock } from "../shared/chat-message-content.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectOk,
dispatchInboundMessageMock,
installGatewayTestHooks,
mockGetReplyFromConfigOnce,
onceMessage,
@@ -32,6 +33,10 @@ installConnectedControlUiServerSuite((started) => {
});
describe("gateway server chat", () => {
beforeEach(() => {
dispatchInboundMessageMock.mockReset();
});
const buildNoReplyHistoryFixture = (includeMixedAssistant = false) => [
{
role: "user",
@@ -562,11 +567,39 @@ describe("gateway server chat", () => {
});
test("routes /btw replies through side-result events without transcript injection", async () => {
await withMainSessionStore(async () => {
mockGetReplyFromConfigOnce(async () => ({
text: "323",
btw: { question: "what is 17 * 19?" },
}));
await withMainSessionStore(async (dir) => {
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
`${JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "main thread context" }],
timestamp: Date.now(),
},
})}\n`,
"utf-8",
);
dispatchInboundMessageMock.mockImplementationOnce(
async (params: {
dispatcher: {
sendFinalReply: (payload: { text: string; btw: { question: string } }) => boolean;
markComplete: () => void;
waitForIdle: () => Promise<void>;
getQueuedCounts: () => { final: number; block: number; tool: number };
};
}) => {
params.dispatcher.sendFinalReply({
text: "323",
btw: { question: "what is 17 * 19?" },
});
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal: true,
counts: params.dispatcher.getQueuedCounts(),
};
},
);
const sideResultPromise = onceMessage(
ws,
(o) =>
@@ -593,18 +626,21 @@ describe("gateway server chat", () => {
});
expect(res.ok).toBe(true);
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalled();
});
const sideResult = await sideResultPromise;
const finalEvent = await finalPromise;
expect(sideResult.payload).toMatchObject({
kind: "btw",
runId: "idem-btw-1",
sessionKey: "main",
sessionKey: "agent:main:main",
question: "what is 17 * 19?",
text: "323",
});
expect(finalEvent.payload).toMatchObject({
runId: "idem-btw-1",
sessionKey: "main",
sessionKey: "agent:main:main",
state: "final",
});
@@ -612,23 +648,49 @@ describe("gateway server chat", () => {
sessionKey: "main",
});
expect(historyRes.ok).toBe(true);
expect(historyRes.payload?.messages ?? []).toEqual([]);
const historyTexts = collectHistoryTextValues(historyRes.payload?.messages ?? []);
expect(historyTexts).toEqual(["main thread context"]);
});
});
test("routes block-streamed /btw replies through side-result events", async () => {
await withMainSessionStore(async () => {
mockGetReplyFromConfigOnce(async (_ctx, opts) => {
await opts?.onBlockReply?.({
text: "first chunk",
btw: { question: "what changed?" },
});
await opts?.onBlockReply?.({
text: "second chunk",
btw: { question: "what changed?" },
});
return undefined;
});
await withMainSessionStore(async (dir) => {
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
`${JSON.stringify({
message: {
role: "assistant",
content: [{ type: "text", text: "existing context" }],
timestamp: Date.now(),
},
})}\n`,
"utf-8",
);
dispatchInboundMessageMock.mockImplementationOnce(
async (params: {
dispatcher: {
sendBlockReply: (payload: { text: string; btw: { question: string } }) => boolean;
markComplete: () => void;
waitForIdle: () => Promise<void>;
getQueuedCounts: () => { final: number; block: number; tool: number };
};
}) => {
params.dispatcher.sendBlockReply({
text: "first chunk",
btw: { question: "what changed?" },
});
params.dispatcher.sendBlockReply({
text: "second chunk",
btw: { question: "what changed?" },
});
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal: false,
counts: params.dispatcher.getQueuedCounts(),
};
},
);
const sideResultPromise = onceMessage(
ws,
(o) =>
@@ -646,6 +708,9 @@ describe("gateway server chat", () => {
});
expect(res.ok).toBe(true);
await vi.waitFor(() => {
expect(dispatchInboundMessageMock).toHaveBeenCalled();
});
const sideResult = await sideResultPromise;
expect(sideResult.payload).toMatchObject({
kind: "btw",

View File

@@ -196,6 +196,7 @@ describe("gateway talk.config", () => {
const res = await fetchTalkConfig(ws);
expect(res.ok).toBe(true);
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
provider: "elevenlabs",
voiceId: "voice-123",
apiKey: "__OPENCLAW_REDACTED__",
silenceTimeoutMs: 1500,
@@ -238,6 +239,7 @@ describe("gateway talk.config", () => {
const res = await fetchTalkConfig(ws, { includeSecrets: true });
expect(res.ok).toBe(true);
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
provider: "elevenlabs",
apiKey: "secret-key-abc",
});
});
@@ -263,7 +265,10 @@ describe("gateway talk.config", () => {
provider: "default",
id: "ELEVENLABS_API_KEY",
} satisfies SecretRef;
expectElevenLabsTalkConfig(res.payload?.config?.talk, { apiKey: secretRef });
expectElevenLabsTalkConfig(res.payload?.config?.talk, {
provider: "elevenlabs",
apiKey: secretRef,
});
});
});
});

View File

@@ -36,6 +36,8 @@ type GetReplyFromConfigFn = (
type CronIsolatedRunFn = (...args: unknown[]) => Promise<{ status: string; summary: string }>;
type AgentCommandFn = (...args: unknown[]) => Promise<void>;
type SendWhatsAppFn = (...args: unknown[]) => Promise<{ messageId: string; toJid: string }>;
type RunBtwSideQuestionFn = (...args: unknown[]) => Promise<unknown>;
type DispatchInboundMessageFn = (...args: unknown[]) => Promise<unknown>;
const createStubOutboundAdapter = (channelId: ChannelPlugin["id"]): ChannelOutboundAdapter => ({
deliveryMode: "direct",
@@ -344,6 +346,8 @@ const hoisted = vi.hoisted(() => {
};
cronIsolatedRun: Mock<CronIsolatedRunFn>;
agentCommand: Mock<AgentCommandFn>;
runBtwSideQuestion: Mock<RunBtwSideQuestionFn>;
dispatchInboundMessage: Mock<DispatchInboundMessageFn>;
testIsNixMode: { value: boolean };
sessionStoreSaveDelayMs: { value: number };
embeddedRunMock: {
@@ -392,6 +396,8 @@ const hoisted = vi.hoisted(() => {
},
cronIsolatedRun: vi.fn(async () => ({ status: "ok", summary: "ok" })),
agentCommand: vi.fn().mockResolvedValue(undefined),
runBtwSideQuestion: vi.fn().mockResolvedValue(undefined),
dispatchInboundMessage: vi.fn(),
testIsNixMode: { value: false },
sessionStoreSaveDelayMs: { value: 0 },
embeddedRunMock: {
@@ -457,6 +463,9 @@ export const testTailscaleWhois = hoisted.testTailscaleWhois;
export const piSdkMock = hoisted.piSdkMock;
export const cronIsolatedRun: Mock<CronIsolatedRunFn> = hoisted.cronIsolatedRun;
export const agentCommand: Mock<AgentCommandFn> = hoisted.agentCommand;
export const runBtwSideQuestion: Mock<RunBtwSideQuestionFn> = hoisted.runBtwSideQuestion;
export const dispatchInboundMessageMock: Mock<DispatchInboundMessageFn> =
hoisted.dispatchInboundMessage;
export const getReplyFromConfig: Mock<GetReplyFromConfigFn> = hoisted.getReplyFromConfig;
export const mockGetReplyFromConfigOnce = (impl: GetReplyFromConfigFn) => {
getReplyFromConfig.mockImplementationOnce(impl);
@@ -938,15 +947,41 @@ vi.mock("../commands/agent.js", () => ({
agentCommand,
agentCommandFromIngress: agentCommand,
}));
vi.mock("../agents/btw.js", () => ({
runBtwSideQuestion: (...args: Parameters<RunBtwSideQuestionFn>) =>
hoisted.runBtwSideQuestion(...args),
}));
vi.mock("/src/agents/btw.js", () => ({
runBtwSideQuestion: (...args: Parameters<RunBtwSideQuestionFn>) =>
hoisted.runBtwSideQuestion(...args),
}));
vi.mock("../auto-reply/dispatch.js", async () => {
return await vi.importActual<typeof import("../auto-reply/dispatch.js")>(
const actual = await vi.importActual<typeof import("../auto-reply/dispatch.js")>(
"../auto-reply/dispatch.js",
);
return {
...actual,
dispatchInboundMessage: (...args: Parameters<typeof actual.dispatchInboundMessage>) => {
const impl = hoisted.dispatchInboundMessage.getMockImplementation();
return impl
? hoisted.dispatchInboundMessage(...args)
: actual.dispatchInboundMessage(...args);
},
};
});
vi.mock("/src/auto-reply/dispatch.js", async () => {
return await vi.importActual<typeof import("../auto-reply/dispatch.js")>(
const actual = await vi.importActual<typeof import("../auto-reply/dispatch.js")>(
"../auto-reply/dispatch.js",
);
return {
...actual,
dispatchInboundMessage: (...args: Parameters<typeof actual.dispatchInboundMessage>) => {
const impl = hoisted.dispatchInboundMessage.getMockImplementation();
return impl
? hoisted.dispatchInboundMessage(...args)
: actual.dispatchInboundMessage(...args);
},
};
});
vi.mock("../auto-reply/reply.js", () => ({
getReplyFromConfig: (...args: Parameters<GetReplyFromConfigFn>) =>

View File

@@ -39,9 +39,13 @@ vi.mock("../agents/pi-tools.before-tool-call.js", () => ({
runBeforeToolCallHook,
}));
vi.mock("../plugins/config-state.js", () => ({
isTestDefaultMemorySlotDisabled: disableDefaultMemorySlot,
}));
vi.mock("../plugins/config-state.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/config-state.js")>();
return {
...actual,
isTestDefaultMemorySlotDisabled: disableDefaultMemorySlot,
};
});
vi.mock("../plugins/tools.js", () => ({
getPluginToolMeta: noPluginToolMeta,