diff --git a/src/cron/delivery-plan.test.ts b/src/cron/delivery-plan.test.ts index f4875156f93..adad97120d9 100644 --- a/src/cron/delivery-plan.test.ts +++ b/src/cron/delivery-plan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveCronDeliveryPlan } from "./delivery-plan.js"; +import { hasExplicitCronDeliveryTarget, resolveCronDeliveryPlan } from "./delivery-plan.js"; import { makeCronJob } from "./delivery.test-helpers.js"; describe("resolveCronDeliveryPlan", () => { @@ -28,4 +28,18 @@ describe("resolveCronDeliveryPlan", () => { requested: false, }); }); + + it("treats numeric zero thread id as an explicit target", () => { + const plan = resolveCronDeliveryPlan( + makeCronJob({ + delivery: { + mode: "none", + threadId: 0, + }, + }), + ); + + expect(plan.threadId).toBe(0); + expect(hasExplicitCronDeliveryTarget(plan)).toBe(true); + }); }); diff --git a/src/cron/delivery-plan.ts b/src/cron/delivery-plan.ts index f2b5d1e2b1f..f2129c2fa88 100644 --- a/src/cron/delivery-plan.ts +++ b/src/cron/delivery-plan.ts @@ -19,6 +19,12 @@ export type CronDeliveryPlan = { requested: boolean; }; +export function hasExplicitCronDeliveryTarget(plan: CronDeliveryPlan): boolean { + return Boolean( + (plan.channel && plan.channel !== "last") || plan.to || plan.threadId != null || plan.accountId, + ); +} + function normalizeChannel(value: unknown): CronMessageChannel | undefined { const trimmed = normalizeOptionalLowercaseString(value); if (!trimmed) { diff --git a/src/cron/delivery-preview.test.ts b/src/cron/delivery-preview.test.ts index f6f9ee9df8b..b2108538eb0 100644 --- a/src/cron/delivery-preview.test.ts +++ b/src/cron/delivery-preview.test.ts @@ -66,4 +66,78 @@ describe("resolveCronDeliveryPreview", () => { expect(preview).toEqual({ label: "not requested", detail: "not requested" }); expect(mocks.resolveDeliveryTarget).not.toHaveBeenCalled(); }); + + it("previews explicit message-tool targets on no-delivery jobs", async () => { + const job = makeCronJob({ + agentId: "avery", + delivery: { + mode: "none", + channel: "topicchat", + to: "room#42", + threadId: 42, + accountId: "ops", + }, + sessionTarget: "isolated", + }); + + const preview = await resolveCronDeliveryPreview({ + cfg: {} as never, + job, + }); + + expect(mocks.resolveDeliveryTarget).toHaveBeenCalledWith( + {}, + "avery", + { + channel: "topicchat", + to: "room#42", + threadId: 42, + accountId: "ops", + sessionKey: undefined, + }, + { dryRun: true }, + ); + expect(preview).toEqual({ + label: "none -> telegram:direct-123", + detail: "explicit", + }); + }); + + it("does not describe unresolved no-delivery message-tool targets as fail-closed", async () => { + mocks.resolveDeliveryTarget.mockResolvedValueOnce({ + ok: false, + mode: "implicit", + error: new Error("no route"), + }); + const job = makeCronJob({ + agentId: "avery", + delivery: { + mode: "none", + threadId: 0, + }, + sessionTarget: "isolated", + }); + + const preview = await resolveCronDeliveryPreview({ + cfg: {} as never, + job, + }); + + expect(mocks.resolveDeliveryTarget).toHaveBeenCalledWith( + {}, + "avery", + { + channel: "last", + to: undefined, + threadId: 0, + accountId: undefined, + sessionKey: undefined, + }, + { dryRun: true }, + ); + expect(preview).toEqual({ + label: "none -> last", + detail: "message tool target unresolved: no route", + }); + }); }); diff --git a/src/cron/delivery-preview.ts b/src/cron/delivery-preview.ts index 73991fc50b6..09dab6593a1 100644 --- a/src/cron/delivery-preview.ts +++ b/src/cron/delivery-preview.ts @@ -1,6 +1,6 @@ import { resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveCronDeliveryPlan } from "./delivery-plan.js"; +import { hasExplicitCronDeliveryTarget, resolveCronDeliveryPlan } from "./delivery-plan.js"; import { resolveDeliveryTarget } from "./isolated-agent/delivery-target.js"; import { resolveCronDeliverySessionKey } from "./session-target.js"; import type { CronDeliveryPreview, CronJob } from "./types.js"; @@ -40,7 +40,7 @@ export async function resolveCronDeliveryPreview(params: { job: CronJob; }): Promise { const plan = resolveCronDeliveryPlan(params.job); - if (plan.mode === "none") { + if (plan.mode === "none" && !hasExplicitCronDeliveryTarget(plan)) { return { label: "not requested", detail: "not requested" }; } if (plan.mode === "webhook") { @@ -67,12 +67,15 @@ export async function resolveCronDeliveryPreview(params: { if (!resolved.ok) { return { label: `${plan.mode} -> ${formatTarget(requestedChannel, plan.to ?? null)}`, - detail: formatDeliveryDetail({ - requestedChannel, - resolved: false, - sessionKey: deliverySessionKey, - error: resolved.error.message, - }), + detail: + plan.mode === "none" + ? `message tool target unresolved: ${resolved.error.message}` + : formatDeliveryDetail({ + requestedChannel, + resolved: false, + sessionKey: deliverySessionKey, + error: resolved.error.message, + }), }; } return { diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 8ada52cb162..2c34c6c6207 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -232,7 +232,8 @@ vi.mock("../../config/sessions/store.runtime.js", () => ({ updateSessionStore: updateSessionStoreMock, })); -vi.mock("../delivery-plan.js", () => ({ +vi.mock("../delivery-plan.js", async () => ({ + ...(await vi.importActual("../delivery-plan.js")), resolveCronDeliveryPlan: resolveCronDeliveryPlanMock, })); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 9fae6bc0b65..ee383b4170b 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -27,7 +27,11 @@ import { isCommandLaneTaskTimeoutError } from "../../process/command-queue.js"; import { CommandLane } from "../../process/lanes.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; -import { resolveCronDeliveryPlan, type CronDeliveryPlan } from "../delivery-plan.js"; +import { + hasExplicitCronDeliveryTarget, + resolveCronDeliveryPlan, + type CronDeliveryPlan, +} from "../delivery-plan.js"; import { createCronRunDiagnosticsFromAgentResult, createCronRunDiagnosticsFromError, @@ -344,12 +348,6 @@ function canPromptForMessageTool(params: { ); } -function hasExplicitCronDeliveryTarget(plan: CronDeliveryPlan): boolean { - return Boolean( - (plan.channel && plan.channel !== "last") || plan.to || plan.threadId || plan.accountId, - ); -} - async function resolveCronDeliveryContext(params: { cfg: OpenClawConfig; job: CronJob;