diff --git a/src/agents/cli-runner.before-agent-reply-cron.test.ts b/src/agents/cli-runner.before-agent-reply-cron.test.ts index d44158fb2cfc..92d072e95ab1 100644 --- a/src/agents/cli-runner.before-agent-reply-cron.test.ts +++ b/src/agents/cli-runner.before-agent-reply-cron.test.ts @@ -160,6 +160,7 @@ describe("runCliAgent cron before_agent_reply seam", () => { const { runCliAgent } = await import("./cli-runner.js"); hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); runBeforeAgentReplyMock.mockResolvedValue(undefined); + executePreparedCliRunMock.mockResolvedValue({ text: "real reply" }); const onExecutionPhase = vi.fn(); await runCliAgent({ @@ -183,6 +184,19 @@ describe("runCliAgent cron before_agent_reply seam", () => { expect(executePreparedCliRunMock).toHaveBeenCalledTimes(1); }); + it("treats empty CLI subprocess output as a failover failure, not a green cron run", async () => { + const { runCliAgent } = await import("./cli-runner.js"); + executePreparedCliRunMock.mockResolvedValue({ text: " " }); + + await expect(runCliAgent({ ...baseRunParams, trigger: "cron" })).rejects.toMatchObject({ + name: "FailoverError", + reason: "empty_response", + provider: baseRunParams.provider, + model: baseRunParams.model, + sessionId: baseRunParams.sessionId, + }); + }); + it("returns a silent payload when a cron hook claims without a reply body", async () => { const { runCliAgent } = await import("./cli-runner.js"); hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply"); diff --git a/src/agents/cli-runner.context-engine.test.ts b/src/agents/cli-runner.context-engine.test.ts index 09e981f04e06..d42c53a38515 100644 --- a/src/agents/cli-runner.context-engine.test.ts +++ b/src/agents/cli-runner.context-engine.test.ts @@ -426,6 +426,48 @@ describe("runPreparedCliAgent context engine lifecycle", () => { expect(dispose).not.toHaveBeenCalled(); }); + it("does not finalize context-engine turns for empty successful CLI output", async () => { + executePreparedCliRunMock.mockResolvedValue({ + text: " ", + rawText: " ", + sessionId: "external-cli-session-empty", + usage: { input: 11, output: 0, total: 11 }, + }); + const bootstrap = vi.fn>(async () => ({ + bootstrapped: true, + })); + const afterTurn = vi.fn>(async () => {}); + const ingestBatch = vi.fn>(async () => ({ + ingestedCount: 0, + })); + const maintain = vi.fn>(async () => + createMaintenanceResult(), + ); + const dispose = vi.fn(async () => {}); + const contextEngine = createContextEngine({ + bootstrap, + afterTurn, + ingestBatch, + maintain, + dispose, + }); + const { runPreparedCliAgent } = await import("./cli-runner.js"); + + await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toMatchObject({ + name: "FailoverError", + reason: "empty_response", + provider: "claude-cli", + model: "sonnet-4.6", + sessionId: "openclaw-session-1", + }); + + expect(bootstrap).toHaveBeenCalledTimes(1); + expect(afterTurn).not.toHaveBeenCalled(); + expect(ingestBatch).not.toHaveBeenCalled(); + expect(maintain).toHaveBeenCalledTimes(1); + expect(dispose).not.toHaveBeenCalled(); + }); + it("does not dispose context engines when CLI attempts fail", async () => { executePreparedCliRunMock.mockRejectedValue(new Error("cli boom")); const dispose = vi.fn(async () => { diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 0a854efe514c..e32bffd783c1 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -402,6 +402,15 @@ export async function runPreparedCliAgent( const executeCliAttempt = async (cliSessionIdToUse?: string) => { const output = await executePreparedCliRun(context, cliSessionIdToUse); const assistantText = output.text.trim(); + if (!assistantText) { + throw new FailoverError("CLI backend returned an empty response.", { + reason: "empty_response", + provider: params.provider, + model: context.modelId, + sessionId: params.sessionId, + lane: params.lane, + }); + } const assistantTexts = assistantText ? [assistantText] : []; const lastAssistant = assistantText.length > 0 diff --git a/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.test.ts b/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.test.ts index 41c92e3a6ed7..5b143bb8af79 100644 --- a/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.test.ts +++ b/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.test.ts @@ -45,6 +45,20 @@ describe("resolveAuthProfileFailureReason", () => { ).toBeNull(); }); + it("does not persist empty responses as auth-profile health", () => { + expect( + resolveAuthProfileFailureReason({ + failoverReason: "empty_response", + }), + ).toBeNull(); + expect( + resolveAuthProfileFailureReason({ + failoverReason: "empty_response", + policy: "shared", + }), + ).toBeNull(); + }); + it("does not persist request-shape (format) rejections as auth-profile health (#77228)", () => { // A format rejection (e.g. the github-copilot prefill-strict 400 // "conversation must end with a user message" reported in #77228) is diff --git a/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.ts b/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.ts index 06a18f28959f..7f812a150925 100644 --- a/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.ts +++ b/src/agents/pi-embedded-runner/run/auth-profile-failure-policy.ts @@ -6,7 +6,7 @@ export function resolveAuthProfileFailureReason(params: { failoverReason: FailoverReason | null; policy?: AuthProfileFailurePolicy; }): AuthProfileFailureReason | null { - // Helper-local runs, transport/server failures, and request-shape ("format") rejections + // Helper-local runs, transport/server failures, empty responses, and request-shape ("format") rejections // should not poison shared provider auth health. A `format` failure means the // provider rejected the request payload (e.g. an assistant-prefill 400 from a // strict provider when a session transcript ends with a stream-error placeholder @@ -20,6 +20,7 @@ export function resolveAuthProfileFailureReason(params: { !params.failoverReason || params.failoverReason === "timeout" || params.failoverReason === "server_error" || + params.failoverReason === "empty_response" || params.failoverReason === "format" ) { return null;