Files
openclaw/src/infra/heartbeat-runner.model-override.test.ts
2026-03-22 23:00:18 -07:00

259 lines
7.6 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 * as replyModule from "../auto-reply/reply.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveAgentMainSessionKey, resolveMainSessionKey } from "../config/sessions.js";
import { runHeartbeatOnce } from "./heartbeat-runner.js";
import { seedSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js";
vi.mock("./outbound/deliver.js", () => ({
deliverOutboundPayloads: vi.fn().mockResolvedValue(undefined),
}));
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(() => {});
afterEach(() => {
vi.restoreAllMocks();
});
describe("runHeartbeatOnce heartbeat model override", () => {
async function runHeartbeatWithSeed(params: {
seedSession: (sessionKey: string, input: SeedSessionInput) => Promise<void>;
cfg: OpenClawConfig;
sessionKey: string;
agentId?: string;
}) {
await params.seedSession(params.sessionKey, { lastChannel: "whatsapp", lastTo: "+1555" });
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
await runHeartbeatOnce({
cfg: params.cfg,
agentId: params.agentId,
deps: {
getQueueSize: () => 0,
nowMs: () => 0,
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
return {
ctx: replySpy.mock.calls[0]?.[0],
opts: replySpy.mock.calls[0]?.[1],
replySpy,
};
}
async function runDefaultsHeartbeat(params: {
model?: string;
suppressToolErrorWarnings?: boolean;
lightContext?: boolean;
isolatedSession?: 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,
lightContext: params.lightContext,
isolatedSession: params.isolatedSession,
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
const result = await runHeartbeatWithSeed({
seedSession,
cfg,
sessionKey,
});
return result.opts;
});
}
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 bootstrapContextMode when heartbeat lightContext is enabled", async () => {
const replyOpts = await runDefaultsHeartbeat({ lightContext: true });
expect(replyOpts).toEqual(
expect.objectContaining({
isHeartbeat: true,
bootstrapContextMode: "lightweight",
}),
);
});
it("uses isolated session key when isolatedSession is enabled", async () => {
await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
isolatedSession: true,
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
const result = await runHeartbeatWithSeed({
seedSession,
cfg,
sessionKey,
});
// Isolated heartbeat runs use a dedicated session key with :heartbeat suffix
expect(result.ctx?.SessionKey).toBe(`${sessionKey}:heartbeat`);
});
});
it("uses main session key when isolatedSession is not set", async () => {
await withHeartbeatFixture(async ({ tmpDir, storePath, seedSession }) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveMainSessionKey(cfg);
const result = await runHeartbeatWithSeed({
seedSession,
cfg,
sessionKey,
});
expect(result.ctx?.SessionKey).toBe(sessionKey);
});
});
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-5.4",
},
},
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" });
const result = await runHeartbeatWithSeed({
seedSession,
cfg,
agentId: "ops",
sessionKey,
});
expect(result.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",
}),
);
});
});