diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index ba6f1e6be1f..3a2c08f7939 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -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); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index b33d91477fa..0bc59d97834 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -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, diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index e0cbdb40959..4f62bc9bd92 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -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({ diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index bfa177a6f0a..31c28312ba9 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -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, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 5288a510b64..ca1d6aaba5f 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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,