fix empty cli response handling

This commit is contained in:
joshavant
2026-05-17 22:52:35 -05:00
committed by Peter Steinberger
parent 3a5627d911
commit 76ce72cbe5
5 changed files with 81 additions and 1 deletions
@@ -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");
@@ -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<NonNullable<ContextEngine["bootstrap"]>>(async () => ({
bootstrapped: true,
}));
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const ingestBatch = vi.fn<NonNullable<ContextEngine["ingestBatch"]>>(async () => ({
ingestedCount: 0,
}));
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(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 () => {
+9
View File
@@ -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
@@ -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
@@ -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;