From 1af6d99658098ceb2f75f36acf68b92c8eefe635 Mon Sep 17 00:00:00 2001 From: xingzhou Date: Sat, 18 Jul 2026 17:41:31 +0800 Subject: [PATCH] fix: surface cron errors for empty isolated agent replies (#100229) * fix(cron): fail empty isolated agent completions * fix: surface cron errors for empty isolated agent replies * fix(cron): preserve intentional terminal progress --------- Co-authored-by: Peter Steinberger Co-authored-by: Peter Steinberger --- .../run.meta-error-status.test.ts | 274 ++++++++++++++++++ src/cron/isolated-agent/run.ts | 36 +++ 2 files changed, 310 insertions(+) diff --git a/src/cron/isolated-agent/run.meta-error-status.test.ts b/src/cron/isolated-agent/run.meta-error-status.test.ts index c85f5668dc45..57ae4c0d92ae 100644 --- a/src/cron/isolated-agent/run.meta-error-status.test.ts +++ b/src/cron/isolated-agent/run.meta-error-status.test.ts @@ -4,7 +4,10 @@ import { makeIsolatedAgentJobFixture, makeIsolatedAgentParamsFixture } from "./j import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js"; import { cleanupDirectCronSessionMock, + dispatchCronDeliveryMock, loadRunCronIsolatedAgentTurn, + resolveCronDeliveryPlanMock, + resolveCronPayloadOutcomeMock, runWithModelFallbackMock, } from "./run.test-harness.js"; @@ -94,6 +97,277 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => { }); }); + it("marks a completed embedded run with no final payload as a cron error", async () => { + runWithModelFallbackMock.mockResolvedValueOnce({ + result: { + payloads: [], + meta: { + agentMeta: { usage: { input: 10, output: 0 } }, + }, + }, + provider: "anthropic", + model: "claude-opus-4-8", + attempts: [], + }); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: true, + mode: "announce", + channel: "messagechat", + to: "test-target", + }); + resolveCronPayloadOutcomeMock.mockReturnValue({ + summary: undefined, + outputText: undefined, + synthesizedText: undefined, + deliveryPayload: undefined, + deliveryPayloads: [], + deliveryPayloadHasStructuredContent: false, + hasFatalErrorPayload: false, + hasFatalStructuredErrorPayload: false, + embeddedRunError: undefined, + }); + + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(dispatchCronDeliveryMock).not.toHaveBeenCalled(); + expect(result.status).toBe("error"); + expect(result.error).toBe("cron isolated run completed without a final assistant payload"); + expect(result.delivered).toBe(false); + }); + + it("marks empty message-tool attempts without source delivery as cron errors", async () => { + runWithModelFallbackMock.mockResolvedValueOnce({ + result: { + payloads: [], + didSendViaMessagingTool: true, + messagingToolSentTargets: [], + meta: { + agentMeta: { usage: { input: 10, output: 0 } }, + }, + }, + provider: "anthropic", + model: "claude-opus-4-8", + attempts: [], + }); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: true, + mode: "announce", + channel: "messagechat", + to: "test-target", + }); + resolveCronPayloadOutcomeMock.mockReturnValue({ + summary: undefined, + outputText: undefined, + synthesizedText: undefined, + deliveryPayload: undefined, + deliveryPayloads: [], + deliveryPayloadHasStructuredContent: false, + hasFatalErrorPayload: false, + hasFatalStructuredErrorPayload: false, + embeddedRunError: undefined, + }); + + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(dispatchCronDeliveryMock).not.toHaveBeenCalled(); + expect(result.status).toBe("error"); + expect(result.error).toBe("cron isolated run completed without a final assistant payload"); + expect(result.delivered).toBe(false); + }); + + it("keeps explicit silent replies as successful cron completions", async () => { + runWithModelFallbackMock.mockResolvedValueOnce({ + result: { + payloads: [], + meta: { + finalAssistantRawText: "NO_REPLY", + finalAssistantVisibleText: "NO_REPLY", + agentMeta: { usage: { input: 10, output: 1 } }, + }, + }, + provider: "anthropic", + model: "claude-opus-4-8", + attempts: [], + }); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: true, + mode: "announce", + channel: "messagechat", + to: "test-target", + }); + resolveCronPayloadOutcomeMock.mockReturnValue({ + summary: undefined, + outputText: undefined, + synthesizedText: undefined, + deliveryPayload: undefined, + deliveryPayloads: [], + deliveryPayloadHasStructuredContent: false, + hasFatalErrorPayload: false, + hasFatalStructuredErrorPayload: false, + embeddedRunError: undefined, + }); + + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(dispatchCronDeliveryMock).toHaveBeenCalled(); + expect(result.status).toBe("ok"); + expect(result.error).toBeUndefined(); + }); + + it("keeps committed message-tool deliveries as successful cron completions", async () => { + runWithModelFallbackMock.mockResolvedValueOnce({ + result: { + payloads: [], + didSendViaMessagingTool: true, + messagingToolSentTexts: ["Delivered to an intentional recipient"], + messagingToolSentTargets: [], + meta: { + agentMeta: { usage: { input: 10, output: 0 } }, + }, + }, + provider: "anthropic", + model: "claude-opus-4-8", + attempts: [], + }); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: true, + mode: "announce", + channel: "messagechat", + to: "test-target", + }); + resolveCronPayloadOutcomeMock.mockReturnValue({ + summary: undefined, + outputText: undefined, + synthesizedText: undefined, + deliveryPayload: undefined, + deliveryPayloads: [], + deliveryPayloadHasStructuredContent: false, + hasFatalErrorPayload: false, + hasFatalStructuredErrorPayload: false, + embeddedRunError: undefined, + }); + + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(dispatchCronDeliveryMock).toHaveBeenCalled(); + expect(result.status).toBe("ok"); + expect(result.error).toBeUndefined(); + }); + + it("does not mark empty deterministic approval prompts as cron errors", async () => { + runWithModelFallbackMock.mockResolvedValueOnce({ + result: { + payloads: [], + didSendDeterministicApprovalPrompt: true, + meta: { + agentMeta: { usage: { input: 10, output: 0 } }, + }, + }, + provider: "anthropic", + model: "claude-opus-4-8", + attempts: [], + }); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: true, + mode: "announce", + channel: "messagechat", + to: "test-target", + }); + resolveCronPayloadOutcomeMock.mockReturnValue({ + summary: undefined, + outputText: undefined, + synthesizedText: undefined, + deliveryPayload: undefined, + deliveryPayloads: [], + deliveryPayloadHasStructuredContent: false, + hasFatalErrorPayload: false, + hasFatalStructuredErrorPayload: false, + embeddedRunError: undefined, + }); + + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(dispatchCronDeliveryMock).toHaveBeenCalled(); + expect(result.status).toBe("ok"); + expect(result.error).toBeUndefined(); + }); + + it("does not mark empty accepted child-session handoffs as cron errors", async () => { + runWithModelFallbackMock.mockResolvedValueOnce({ + result: { + payloads: [], + acceptedSessionSpawns: [{ runId: "run-child", childSessionKey: "agent:default:child" }], + meta: { + agentMeta: { usage: { input: 10, output: 0 } }, + }, + }, + provider: "anthropic", + model: "claude-opus-4-8", + attempts: [], + }); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: true, + mode: "announce", + channel: "messagechat", + to: "test-target", + }); + resolveCronPayloadOutcomeMock.mockReturnValue({ + summary: undefined, + outputText: undefined, + synthesizedText: undefined, + deliveryPayload: undefined, + deliveryPayloads: [], + deliveryPayloadHasStructuredContent: false, + hasFatalErrorPayload: false, + hasFatalStructuredErrorPayload: false, + embeddedRunError: undefined, + }); + + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(dispatchCronDeliveryMock).toHaveBeenCalled(); + expect(result.status).toBe("ok"); + expect(result.error).toBeUndefined(); + }); + + it("does not mark empty successful cron-add completions as cron errors", async () => { + runWithModelFallbackMock.mockResolvedValueOnce({ + result: { + payloads: [], + successfulCronAdds: 1, + meta: { + agentMeta: { usage: { input: 10, output: 0 } }, + }, + }, + provider: "anthropic", + model: "claude-opus-4-8", + attempts: [], + }); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: true, + mode: "announce", + channel: "messagechat", + to: "test-target", + }); + resolveCronPayloadOutcomeMock.mockReturnValue({ + summary: undefined, + outputText: undefined, + synthesizedText: undefined, + deliveryPayload: undefined, + deliveryPayloads: [], + deliveryPayloadHasStructuredContent: false, + hasFatalErrorPayload: false, + hasFatalStructuredErrorPayload: false, + embeddedRunError: undefined, + }); + + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(dispatchCronDeliveryMock).toHaveBeenCalled(); + expect(result.status).toBe("ok"); + expect(result.error).toBeUndefined(); + }); + it("surfaces cron timeout result when the cron-nested lane watchdog fires", async () => { runWithModelFallbackMock.mockRejectedValueOnce( makeCommandLaneTaskTimeoutError("cron-nested", 330_000), diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 7d556884a382..e35d087f56e2 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -1,7 +1,9 @@ import { isDeepStrictEqual } from "node:util"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { hasAcceptedSessionSpawn } from "../../agents/accepted-session-spawn.js"; import { retireSessionMcpRuntime } from "../../agents/agent-bundle-mcp-tools.js"; import { hasAnyAuthProfileStoreSource } from "../../agents/auth-profiles/source-check.js"; +import { hasCommittedMessagingToolDeliveryEvidence } from "../../agents/embedded-agent-runner/delivery-evidence.js"; import { findModelInCatalog } from "../../agents/model-catalog-lookup.js"; import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js"; @@ -9,6 +11,7 @@ import { createAgentRunRestartAbortError } from "../../agents/run-termination.js import { expandToolGroups, normalizeToolName } from "../../agents/tool-policy.js"; import { deriveContextPromptTokens } from "../../agents/usage.js"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; +import { isSilentReplyPayloadText } from "../../auto-reply/tokens.js"; import type { CliDeps } from "../../cli/outbound-send-deps.js"; import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -1442,6 +1445,39 @@ async function finalizeCronRun(params: { sourceDeliveryOutcome, }); } + const hasCommittedTerminalProgress = + hasCommittedMessagingToolDeliveryEvidence(finalRunResult) || + finalRunResult.didSendDeterministicApprovalPrompt === true || + hasAcceptedSessionSpawn(finalRunResult.acceptedSessionSpawns) || + (finalRunResult.successfulCronAdds ?? 0) > 0; + const hasIntentionalSilentReply = + finalRunResult.meta?.terminalReplyKind === "silent-empty" || + isSilentReplyPayloadText(finalRunResult.meta?.finalAssistantRawText) || + isSilentReplyPayloadText(finalRunResult.meta?.finalAssistantVisibleText); + if ( + prepared.deliveryRequested && + !hasFatalErrorPayload && + !sourceDeliveryOutcome.satisfiesSourceDelivery && + !hasCommittedTerminalProgress && + !hasIntentionalSilentReply && + deliveryPayloads.length === 0 && + normalizeOptionalString(synthesizedText) === undefined + ) { + const error = "cron isolated run completed without a final assistant payload"; + return prepared.withRunSession({ + status: "error", + error, + summary: error, + outputText: error, + delivered: false, + deliveryAttempted: false, + diagnostics: mergeCronRunDiagnostics( + runDiagnostics, + createCronRunDiagnosticsFromError("agent-run", error), + ), + ...telemetry, + }); + } if (hasFatalStructuredErrorPayload && prepared.deliveryRequested) { // Structured run error payloads belong in cron state and failure alerts, // not the normal completion announce path where provider JSON can leak.