fix(cron): require verified message delivery target

This commit is contained in:
Ayaan Zaidi
2026-04-21 10:07:05 +05:30
parent 657dcb416b
commit d083702a7b
5 changed files with 96 additions and 13 deletions

View File

@@ -848,6 +848,39 @@ describe("dispatchCronDelivery — double-announce guard", () => {
);
});
it("keeps unresolved message-tool delivery out of delivered status", async () => {
const params = makeBaseParams({ synthesizedText: "hello from cron" });
params.resolvedDelivery = {
ok: false,
channel: undefined,
to: undefined,
accountId: undefined,
threadId: undefined,
mode: "implicit",
error: new Error("sessionKey is required to resolve delivery.channel=last"),
};
params.unverifiedMessagingToolDelivery = true;
const state = await dispatchCronDelivery(params);
expect(deliverOutboundPayloads).not.toHaveBeenCalled();
expect(state.delivered).toBe(false);
expect(state.deliveryAttempted).toBe(false);
expect(state.result).toEqual(
expect.objectContaining({
status: "error",
errorKind: "delivery-target",
deliveryAttempted: false,
}),
);
expect(state.result?.error).toContain(
"sessionKey is required to resolve delivery.channel=last",
);
expect(state.result?.error).toContain(
"the agent used the message tool, but OpenClaw could not verify",
);
});
it("builds outbound session context from the run session key under per-channel-peer scoping", async () => {
vi.mocked(countActiveDescendantRuns).mockReturnValue(0);
vi.mocked(isLikelyInterimCronMessage).mockReturnValue(false);

View File

@@ -110,6 +110,7 @@ type DispatchCronDeliveryParams = {
deliveryRequested: boolean;
skipHeartbeatDelivery: boolean;
skipMessagingToolDelivery?: boolean;
unverifiedMessagingToolDelivery?: boolean;
deliveryBestEffort: boolean;
deliveryPayloadHasStructuredContent: boolean;
deliveryPayloads: ReplyPayload[];
@@ -445,10 +446,14 @@ export async function dispatchCronDelivery(
let delivered = skipMessagingToolDelivery;
let deliveryAttempted = skipMessagingToolDelivery;
let directCronSessionDeleted = false;
const formatDeliveryTargetError = (error: string) =>
params.unverifiedMessagingToolDelivery === true
? `${error}; the agent used the message tool, but OpenClaw could not verify that message matched the cron delivery target`
: error;
const failDeliveryTarget = (error: string) =>
params.withRunSession({
status: "error",
error,
error: formatDeliveryTargetError(error),
errorKind: "delivery-target",
summary,
outputText,

View File

@@ -396,6 +396,41 @@ describe("runCronIsolatedAgentTurn message tool policy", () => {
);
});
it("does not mark message tool delivery as matched when cron target resolution failed", async () => {
mockRunCronFallbackPassthrough();
resolveCronDeliveryPlanMock.mockReturnValue({
requested: true,
mode: "announce",
channel: "last",
});
resolveDeliveryTargetMock.mockResolvedValue({
ok: false,
channel: undefined,
to: undefined,
accountId: undefined,
threadId: undefined,
mode: "implicit",
error: new Error("sessionKey is required to resolve delivery.channel=last"),
});
runEmbeddedPiAgentMock.mockResolvedValue({
payloads: [{ text: "sent" }],
didSendViaMessagingTool: true,
messagingToolSentTargets: [{ tool: "message", provider: "telegram", to: "123" }],
meta: { agentMeta: { usage: { input: 10, output: 20 } } },
});
await runCronIsolatedAgentTurn(makeParams());
expect(dispatchCronDeliveryMock).toHaveBeenCalledTimes(1);
expect(dispatchCronDeliveryMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
deliveryRequested: true,
skipMessagingToolDelivery: false,
unverifiedMessagingToolDelivery: true,
}),
);
});
it("marks no-deliver runs delivered when the message tool sends to the current target", async () => {
mockRunCronFallbackPassthrough();
resolveCronDeliveryPlanMock.mockReturnValue({

View File

@@ -386,15 +386,22 @@ function resetRunOutcomeMocks(): void {
deliveryRequested,
skipHeartbeatDelivery,
skipMessagingToolDelivery,
resolvedDelivery,
}) => ({
result: undefined,
delivered: Boolean(
skipMessagingToolDelivery ||
(deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery),
(deliveryRequested &&
!skipHeartbeatDelivery &&
!skipMessagingToolDelivery &&
resolvedDelivery.ok),
),
deliveryAttempted: Boolean(
skipMessagingToolDelivery ||
(deliveryRequested && !skipHeartbeatDelivery && !skipMessagingToolDelivery),
(deliveryRequested &&
!skipHeartbeatDelivery &&
!skipMessagingToolDelivery &&
resolvedDelivery.ok),
),
summary,
outputText,

View File

@@ -662,17 +662,19 @@ async function finalizeCronRun(params: {
matchesMessagingToolDeliveryTarget,
resolveCronDeliveryBestEffort,
} = await loadCronDeliveryRuntime();
const messagingToolSentTargets = finalRunResult.messagingToolSentTargets ?? [];
const didSendViaMessagingTool =
finalRunResult.didSendViaMessagingTool === true && messagingToolSentTargets.length > 0;
const skipMessagingToolDelivery =
finalRunResult.didSendViaMessagingTool === true &&
(prepared.resolvedDelivery.ok
? (finalRunResult.messagingToolSentTargets ?? []).some((target) =>
matchesMessagingToolDeliveryTarget(target, {
channel: prepared.resolvedDelivery.channel,
to: prepared.resolvedDelivery.to,
accountId: prepared.resolvedDelivery.accountId,
}),
)
: (finalRunResult.messagingToolSentTargets ?? []).length > 0);
didSendViaMessagingTool &&
prepared.resolvedDelivery.ok &&
messagingToolSentTargets.some((target) =>
matchesMessagingToolDeliveryTarget(target, {
channel: prepared.resolvedDelivery.channel,
to: prepared.resolvedDelivery.to,
accountId: prepared.resolvedDelivery.accountId,
}),
);
const deliveryResult = await dispatchCronDelivery({
cfg: prepared.input.cfg,
cfgWithAgentDefaults: prepared.cfgWithAgentDefaults,
@@ -687,6 +689,7 @@ async function finalizeCronRun(params: {
deliveryRequested: prepared.deliveryRequested,
skipHeartbeatDelivery,
skipMessagingToolDelivery,
unverifiedMessagingToolDelivery: didSendViaMessagingTool && !prepared.resolvedDelivery.ok,
deliveryBestEffort: resolveCronDeliveryBestEffort(prepared.input.job),
deliveryPayloadHasStructuredContent,
deliveryPayloads,