diff --git a/qa/scenarios/scheduling/cron-isolated-spawn-only-child-delivery.yaml b/qa/scenarios/scheduling/cron-isolated-spawn-only-child-delivery.yaml new file mode 100644 index 000000000000..0443006a06e3 --- /dev/null +++ b/qa/scenarios/scheduling/cron-isolated-spawn-only-child-delivery.yaml @@ -0,0 +1,194 @@ +title: Isolated cron spawn-only child delivery + +scenario: + id: cron-isolated-spawn-only-child-delivery + surface: cron + coverage: + primary: + - automation.isolated-cron-execution + - agent-runtime.subagent-turns-delivery + secondary: + - agent-runtime.subagent-turns-subagents + - automation.run-history-cron + - channels.qa-channel-final-reply + regressionRefs: + - openclaw/openclaw#117308 + objective: Verify a naturally fired isolated one-shot cron with a spawn-only parent delivers its child's exact reply before retiring. + successCriteria: + - The isolated parent successfully spawns exactly one child and finishes without producing assistant text. + - The child completes successfully and its exact reply reaches the QA channel once. + - Cron history records successful delivery before the one-shot job is retired. + docsRefs: + - docs/help/testing.md + - docs/channels/qa-channel.md + - docs/tools/subagents.md + codeRefs: + - src/cron/isolated-agent/run-finalize.ts + - src/cron/isolated-agent/delivery-dispatch.ts + - src/cron/service/timer-outcomes.ts + - extensions/qa-lab/src/providers/mock-openai/server.ts + execution: + kind: flow + suiteIsolation: isolated + isolationReason: Owns a naturally scheduled one-shot job, subagent lifecycle, and exact QA-channel delivery. + channel: qa-channel + retryCount: 0 + timeoutMs: 180000 + summary: Naturally fire a spawn-only isolated cron and require one exact child delivery before one-shot retirement. + config: + requiredProviderMode: mock-openai + channelId: qa-room + fireDelayMs: 12000 + duplicateWindowMs: 2000 + promptSnippet: Subagent direct fallback QA check + workerSnippet: Subagent direct fallback worker + expectedLabel: qa-direct-fallback-worker + expectedMarker: QA-SUBAGENT-DIRECT-FALLBACK-OK + prompt: "Subagent direct fallback QA check: spawn one native subagent worker. The worker must finish with exactly QA-SUBAGENT-DIRECT-FALLBACK-OK. After the spawn is accepted, finish without assistant text. Empty response exhaustion QA check. Do not use ACP." + +flow: + steps: + - name: naturally fires an isolated spawn-only one-shot job + actions: + - call: waitForGatewayHealthy + args: + - ref: env + - 120000 + - call: waitForQaChannelReady + args: + - ref: env + - 120000 + - call: reset + - set: requestCursorBefore + value: + expr: "(await fetchJson(`${env.mock.baseUrl}/debug/request-cursor`)).cursor" + - set: runStartedAt + value: + expr: "Date.now()" + - set: scheduledFor + value: + expr: "new Date(runStartedAt + config.fireDelayMs).toISOString()" + - call: env.gateway.call + saveAs: addedJob + args: + - cron.add + - agentId: qa + name: + expr: "`qa-cron-spawn-only-${randomUUID()}`" + enabled: true + deleteAfterRun: true + schedule: + kind: at + at: + ref: scheduledFor + sessionTarget: isolated + wakeMode: now + payload: + kind: agentTurn + timeoutSeconds: 90 + toolsAllow: + - sessions_spawn + - read + message: + ref: config.prompt + delivery: + mode: announce + channel: qa-channel + to: + expr: "`channel:${config.channelId}`" + - timeoutMs: 30000 + - set: jobId + value: + expr: "addedJob.id" + - assert: + expr: "Boolean(jobId) && addedJob.sessionTarget === 'isolated' && addedJob.deleteAfterRun === true && JSON.stringify(addedJob.payload?.toolsAllow) === JSON.stringify(['sessions_spawn', 'read'])" + message: + expr: "`expected an isolated delete-after-run cron job restricted to sessions_spawn and read, got ${JSON.stringify(addedJob)}`" + - call: waitForCronRunCompletion + saveAs: completedRun + args: + - callGateway: + expr: "env.gateway.call.bind(env.gateway)" + jobId: + ref: jobId + afterTs: + ref: runStartedAt + timeoutMs: + expr: "liveTurnTimeoutMs(env, config.fireDelayMs + 90000)" + - assert: + expr: "Date.now() >= new Date(scheduledFor).getTime()" + message: one-shot cron completed before its natural scheduled time + - assert: + expr: "completedRun.status === 'ok' && completedRun.delivered === true && completedRun.deliveryStatus === 'delivered'" + message: + expr: "`expected successful delivered spawn-only cron run, got ${JSON.stringify(completedRun)}`" + - assert: + expr: "String(completedRun.summary ?? '').trim() === config.expectedMarker" + message: + expr: "`cron summary did not preserve the child's exact reply: ${JSON.stringify(completedRun)}`" + + - name: proves the parent accepted its child without producing assistant text + actions: + - set: scenarioRequests + value: + expr: "await fetchJson(`${env.mock.baseUrl}/debug/requests?after=${requestCursorBefore}`)" + - set: parentRequests + value: + expr: "scenarioRequests.filter((request) => String(request.prompt ?? '').includes(config.promptSnippet))" + - set: spawnRequests + value: + expr: "parentRequests.filter((request) => request.plannedToolName === 'sessions_spawn')" + - set: terminalRequests + value: + expr: "parentRequests.filter((request) => !request.plannedToolName)" + - assert: + expr: "parentRequests.length === 2 && spawnRequests.length === 1 && terminalRequests.length === 1 && spawnRequests[0].plannedToolArgs?.label === config.expectedLabel && spawnRequests[0].cursor < terminalRequests[0].cursor && /empty response exhaustion qa check/i.test(String(terminalRequests[0].allInputText ?? ''))" + message: + expr: "`expected one child spawn followed by an empty tool-free parent response, saw ${JSON.stringify(parentRequests.map((request) => ({ tool: request.plannedToolName ?? null, label: request.plannedToolArgs?.label ?? null })))}`" + - set: acceptedSpawn + value: + expr: "(() => { let result = JSON.parse(String(terminalRequests[0].toolOutput ?? 'null')); if (result?.status === 'completed') result = result.value; return typeof result === 'string' ? JSON.parse(result) : result; })()" + - assert: + expr: "acceptedSpawn?.status === 'accepted' && Boolean(String(acceptedSpawn.childSessionKey ?? '').trim()) && Boolean(String(acceptedSpawn.runId ?? '').trim())" + message: + expr: "`parent did not receive a successful child-spawn result before its empty response: ${JSON.stringify(acceptedSpawn)}`" + - set: childRequests + value: + expr: "scenarioRequests.filter((request) => String(request.prompt ?? '').includes(config.workerSnippet))" + - assert: + expr: "childRequests.length === 1 && !childRequests[0].plannedToolName" + message: + expr: "`expected one child assistant reply without additional tools, saw ${JSON.stringify(childRequests.map((request) => request.plannedToolName ?? null))}`" + + - name: delivers the exact child reply once and retires the one-shot job + actions: + - call: waitForOutboundMessage + saveAs: deliveredReply + args: + - ref: state + - lambda: + params: [candidate] + expr: "candidate.conversation.id === config.channelId && String(candidate.text ?? '').trim() === config.expectedMarker" + - expr: "liveTurnTimeoutMs(env, 30000)" + - call: sleep + args: + - expr: "config.duplicateWindowMs" + - set: deliveredReplies + value: + expr: "getTransportSnapshot().messages.filter((message) => message.direction === 'outbound' && message.conversation.id === config.channelId && String(message.text ?? '').trim() === config.expectedMarker)" + - assert: + expr: "deliveredReplies.length === 1" + message: + expr: "`expected exactly one child reply on qa-channel, saw ${deliveredReplies.length}: ${JSON.stringify(deliveredReplies.map((message) => message.text))}`" + - call: waitForCondition + saveAs: retiredJobs + args: + - lambda: + async: true + expr: "env.gateway.call('cron.list', { includeDisabled: true }, { timeoutMs: 30000 }).then((page) => page.jobs.some((job) => job.id === jobId) ? undefined : page.jobs)" + - expr: "liveTurnTimeoutMs(env, 30000)" + - 250 + - assert: + expr: "!retiredJobs.some((job) => job.id === jobId)" + message: successful one-shot job was not retired after exact child delivery + detailsExpr: "`child=${config.expectedLabel} accepted=true parentVisibleText=false delivery=${completedRun.deliveryStatus} reply=${deliveredReply.text} outboundCount=${deliveredReplies.length} retired=true`" diff --git a/src/cron/isolated-agent/delivery-dispatch-types.ts b/src/cron/isolated-agent/delivery-dispatch-types.ts index cd2c2f90d806..16832cfcb4e9 100644 --- a/src/cron/isolated-agent/delivery-dispatch-types.ts +++ b/src/cron/isolated-agent/delivery-dispatch-types.ts @@ -27,6 +27,7 @@ export type DispatchCronDeliveryParams = { resolvedDelivery: DeliveryTargetResolution; deliveryRequested: boolean; skipHeartbeatDelivery: boolean; + spawnOnlyHandoff: boolean; sourceDeliveryOutcome: SourceDeliveryOutcome; deliveryBestEffort: boolean; deliveryPayloadHasStructuredContent: boolean; 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 37b1b3320fcc..92c681464198 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -214,6 +214,7 @@ function makeBaseParams(overrides: { runStartedAt?: number; sessionTarget?: string; deliveryBestEffort?: boolean; + spawnOnlyHandoff?: boolean; runSessionKey?: string; resolvedDeliveryMode?: "explicit" | "implicit"; }): Parameters[0] { @@ -245,6 +246,7 @@ function makeBaseParams(overrides: { resolvedDelivery, deliveryRequested: overrides.deliveryRequested ?? true, skipHeartbeatDelivery: false, + spawnOnlyHandoff: overrides.spawnOnlyHandoff ?? false, sourceDeliveryOutcome: { visibleDeliveries: [], verifiedMessageToolDelivery: false, @@ -1101,6 +1103,169 @@ describe("dispatchCronDelivery — double-announce guard", () => { }); }); + it.each([ + { + name: "active direct", + activeDescendants: true, + threadId: undefined, + deliveryBestEffort: false, + }, + { + name: "active threaded", + activeDescendants: true, + threadId: "42", + deliveryBestEffort: false, + }, + { + name: "completed direct", + activeDescendants: false, + threadId: undefined, + deliveryBestEffort: false, + }, + { + name: "completed threaded", + activeDescendants: false, + threadId: "42", + deliveryBestEffort: false, + }, + { + name: "active best-effort direct", + activeDescendants: true, + threadId: undefined, + deliveryBestEffort: true, + }, + ])( + "delivers $name accepted child results without parent text", + async ({ activeDescendants, deliveryBestEffort, threadId }) => { + const childReply = "Completed child result visible to the user."; + if (activeDescendants) { + vi.mocked(countActiveDescendantRuns).mockReturnValueOnce(1).mockReturnValueOnce(0); + } else { + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + } + vi.mocked(waitForDescendantSubagentSummary).mockResolvedValue(undefined); + vi.mocked(readDescendantSubagentFallbackReply).mockResolvedValue(childReply); + + const params = makeBaseParams({ + spawnOnlyHandoff: true, + deliveryBestEffort, + synthesizedText: "", + }); + params.synthesizedText = undefined; + params.deliveryPayloads = []; + params.summary = undefined; + params.outputText = undefined; + params.resolvedDelivery = makeResolvedDelivery({ threadId }); + + const state = await dispatchCronDelivery(params); + + expect(waitForDescendantSubagentSummary).toHaveBeenCalledTimes(activeDescendants ? 1 : 0); + expect(readDescendantSubagentFallbackReply).toHaveBeenCalledWith({ + sessionKey: params.runSessionKey, + runStartedAt: params.runStartedAt, + }); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + expectDeliveryCall(0, { + channel: "telegram", + to: "123456", + ...(threadId === undefined ? {} : { threadId }), + payloads: [{ text: childReply }], + }); + expect(state.delivered).toBe(true); + expect(state.deliveryAttempted).toBe(true); + }, + ); + + it("preserves a substantive parent synthesis after an accepted child has completed", async () => { + const parentReply = "Combined parent summary already includes every child result."; + vi.mocked(countActiveDescendantRuns).mockReturnValue(0); + + const state = await dispatchCronDelivery( + makeBaseParams({ spawnOnlyHandoff: false, synthesizedText: parentReply }), + ); + + expect(readDescendantSubagentFallbackReply).not.toHaveBeenCalled(); + expectDeliveryCall(0, { payloads: [{ text: parentReply }] }); + expect(state.delivered).toBe(true); + }); + + it("immediately delivers a substantive threaded parent while its accepted child runs", async () => { + const parentReply = "Parent summary is ready for the existing thread."; + vi.mocked(countActiveDescendantRuns).mockReturnValue(1); + const params = makeBaseParams({ spawnOnlyHandoff: false, synthesizedText: parentReply }); + params.resolvedDelivery = makeResolvedDelivery({ threadId: "42" }); + + const state = await dispatchCronDelivery(params); + + expect(waitForDescendantSubagentSummary).not.toHaveBeenCalled(); + expect(deliverOutboundPayloads).toHaveBeenCalledTimes(1); + expectDeliveryCall(0, { threadId: "42", payloads: [{ text: parentReply }] }); + expect(state.delivered).toBe(true); + }); + + it.each([ + { + name: "active child times out", + activeDescendants: 1, + error: "cron child-session handoff timed out before producing a final assistant payload", + }, + { + name: "completed child has no output", + activeDescendants: 0, + error: "cron child-session handoff completed without a final assistant payload", + }, + ])("fails an accepted spawn-only handoff when $name", async ({ activeDescendants, error }) => { + vi.mocked(countActiveDescendantRuns).mockReturnValue(activeDescendants); + const params = makeBaseParams({ spawnOnlyHandoff: true, synthesizedText: "" }); + params.synthesizedText = undefined; + params.deliveryPayloads = []; + params.summary = undefined; + params.outputText = undefined; + + const state = await dispatchCronDelivery(params); + + expectResultFields(state.result, { + status: "error", + error, + delivered: false, + deliveryAttempted: true, + }); + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + }); + + it("preserves abort precedence when an accepted child handoff is interrupted", async () => { + const abortReason = "scheduled run aborted while waiting for its child"; + vi.mocked(countActiveDescendantRuns).mockReturnValue(1); + const params = makeBaseParams({ spawnOnlyHandoff: true, synthesizedText: "" }); + params.synthesizedText = undefined; + params.deliveryPayloads = []; + params.summary = undefined; + params.outputText = undefined; + params.isAborted = () => true; + params.abortReason = () => abortReason; + + const state = await dispatchCronDelivery(params); + + expect(waitForDescendantSubagentSummary).toHaveBeenCalledTimes(1); + expectResultFields(state.result, { status: "error", error: abortReason }); + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + }); + + it("keeps an empty no-spawn parent silent", async () => { + const params = makeBaseParams({ synthesizedText: "" }); + params.synthesizedText = undefined; + params.deliveryPayloads = []; + params.summary = undefined; + params.outputText = undefined; + + const state = await dispatchCronDelivery(params); + + expect(waitForDescendantSubagentSummary).not.toHaveBeenCalled(); + expect(readDescendantSubagentFallbackReply).not.toHaveBeenCalled(); + expect(deliverOutboundPayloads).not.toHaveBeenCalled(); + expect(state.deliveryAttempted).toBe(false); + }); + it("uses the run-scoped session key for isolated cron descendant fallback delivery", async () => { const runStartedAt = 1_000; const agentSessionKey = "agent:main:cron:daily-monitor"; diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index b326062dafc7..fd77e654af6f 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -573,10 +573,11 @@ export async function dispatchCronDelivery( const finalizeTextDelivery = async ( delivery: SuccessfulCronDeliveryTarget, ): Promise => { - if (!synthesizedText) { + if (!synthesizedText && !params.spawnOnlyHandoff) { return null; } - const initialSynthesizedText = synthesizedText.trim(); + const initialSynthesizedText = synthesizedText?.trim() ?? ""; + const spawnOnlyHandoff = params.spawnOnlyHandoff; const expectedSubagentFollowup = expectsSubagentFollowup(initialSynthesizedText); const subagentRegistryRuntime = await loadDeliverySubagentRegistryRuntime(); const subagentFollowupSessionKey = params.runSessionKey; @@ -584,7 +585,8 @@ export async function dispatchCronDelivery( subagentFollowupSessionKey, ); const shouldCheckCompletedDescendants = - activeSubagentRuns === 0 && isLikelyInterimCronMessage(initialSynthesizedText); + activeSubagentRuns === 0 && + (spawnOnlyHandoff || isLikelyInterimCronMessage(initialSynthesizedText)); const needsSubagentFollowupRuntime = shouldCheckCompletedDescendants || activeSubagentRuns > 0 || expectedSubagentFollowup; const subagentFollowupRuntime = needsSubagentFollowupRuntime @@ -602,7 +604,10 @@ export async function dispatchCronDelivery( }) : undefined; const hadDescendants = activeSubagentRuns > 0 || Boolean(completedDescendantReply); - if (!params.deliveryBestEffort && (activeSubagentRuns > 0 || expectedSubagentFollowup)) { + if ( + (!params.deliveryBestEffort || spawnOnlyHandoff) && + (activeSubagentRuns > 0 || expectedSubagentFollowup) + ) { let finalReply = await subagentFollowupRuntime?.waitForDescendantSubagentSummary({ sessionKey: subagentFollowupSessionKey, initialReply: initialSynthesizedText, @@ -632,6 +637,23 @@ export async function dispatchCronDelivery( synthesizedText = completedDescendantReply; deliveryPayloads = [{ text: completedDescendantReply }]; } + if (spawnOnlyHandoff && !synthesizedText?.trim()) { + // An accepted spawn is the turn's only completion; retiring it without + // child output permanently loses one-shot scheduled work. + const error = params.isAborted() + ? params.abortReason() + : activeSubagentRuns > 0 + ? "cron child-session handoff timed out before producing a final assistant payload" + : "cron child-session handoff completed without a final assistant payload"; + deliveryAttempted = true; + return params.withRunSession({ + status: "error", + error, + delivered: false, + deliveryAttempted, + ...params.telemetry, + }); + } if (!params.deliveryBestEffort && activeSubagentRuns > 0) { // Parent orchestration is still in progress; avoid announcing a partial // update to the main requester. Mark deliveryAttempted so the timer does @@ -647,7 +669,7 @@ export async function dispatchCronDelivery( } if ( hadDescendants && - synthesizedText.trim() === initialSynthesizedText && + synthesizedText?.trim() === initialSynthesizedText && isLikelyInterimCronMessage(initialSynthesizedText) && !isSilentReplyText(initialSynthesizedText, SILENT_REPLY_TOKEN) ) { @@ -715,7 +737,8 @@ export async function dispatchCronDelivery( // send through the real outbound adapter so delivered=true always reflects // an actual channel send instead of internal announce routing. const useDirectDelivery = - params.deliveryPayloadHasStructuredContent || params.resolvedDelivery.threadId != null; + params.deliveryPayloadHasStructuredContent || + (params.resolvedDelivery.threadId != null && !params.spawnOnlyHandoff); if (useDirectDelivery) { const directResult = await deliverViaDirectAndCleanup(params.resolvedDelivery); if (directResult) { diff --git a/src/cron/isolated-agent/run-finalize.ts b/src/cron/isolated-agent/run-finalize.ts index 46ce36f839aa..394f160a1b68 100644 --- a/src/cron/isolated-agent/run-finalize.ts +++ b/src/cron/isolated-agent/run-finalize.ts @@ -333,9 +333,15 @@ export async function finalizeCronRun(params: { } }; + const acceptedSessionSpawn = hasAcceptedSessionSpawn(finalRunResult.acceptedSessionSpawns); + const spawnOnlyHandoff = + acceptedSessionSpawn && + deliveryPayloads.length === 0 && + normalizeOptionalString(synthesizedText) === undefined; const skipHeartbeatDelivery = prepared.deliveryRequested && !hasFatalErrorPayload && + !spawnOnlyHandoff && isHeartbeatOnlyResponse(deliveryPayloads, resolveHeartbeatAckMaxChars(prepared.agentCfg)); const sourceDeliveryOutcome = resolveSourceDeliveryOutcome(prepared.sourceDelivery, { didSendViaMessageTool: finalRunResult.didSendViaMessagingTool, @@ -356,7 +362,7 @@ export async function finalizeCronRun(params: { const hasCommittedTerminalProgress = hasCommittedMessagingToolDeliveryEvidence(finalRunResult) || finalRunResult.didSendDeterministicApprovalPrompt === true || - hasAcceptedSessionSpawn(finalRunResult.acceptedSessionSpawns) || + acceptedSessionSpawn || (finalRunResult.successfulCronAdds ?? 0) > 0; const hasIntentionalSilentReply = finalRunResult.meta?.terminalReplyKind === "silent-empty" || @@ -432,6 +438,7 @@ export async function finalizeCronRun(params: { resolvedDelivery: prepared.resolvedDelivery, deliveryRequested: prepared.deliveryRequested, skipHeartbeatDelivery, + spawnOnlyHandoff, sourceDeliveryOutcome, deliveryBestEffort: resolveCronDeliveryBestEffort(prepared.input.job), deliveryPayloadHasStructuredContent, @@ -483,6 +490,10 @@ export async function finalizeCronRun(params: { resultWithDeliveryMeta.delivered ?? deliveryResult.delivered, ); if (!hasFatalErrorPayload) { + // Spawn-only turns are incomplete until a child produces output; keeping + // their failure visible prevents a one-shot job from being retired. + const incompleteSpawnOnlyHandoff = + spawnOnlyHandoff && normalizeOptionalString(deliveryResult.synthesizedText) === undefined; // A successful isolated agent turn must keep `status: "ok"` even when the // post-run delivery phase fails. Collapsing the delivery error into the // execution status made the outer scheduled run report `status=error` @@ -492,6 +503,7 @@ export async function finalizeCronRun(params: { if ( deliveryResult.result.status === "error" && deliveryResult.result.errorKind !== "delivery-target" && + !incompleteSpawnOnlyHandoff && !params.isAborted() ) { const failedDeliveryError = resultWithDeliveryMeta.error; 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 57ae4c0d92ae..77cf872f1762 100644 --- a/src/cron/isolated-agent/run.meta-error-status.test.ts +++ b/src/cron/isolated-agent/run.meta-error-status.test.ts @@ -5,6 +5,7 @@ import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js"; import { cleanupDirectCronSessionMock, dispatchCronDeliveryMock, + isHeartbeatOnlyResponseMock, loadRunCronIsolatedAgentTurn, resolveCronDeliveryPlanMock, resolveCronPayloadOutcomeMock, @@ -293,6 +294,7 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => { }); it("does not mark empty accepted child-session handoffs as cron errors", async () => { + isHeartbeatOnlyResponseMock.mockReturnValue(true); runWithModelFallbackMock.mockResolvedValueOnce({ result: { payloads: [], @@ -325,11 +327,158 @@ describe("runCronIsolatedAgentTurn - meta.error status propagation", () => { const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); - expect(dispatchCronDeliveryMock).toHaveBeenCalled(); + expect(dispatchCronDeliveryMock).toHaveBeenCalledWith( + expect.objectContaining({ + spawnOnlyHandoff: true, + skipHeartbeatDelivery: false, + deliveryPayloads: [], + synthesizedText: undefined, + }), + ); expect(result.status).toBe("ok"); expect(result.error).toBeUndefined(); }); + it("preserves incomplete accepted child-session handoffs as cron errors", async () => { + const error = "cron child-session handoff timed out before producing a final assistant payload"; + 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, + }); + dispatchCronDeliveryMock.mockImplementationOnce(({ withRunSession }) => ({ + result: withRunSession({ status: "error", error, deliveryAttempted: true }), + delivered: false, + deliveryAttempted: true, + cronRunSessionCleanupAttempted: false, + summary: undefined, + outputText: undefined, + synthesizedText: undefined, + deliveryPayloads: [], + })); + + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(result.status).toBe("error"); + expect(result.error).toBe(error); + expect(result.delivered).toBe(false); + }); + + it("keeps actual heartbeat acknowledgements silent after an accepted child spawn", async () => { + const heartbeatPayload = { text: "HEARTBEAT_OK" }; + isHeartbeatOnlyResponseMock.mockReturnValue(true); + runWithModelFallbackMock.mockResolvedValueOnce({ + result: { + payloads: [heartbeatPayload], + acceptedSessionSpawns: [{ runId: "run-child", childSessionKey: "agent:default:child" }], + meta: { 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: heartbeatPayload.text, + outputText: heartbeatPayload.text, + synthesizedText: heartbeatPayload.text, + deliveryPayload: heartbeatPayload, + deliveryPayloads: [heartbeatPayload], + deliveryPayloadHasStructuredContent: false, + hasFatalErrorPayload: false, + hasFatalStructuredErrorPayload: false, + embeddedRunError: undefined, + }); + + await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(dispatchCronDeliveryMock).toHaveBeenCalledWith( + expect.objectContaining({ spawnOnlyHandoff: false, skipHeartbeatDelivery: true }), + ); + }); + + it("preserves structured-parent delivery failures after accepting a child", async () => { + const mediaPayload = { mediaUrl: "https://example.invalid/chart.png" }; + const error = "Structured message failed"; + runWithModelFallbackMock.mockResolvedValueOnce({ + result: { + payloads: [mediaPayload], + acceptedSessionSpawns: [{ runId: "run-child", childSessionKey: "agent:default:child" }], + meta: { 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: mediaPayload, + deliveryPayloads: [mediaPayload], + deliveryPayloadHasStructuredContent: true, + hasFatalErrorPayload: false, + hasFatalStructuredErrorPayload: false, + embeddedRunError: undefined, + }); + dispatchCronDeliveryMock.mockImplementationOnce(({ withRunSession }) => ({ + result: withRunSession({ status: "error", error, deliveryAttempted: true }), + delivered: false, + deliveryAttempted: true, + cronRunSessionCleanupAttempted: false, + summary: undefined, + outputText: undefined, + synthesizedText: undefined, + deliveryPayloads: [mediaPayload], + })); + + const result = await runCronIsolatedAgentTurn(makeIsolatedAgentParamsFixture()); + + expect(dispatchCronDeliveryMock).toHaveBeenCalledWith( + expect.objectContaining({ + spawnOnlyHandoff: false, + deliveryPayloadHasStructuredContent: true, + }), + ); + expect(result.status).toBe("ok"); + expect(result.deliveryError).toBe(error); + }); + it("does not mark empty successful cron-add completions as cron errors", async () => { runWithModelFallbackMock.mockResolvedValueOnce({ result: {