Auto-reply: fix non-default agent session transcript path resolution (#15154)
* Auto-reply: fix non-default agent transcript path resolution * Auto-reply: harden non-default agent transcript lookups * Auto-reply: harden session path resolution across agent stores
This commit is contained in:
committed by
GitHub
parent
79a38858ae
commit
ac41176532
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
|
||||
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
|
||||
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
|
||||
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.
|
||||
|
||||
## 2026.2.12
|
||||
|
||||
|
||||
@@ -438,6 +438,7 @@ export function createSessionStatusTool(opts?: {
|
||||
},
|
||||
sessionEntry: resolved.entry,
|
||||
sessionKey: resolved.key,
|
||||
sessionStorePath: storePath,
|
||||
groupActivation,
|
||||
modelAuth: resolveModelAuthLabel({
|
||||
provider: providerForCard,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import { saveSessionStore } from "../config/sessions.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
|
||||
vi.mock("../agents/pi-embedded.js", () => ({
|
||||
@@ -41,7 +43,7 @@ describe("RawBody directive parsing", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("/model, /think, /verbose directives detected from RawBody even when Body has structural wrapper", async () => {
|
||||
@@ -238,4 +240,58 @@ describe("RawBody directive parsing", () => {
|
||||
expect(prompt).not.toContain("/think:high");
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses non-default agent session files without throwing path validation errors", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const agentId = "worker1";
|
||||
const sessionId = "sess-worker-1";
|
||||
const sessionKey = `agent:${agentId}:telegram:12345`;
|
||||
const sessionsDir = path.join(home, ".openclaw", "agents", agentId, "sessions");
|
||||
const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`);
|
||||
const storePath = path.join(sessionsDir, "sessions.json");
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(sessionFile, "", "utf-8");
|
||||
await saveSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
sessionFile,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
agentMeta: { sessionId, provider: "anthropic", model: "claude-opus-4-5" },
|
||||
},
|
||||
});
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "hello",
|
||||
From: "telegram:12345",
|
||||
To: "telegram:12345",
|
||||
SessionKey: sessionKey,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
workspace: path.join(home, "openclaw"),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toBe("ok");
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.sessionFile).toBe(sessionFile);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,40 @@ describe("trigger handling", () => {
|
||||
expect(store[sessionKey]?.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
it("runs /compact for non-default agents without transcript path validation failures", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(compactEmbeddedPiSession).mockClear();
|
||||
vi.mocked(compactEmbeddedPiSession).mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "summary",
|
||||
firstKeptEntryId: "x",
|
||||
tokensBefore: 12000,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/compact",
|
||||
From: "+1004",
|
||||
To: "+2000",
|
||||
SessionKey: "agent:worker1:telegram:12345",
|
||||
CommandAuthorized: true,
|
||||
},
|
||||
{},
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text?.startsWith("⚙️ Compacted")).toBe(true);
|
||||
expect(compactEmbeddedPiSession).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(compactEmbeddedPiSession).mock.calls[0]?.[0]?.sessionFile).toContain(
|
||||
join("agents", "worker1", "sessions"),
|
||||
);
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it("ignores think directives that only appear in the context wrapper", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
isEmbeddedPiRunActive,
|
||||
waitForEmbeddedPiRunEnd,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions.js";
|
||||
import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../../config/sessions.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { formatContextUsageShort, formatTokenCount } from "../status.js";
|
||||
@@ -79,7 +79,14 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
||||
groupChannel: params.sessionEntry.groupChannel,
|
||||
groupSpace: params.sessionEntry.space,
|
||||
spawnedBy: params.sessionEntry.spawnedBy,
|
||||
sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry),
|
||||
sessionFile: resolveSessionFilePath(
|
||||
sessionId,
|
||||
params.sessionEntry,
|
||||
resolveSessionFilePathOptions({
|
||||
agentId: params.agentId,
|
||||
storePath: params.storePath,
|
||||
}),
|
||||
),
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.cfg,
|
||||
skillsSnapshot: params.sessionEntry.skillsSnapshot,
|
||||
|
||||
@@ -106,6 +106,7 @@ export async function buildStatusReply(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey: string;
|
||||
sessionScope?: SessionScope;
|
||||
storePath?: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
contextTokens: number;
|
||||
@@ -124,6 +125,7 @@ export async function buildStatusReply(params: {
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
storePath,
|
||||
provider,
|
||||
model,
|
||||
contextTokens,
|
||||
@@ -225,6 +227,7 @@ export async function buildStatusReply(params: {
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
sessionStorePath: storePath,
|
||||
groupActivation,
|
||||
resolvedThink: resolvedThinkLevel ?? (await resolveDefaultThinkingLevel()),
|
||||
resolvedVerbose: resolvedVerboseLevel,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
resolveGroupSessionKey,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
@@ -316,7 +317,11 @@ export async function runPreparedReply(
|
||||
}
|
||||
}
|
||||
const sessionIdFinal = sessionId ?? crypto.randomUUID();
|
||||
const sessionFile = resolveSessionFilePath(sessionIdFinal, sessionEntry);
|
||||
const sessionFile = resolveSessionFilePath(
|
||||
sessionIdFinal,
|
||||
sessionEntry,
|
||||
resolveSessionFilePathOptions({ agentId, storePath }),
|
||||
);
|
||||
const queueBodyBase = baseBodyForPrompt;
|
||||
const queuedBody = mediaNote
|
||||
? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim()
|
||||
|
||||
@@ -406,6 +406,68 @@ describe("buildStatusMessage", () => {
|
||||
{ prefix: "openclaw-status-" },
|
||||
);
|
||||
});
|
||||
|
||||
it("reads transcript usage for non-default agents", async () => {
|
||||
await withTempHome(
|
||||
async (dir) => {
|
||||
vi.resetModules();
|
||||
const { buildStatusMessage: buildStatusMessageDynamic } = await import("./status.js");
|
||||
|
||||
const sessionId = "sess-worker1";
|
||||
const logPath = path.join(
|
||||
dir,
|
||||
".openclaw",
|
||||
"agents",
|
||||
"worker1",
|
||||
"sessions",
|
||||
`${sessionId}.jsonl`,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
logPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
model: "claude-opus-4-5",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cacheRead: 1000,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 1003,
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const text = buildStatusMessageDynamic({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
contextTokens: 32_000,
|
||||
},
|
||||
sessionEntry: {
|
||||
sessionId,
|
||||
updatedAt: 0,
|
||||
totalTokens: 3,
|
||||
contextTokens: 32_000,
|
||||
},
|
||||
sessionKey: "agent:worker1:telegram:12345",
|
||||
sessionScope: "per-sender",
|
||||
queue: { mode: "collect", depth: 0 },
|
||||
includeTranscriptUsage: true,
|
||||
modelAuth: "api-key",
|
||||
});
|
||||
|
||||
expect(normalizeTestText(text)).toContain("Context: 1.0k/32k");
|
||||
},
|
||||
{ prefix: "openclaw-status-" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCommandsMessage", () => {
|
||||
|
||||
@@ -13,12 +13,14 @@ import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/us
|
||||
import {
|
||||
resolveMainSessionKey,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
type SessionEntry,
|
||||
type SessionScope,
|
||||
} from "../config/sessions.js";
|
||||
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||
import { resolveCommitHash } from "../infra/git-commit.js";
|
||||
import { listPluginCommands } from "../plugins/commands.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
getTtsMaxLength,
|
||||
getTtsProvider,
|
||||
@@ -59,6 +61,7 @@ type StatusArgs = {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
sessionScope?: SessionScope;
|
||||
sessionStorePath?: string;
|
||||
groupActivation?: "mention" | "always";
|
||||
resolvedThink?: ThinkLevel;
|
||||
resolvedVerbose?: VerboseLevel;
|
||||
@@ -165,6 +168,8 @@ const formatQueueDetails = (queue?: QueueStatus) => {
|
||||
const readUsageFromSessionLog = (
|
||||
sessionId?: string,
|
||||
sessionEntry?: SessionEntry,
|
||||
sessionKey?: string,
|
||||
storePath?: string,
|
||||
):
|
||||
| {
|
||||
input: number;
|
||||
@@ -178,7 +183,17 @@ const readUsageFromSessionLog = (
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const logPath = resolveSessionFilePath(sessionId, sessionEntry);
|
||||
let logPath: string;
|
||||
try {
|
||||
const agentId = sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined;
|
||||
logPath = resolveSessionFilePath(
|
||||
sessionId,
|
||||
sessionEntry,
|
||||
resolveSessionFilePathOptions({ agentId, storePath }),
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -333,7 +348,12 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
// Prefer prompt-size tokens from the session transcript when it looks larger
|
||||
// (cached prompt tokens are often missing from agent meta/store).
|
||||
if (args.includeTranscriptUsage) {
|
||||
const logUsage = readUsageFromSessionLog(entry?.sessionId, entry);
|
||||
const logUsage = readUsageFromSessionLog(
|
||||
entry?.sessionId,
|
||||
entry,
|
||||
args.sessionKey,
|
||||
args.sessionStorePath,
|
||||
);
|
||||
if (logUsage) {
|
||||
const candidate = logUsage.promptTokens || logUsage.total;
|
||||
if (!totalTokens || totalTokens === 0 || candidate > totalTokens) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
resolveSessionTranscriptPath,
|
||||
resolveSessionTranscriptPathInDir,
|
||||
resolveStorePath,
|
||||
@@ -75,4 +76,19 @@ describe("session path safety", () => {
|
||||
const resolved = resolveSessionTranscriptPath("sess-1", "main");
|
||||
expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers storePath when resolving session file options", () => {
|
||||
const opts = resolveSessionFilePathOptions({
|
||||
storePath: "/tmp/custom/agent-store/sessions.json",
|
||||
agentId: "ops",
|
||||
});
|
||||
expect(opts).toEqual({
|
||||
sessionsDir: path.resolve("/tmp/custom/agent-store"),
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to agentId when storePath is absent", () => {
|
||||
const opts = resolveSessionFilePathOptions({ agentId: "ops" });
|
||||
expect(opts).toEqual({ agentId: "ops" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,26 @@ export function resolveDefaultSessionStorePath(agentId?: string): string {
|
||||
return path.join(resolveAgentSessionsDir(agentId), "sessions.json");
|
||||
}
|
||||
|
||||
export type SessionFilePathOptions = {
|
||||
agentId?: string;
|
||||
sessionsDir?: string;
|
||||
};
|
||||
|
||||
export function resolveSessionFilePathOptions(params: {
|
||||
agentId?: string;
|
||||
storePath?: string;
|
||||
}): SessionFilePathOptions | undefined {
|
||||
const storePath = params.storePath?.trim();
|
||||
if (storePath) {
|
||||
return { sessionsDir: path.dirname(path.resolve(storePath)) };
|
||||
}
|
||||
const agentId = params.agentId?.trim();
|
||||
if (agentId) {
|
||||
return { agentId };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
|
||||
|
||||
export function validateSessionId(sessionId: string): string {
|
||||
@@ -43,7 +63,7 @@ export function validateSessionId(sessionId: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveSessionsDir(opts?: { agentId?: string; sessionsDir?: string }): string {
|
||||
function resolveSessionsDir(opts?: SessionFilePathOptions): string {
|
||||
const sessionsDir = opts?.sessionsDir?.trim();
|
||||
if (sessionsDir) {
|
||||
return path.resolve(sessionsDir);
|
||||
@@ -95,7 +115,7 @@ export function resolveSessionTranscriptPath(
|
||||
export function resolveSessionFilePath(
|
||||
sessionId: string,
|
||||
entry?: { sessionFile?: string },
|
||||
opts?: { agentId?: string; sessionsDir?: string },
|
||||
opts?: SessionFilePathOptions,
|
||||
): string {
|
||||
const sessionsDir = resolveSessionsDir(opts);
|
||||
const candidate = entry?.sessionFile?.trim();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { SessionEntry, SessionSystemPromptReport } from "../../config/sessions/types.js";
|
||||
import type {
|
||||
CostUsageSummary,
|
||||
@@ -13,7 +12,10 @@ import type {
|
||||
} from "../../infra/session-cost-usage.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions/paths.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
} from "../../config/sessions/paths.js";
|
||||
import { loadProviderUsageSummary } from "../../infra/provider-usage.js";
|
||||
import {
|
||||
loadCostUsageSummary,
|
||||
@@ -334,10 +336,10 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
// Resolve the session file path
|
||||
let sessionFile: string;
|
||||
try {
|
||||
const pathOpts =
|
||||
storePath && storePath !== "(multiple)"
|
||||
? { sessionsDir: path.dirname(storePath) }
|
||||
: { agentId: agentIdFromKey };
|
||||
const pathOpts = resolveSessionFilePathOptions({
|
||||
storePath: storePath !== "(multiple)" ? storePath : undefined,
|
||||
agentId: agentIdFromKey,
|
||||
});
|
||||
sessionFile = resolveSessionFilePath(sessionId, storeEntry, pathOpts);
|
||||
} catch {
|
||||
respond(
|
||||
@@ -778,7 +780,7 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
const sessionId = entry?.sessionId ?? rawSessionId;
|
||||
let sessionFile: string;
|
||||
try {
|
||||
const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
|
||||
const pathOpts = resolveSessionFilePathOptions({ storePath, agentId });
|
||||
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
|
||||
} catch {
|
||||
respond(
|
||||
@@ -830,7 +832,7 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
const sessionId = entry?.sessionId ?? rawSessionId;
|
||||
let sessionFile: string;
|
||||
try {
|
||||
const pathOpts = storePath ? { sessionsDir: path.dirname(storePath) } : { agentId };
|
||||
const pathOpts = resolveSessionFilePathOptions({ storePath, agentId });
|
||||
sessionFile = resolveSessionFilePath(sessionId, entry, pathOpts);
|
||||
} catch {
|
||||
respond(
|
||||
|
||||
@@ -524,6 +524,84 @@ describe("runHeartbeatOnce", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses non-default agent sessionFile from templated stores", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const storeTemplate = path.join(tmpDir, "agents", "{agentId}", "sessions", "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
const agentId = "ops";
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "30m", prompt: "Default prompt" },
|
||||
},
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: agentId,
|
||||
heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" },
|
||||
},
|
||||
],
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storeTemplate },
|
||||
};
|
||||
const sessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||
const storePath = resolveStorePath(storeTemplate, { agentId });
|
||||
const sessionsDir = path.dirname(storePath);
|
||||
const sessionId = "sid-ops";
|
||||
const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`);
|
||||
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(sessionFile, "", "utf-8");
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId,
|
||||
sessionFile,
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([{ text: "Final alert" }]);
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
agentId,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.status).toBe("ran");
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith("+1555", "Final alert", expect.any(Object));
|
||||
expect(replySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ SessionKey: sessionKey }),
|
||||
{ isHeartbeat: true },
|
||||
cfg,
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs heartbeats in the explicit session key when configured", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
|
||||
Reference in New Issue
Block a user