Files
moltbot/src/infra/heartbeat-runner.model-override.test.ts

195 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
import { setTelegramRuntime } from "../../extensions/telegram/src/runtime.js";
import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
import { setWhatsAppRuntime } from "../../extensions/whatsapp/src/runtime.js";
import * as replyModule from "../auto-reply/reply.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAgentMainSessionKey, resolveMainSessionKey } from "../config/sessions.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createPluginRuntime } from "../plugins/runtime/index.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { runHeartbeatOnce } from "./heartbeat-runner.js";
import { seedSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js";
// Avoid pulling optional runtime deps during isolated runs.
vi.mock("jiti", () => ({ createJiti: () => () => ({}) }));
type SeedSessionInput = {
lastChannel: string;
lastTo: string;
updatedAt?: number;
};
async function withHeartbeatFixture(
run: (ctx: {
tmpDir: string;
storePath: string;
seedSession: (sessionKey: string, input: SeedSessionInput) => Promise<void>;
}) => Promise<unknown>,
): Promise<unknown> {
return withTempHeartbeatSandbox(
async ({ tmpDir, storePath }) => {
const seedSession = async (sessionKey: string, input: SeedSessionInput) => {
await seedSessionStore(storePath, sessionKey, {
updatedAt: input.updatedAt,
lastChannel: input.lastChannel,
lastProvider: input.lastChannel,
lastTo: input.lastTo,
});
};
return run({ tmpDir, storePath, seedSession });
},
{ prefix: "openclaw-hb-model-" },
);
}
beforeEach(() => {
const runtime = createPluginRuntime();
setTelegramRuntime(runtime);
setWhatsAppRuntime(runtime);
setActivePluginRegistry(
createTestRegistry([
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
]),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("runHeartbeatOnce heartbeat model override", () => {
async function runDefaultsHeartbeat(params: {
model?: string;
suppressToolErrorWarnings?: boolean;
}) {
return withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
model: params.model,
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" });
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
await runHeartbeatOnce({
cfg,
deps: {
getQueueSize: () => 0,
nowMs: () => 0,
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
return replySpy.mock.calls[0]?.[1];
});
}
it("passes heartbeatModelOverride from defaults heartbeat config", async () => {
const replyOpts = await runDefaultsHeartbeat({ model: "ollama/llama3.2:1b" });
expect(replyOpts).toEqual(
expect.objectContaining({
isHeartbeat: true,
heartbeatModelOverride: "ollama/llama3.2:1b",
suppressToolErrorWarnings: false,
}),
);
});
it("passes suppressToolErrorWarnings when configured", async () => {
const replyOpts = await runDefaultsHeartbeat({ suppressToolErrorWarnings: true });
expect(replyOpts).toEqual(
expect.objectContaining({
isHeartbeat: true,
suppressToolErrorWarnings: true,
}),
);
});
it("passes per-agent heartbeat model override (merged with defaults)", async () => {
await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
heartbeat: {
every: "30m",
model: "openai/gpt-4o-mini",
},
},
list: [
{ id: "main", default: true },
{
id: "ops",
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
model: "ollama/llama3.2:1b",
},
},
],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" });
await seedSession(sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" });
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
await runHeartbeatOnce({
cfg,
agentId: "ops",
deps: {
getQueueSize: () => 0,
nowMs: () => 0,
},
});
expect(replySpy).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
isHeartbeat: true,
heartbeatModelOverride: "ollama/llama3.2:1b",
}),
cfg,
);
});
});
it("does not pass heartbeatModelOverride when no heartbeat model is configured", async () => {
const replyOpts = await runDefaultsHeartbeat({ model: undefined });
expect(replyOpts).toEqual(
expect.objectContaining({
isHeartbeat: true,
}),
);
});
it("trims heartbeat model override before passing it downstream", async () => {
const replyOpts = await runDefaultsHeartbeat({ model: " ollama/llama3.2:1b " });
expect(replyOpts).toEqual(
expect.objectContaining({
isHeartbeat: true,
heartbeatModelOverride: "ollama/llama3.2:1b",
}),
);
});
});