diff --git a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts index e3d915cde19..3d66550462b 100644 --- a/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts +++ b/src/cron/isolated-agent.delivers-response-has-heartbeat-ok-but-includes.test.ts @@ -1,106 +1,22 @@ +import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CliDeps } from "../cli/deps.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { CronJob } from "./types.js"; -import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; - -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: vi.fn(), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, -})); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(), -})); -vi.mock("../agents/subagent-announce.js", () => ({ - runSubagentAnnounceFlow: vi.fn(), -})); - import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; +import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; - -let fixtureRoot = ""; -let fixtureCount = 0; - -async function withTempHome(fn: (home: string) => Promise): Promise { - const home = path.join(fixtureRoot, `home-${fixtureCount++}`); - await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true }); - return await fn(home); -} - -async function writeSessionStore(home: string) { - const dir = path.join(home, ".openclaw", "sessions"); - await fs.mkdir(dir, { recursive: true }); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile( - storePath, - JSON.stringify( - { - "agent:main:main": { - sessionId: "main-session", - updatedAt: Date.now(), - lastProvider: "telegram", - lastChannel: "telegram", - lastTo: "123", - }, - }, - null, - 2, - ), - "utf-8", - ); - return storePath; -} - -function makeCfg( - home: string, - storePath: string, - overrides: Partial = {}, -): OpenClawConfig { - const base: OpenClawConfig = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - session: { store: storePath, mainKey: "main" }, - } as OpenClawConfig; - return { ...base, ...overrides }; -} - -function makeJob(payload: CronJob["payload"]): CronJob { - const now = Date.now(); - return { - id: "job-1", - name: "job-1", - enabled: true, - createdAtMs: now, - updatedAtMs: now, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "now", - payload, - state: {}, - }; -} +import { + makeCfg, + makeJob, + withTempCronHome, + writeSessionStore, +} from "./isolated-agent.test-harness.js"; describe("runCronIsolatedAgentTurn", () => { - beforeAll(async () => { - fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-fixtures-")); - }); - - afterAll(async () => { - await fs.rm(fixtureRoot, { recursive: true, force: true }); - }); - beforeEach(() => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); vi.mocked(runEmbeddedPiAgent).mockReset(); @@ -118,8 +34,12 @@ describe("runCronIsolatedAgentTurn", () => { }); it("handles media heartbeat delivery and announce cleanup modes", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { + lastProvider: "telegram", + lastChannel: "telegram", + lastTo: "123", + }); const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn().mockResolvedValue({ diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts new file mode 100644 index 00000000000..2939f2e3bc8 --- /dev/null +++ b/src/cron/isolated-agent.mocks.ts @@ -0,0 +1,15 @@ +import { vi } from "vitest"; + +vi.mock("../agents/pi-embedded.js", () => ({ + abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: vi.fn(), + resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, +})); + +vi.mock("../agents/model-catalog.js", () => ({ + loadModelCatalog: vi.fn(), +})); + +vi.mock("../agents/subagent-announce.js", () => ({ + runSubagentAnnounceFlow: vi.fn(), +})); diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.e2e.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.e2e.test.ts index 6eef3fa3ec0..e0e3e0299fa 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.e2e.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.e2e.test.ts @@ -1,89 +1,66 @@ +import "./isolated-agent.mocks.js"; import fs from "node:fs/promises"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { CliDeps } from "../cli/deps.js"; -import type { OpenClawConfig } from "../config/config.js"; -import type { CronJob } from "./types.js"; -import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; -import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; - -vi.mock("../agents/pi-embedded.js", () => ({ - abortEmbeddedPiRun: vi.fn().mockReturnValue(false), - runEmbeddedPiAgent: vi.fn(), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, -})); -vi.mock("../agents/model-catalog.js", () => ({ - loadModelCatalog: vi.fn(), -})); -vi.mock("../agents/subagent-announce.js", () => ({ - runSubagentAnnounceFlow: vi.fn(), -})); - import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js"; +import { telegramOutbound } from "../channels/plugins/outbound/telegram.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; +import { + makeCfg, + makeJob, + withTempCronHome, + writeSessionStore, +} from "./isolated-agent.test-harness.js"; -async function withTempHome(fn: (home: string) => Promise): Promise { - return withTempHomeBase(fn, { prefix: "openclaw-cron-" }); -} +async function expectBestEffortTelegramNotDelivered( + payload: Record, +): Promise { + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), + sendMessageDiscord: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [payload], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); -async function writeSessionStore(home: string) { - const dir = path.join(home, ".openclaw", "sessions"); - await fs.mkdir(dir, { recursive: true }); - const storePath = path.join(dir, "sessions.json"); - await fs.writeFile( - storePath, - JSON.stringify( - { - "agent:main:main": { - sessionId: "main-session", - updatedAt: Date.now(), - lastProvider: "webchat", - lastTo: "", + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath, { + channels: { telegram: { botToken: "t-1" } }, + }), + deps, + job: { + ...makeJob({ kind: "agentTurn", message: "do it" }), + delivery: { + mode: "announce", + channel: "telegram", + to: "123", + bestEffort: true, }, }, - null, - 2, - ), - "utf-8", - ); - return storePath; -} + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); -function makeCfg( - home: string, - storePath: string, - overrides: Partial = {}, -): OpenClawConfig { - const base: OpenClawConfig = { - agents: { - defaults: { - model: "anthropic/claude-opus-4-5", - workspace: path.join(home, "openclaw"), - }, - }, - session: { store: storePath, mainKey: "main" }, - } as OpenClawConfig; - return { ...base, ...overrides }; -} - -function makeJob(payload: CronJob["payload"]): CronJob { - const now = Date.now(); - return { - id: "job-1", - name: "job-1", - enabled: true, - createdAtMs: now, - updatedAtMs: now, - schedule: { kind: "every", everyMs: 60_000 }, - sessionTarget: "isolated", - wakeMode: "now", - payload, - state: {}, - }; + expect(res.status).toBe("ok"); + expect(res.delivered).toBe(false); + expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); + expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + }); } describe("runCronIsolatedAgentTurn", () => { @@ -103,8 +80,8 @@ describe("runCronIsolatedAgentTurn", () => { }); it("delivers directly when delivery has an explicit target", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), @@ -145,8 +122,8 @@ describe("runCronIsolatedAgentTurn", () => { }); it("delivers the final payload text when delivery has an explicit target", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), @@ -187,8 +164,8 @@ describe("runCronIsolatedAgentTurn", () => { }); it("passes resolved threadId into shared subagent announce flow", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); await fs.writeFile( storePath, JSON.stringify( @@ -247,8 +224,8 @@ describe("runCronIsolatedAgentTurn", () => { }); it("skips announce when messaging tool already sent to target", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), @@ -288,52 +265,15 @@ describe("runCronIsolatedAgentTurn", () => { }); it("reports not-delivered when best-effort structured outbound sends all fail", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "caption", mediaUrl: "https://example.com/img.png" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), - deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { - mode: "announce", - channel: "telegram", - to: "123", - bestEffort: true, - }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", - }); - - expect(res.status).toBe("ok"); - expect(res.delivered).toBe(false); - expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); - expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); + await expectBestEffortTelegramNotDelivered({ + text: "caption", + mediaUrl: "https://example.com/img.png", }); }); it("skips announce for heartbeat-only output", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), @@ -370,8 +310,8 @@ describe("runCronIsolatedAgentTurn", () => { }); it("fails when direct delivery fails and best-effort is disabled", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); + await withTempCronHome(async (home) => { + const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" }); const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), @@ -408,45 +348,6 @@ describe("runCronIsolatedAgentTurn", () => { }); it("ignores direct delivery failures when best-effort is enabled", async () => { - await withTempHome(async (home) => { - const storePath = await writeSessionStore(home); - const deps: CliDeps = { - sendMessageWhatsApp: vi.fn(), - sendMessageTelegram: vi.fn().mockRejectedValue(new Error("boom")), - sendMessageDiscord: vi.fn(), - sendMessageSignal: vi.fn(), - sendMessageIMessage: vi.fn(), - }; - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "hello from cron" }], - meta: { - durationMs: 5, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, - }); - const res = await runCronIsolatedAgentTurn({ - cfg: makeCfg(home, storePath, { - channels: { telegram: { botToken: "t-1" } }, - }), - deps, - job: { - ...makeJob({ kind: "agentTurn", message: "do it" }), - delivery: { - mode: "announce", - channel: "telegram", - to: "123", - bestEffort: true, - }, - }, - message: "do it", - sessionKey: "cron:job-1", - lane: "cron", - }); - - expect(res.status).toBe("ok"); - expect(res.delivered).toBe(false); - expect(runSubagentAnnounceFlow).not.toHaveBeenCalled(); - expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1); - }); + await expectBestEffortTelegramNotDelivered({ text: "hello from cron" }); }); }); diff --git a/src/cron/isolated-agent.test-harness.ts b/src/cron/isolated-agent.test-harness.ts new file mode 100644 index 00000000000..b908c44ef25 --- /dev/null +++ b/src/cron/isolated-agent.test-harness.ts @@ -0,0 +1,67 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import type { CronJob } from "./types.js"; +import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; + +export async function withTempCronHome(fn: (home: string) => Promise): Promise { + return withTempHomeBase(fn, { prefix: "openclaw-cron-" }); +} + +export async function writeSessionStore( + home: string, + session: { lastProvider: string; lastTo: string; lastChannel?: string }, +): Promise { + const dir = path.join(home, ".openclaw", "sessions"); + await fs.mkdir(dir, { recursive: true }); + const storePath = path.join(dir, "sessions.json"); + await fs.writeFile( + storePath, + JSON.stringify( + { + "agent:main:main": { + sessionId: "main-session", + updatedAt: Date.now(), + ...session, + }, + }, + null, + 2, + ), + "utf-8", + ); + return storePath; +} + +export function makeCfg( + home: string, + storePath: string, + overrides: Partial = {}, +): OpenClawConfig { + const base: OpenClawConfig = { + agents: { + defaults: { + model: "anthropic/claude-opus-4-5", + workspace: path.join(home, "openclaw"), + }, + }, + session: { store: storePath, mainKey: "main" }, + } as OpenClawConfig; + return { ...base, ...overrides }; +} + +export function makeJob(payload: CronJob["payload"]): CronJob { + const now = Date.now(); + return { + id: "job-1", + name: "job-1", + enabled: true, + createdAtMs: now, + updatedAtMs: now, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "isolated", + wakeMode: "now", + payload, + state: {}, + }; +}