Skip to content

Commit 9cda583

Browse files
committed
fix empty cli response handling
1 parent 91266fa commit 9cda583

5 files changed

Lines changed: 81 additions & 1 deletion

File tree

src/agents/cli-runner.before-agent-reply-cron.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ describe("runCliAgent cron before_agent_reply seam", () => {
160160
const { runCliAgent } = await import("./cli-runner.js");
161161
hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply");
162162
runBeforeAgentReplyMock.mockResolvedValue(undefined);
163+
executePreparedCliRunMock.mockResolvedValue({ text: "real reply" });
163164
const onExecutionPhase = vi.fn();
164165

165166
await runCliAgent({
@@ -183,6 +184,19 @@ describe("runCliAgent cron before_agent_reply seam", () => {
183184
expect(executePreparedCliRunMock).toHaveBeenCalledTimes(1);
184185
});
185186

187+
it("treats empty CLI subprocess output as a failover failure, not a green cron run", async () => {
188+
const { runCliAgent } = await import("./cli-runner.js");
189+
executePreparedCliRunMock.mockResolvedValue({ text: " " });
190+
191+
await expect(runCliAgent({ ...baseRunParams, trigger: "cron" })).rejects.toMatchObject({
192+
name: "FailoverError",
193+
reason: "empty_response",
194+
provider: baseRunParams.provider,
195+
model: baseRunParams.model,
196+
sessionId: baseRunParams.sessionId,
197+
});
198+
});
199+
186200
it("returns a silent payload when a cron hook claims without a reply body", async () => {
187201
const { runCliAgent } = await import("./cli-runner.js");
188202
hasHooksMock.mockImplementation((hookName) => hookName === "before_agent_reply");

src/agents/cli-runner.context-engine.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,48 @@ describe("runPreparedCliAgent context engine lifecycle", () => {
426426
expect(dispose).not.toHaveBeenCalled();
427427
});
428428

429+
it("does not finalize context-engine turns for empty successful CLI output", async () => {
430+
executePreparedCliRunMock.mockResolvedValue({
431+
text: " ",
432+
rawText: " ",
433+
sessionId: "external-cli-session-empty",
434+
usage: { input: 11, output: 0, total: 11 },
435+
});
436+
const bootstrap = vi.fn<NonNullable<ContextEngine["bootstrap"]>>(async () => ({
437+
bootstrapped: true,
438+
}));
439+
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
440+
const ingestBatch = vi.fn<NonNullable<ContextEngine["ingestBatch"]>>(async () => ({
441+
ingestedCount: 0,
442+
}));
443+
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
444+
createMaintenanceResult(),
445+
);
446+
const dispose = vi.fn(async () => {});
447+
const contextEngine = createContextEngine({
448+
bootstrap,
449+
afterTurn,
450+
ingestBatch,
451+
maintain,
452+
dispose,
453+
});
454+
const { runPreparedCliAgent } = await import("./cli-runner.js");
455+
456+
await expect(runPreparedCliAgent(buildPreparedContext(contextEngine))).rejects.toMatchObject({
457+
name: "FailoverError",
458+
reason: "empty_response",
459+
provider: "claude-cli",
460+
model: "sonnet-4.6",
461+
sessionId: "openclaw-session-1",
462+
});
463+
464+
expect(bootstrap).toHaveBeenCalledTimes(1);
465+
expect(afterTurn).not.toHaveBeenCalled();
466+
expect(ingestBatch).not.toHaveBeenCalled();
467+
expect(maintain).toHaveBeenCalledTimes(1);
468+
expect(dispose).not.toHaveBeenCalled();
469+
});
470+
429471
it("does not dispose context engines when CLI attempts fail", async () => {
430472
executePreparedCliRunMock.mockRejectedValue(new Error("cli boom"));
431473
const dispose = vi.fn(async () => {

src/agents/cli-runner.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,15 @@ export async function runPreparedCliAgent(
402402
const executeCliAttempt = async (cliSessionIdToUse?: string) => {
403403
const output = await executePreparedCliRun(context, cliSessionIdToUse);
404404
const assistantText = output.text.trim();
405+
if (!assistantText) {
406+
throw new FailoverError("CLI backend returned an empty response.", {
407+
reason: "empty_response",
408+
provider: params.provider,
409+
model: context.modelId,
410+
sessionId: params.sessionId,
411+
lane: params.lane,
412+
});
413+
}
405414
const assistantTexts = assistantText ? [assistantText] : [];
406415
const lastAssistant =
407416
assistantText.length > 0

src/agents/pi-embedded-runner/run/auth-profile-failure-policy.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,20 @@ describe("resolveAuthProfileFailureReason", () => {
4545
).toBeNull();
4646
});
4747

48+
it("does not persist empty responses as auth-profile health", () => {
49+
expect(
50+
resolveAuthProfileFailureReason({
51+
failoverReason: "empty_response",
52+
}),
53+
).toBeNull();
54+
expect(
55+
resolveAuthProfileFailureReason({
56+
failoverReason: "empty_response",
57+
policy: "shared",
58+
}),
59+
).toBeNull();
60+
});
61+
4862
it("does not persist request-shape (format) rejections as auth-profile health (#77228)", () => {
4963
// A format rejection (e.g. the github-copilot prefill-strict 400
5064
// "conversation must end with a user message" reported in #77228) is

src/agents/pi-embedded-runner/run/auth-profile-failure-policy.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export function resolveAuthProfileFailureReason(params: {
66
failoverReason: FailoverReason | null;
77
policy?: AuthProfileFailurePolicy;
88
}): AuthProfileFailureReason | null {
9-
// Helper-local runs, transport/server failures, and request-shape ("format") rejections
9+
// Helper-local runs, transport/server failures, empty responses, and request-shape ("format") rejections
1010
// should not poison shared provider auth health. A `format` failure means the
1111
// provider rejected the request payload (e.g. an assistant-prefill 400 from a
1212
// strict provider when a session transcript ends with a stream-error placeholder
@@ -20,6 +20,7 @@ export function resolveAuthProfileFailureReason(params: {
2020
!params.failoverReason ||
2121
params.failoverReason === "timeout" ||
2222
params.failoverReason === "server_error" ||
23+
params.failoverReason === "empty_response" ||
2324
params.failoverReason === "format"
2425
) {
2526
return null;

0 commit comments

Comments
 (0)