Skip to content

Commit 1c5aa95

Browse files
fix(cli): accept empty Claude end turns
1 parent 9d55fc4 commit 1c5aa95

3 files changed

Lines changed: 51 additions & 3 deletions

File tree

src/agents/cli-output.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type CliOutput = {
1818
sessionId?: string;
1919
usage?: CliUsage;
2020
finalPromptText?: string;
21+
allowEmptySuccess?: boolean;
2122
};
2223

2324
export type CliStreamingDelta = {
@@ -369,7 +370,12 @@ function parseClaudeCliJsonlResult(params: {
369370
}
370371
// Claude may finish with an empty result after tool-only work. Keep the
371372
// resolved session handle and usage instead of dropping them.
372-
return { text: "", sessionId: params.sessionId, usage: params.usage };
373+
return {
374+
text: "",
375+
sessionId: params.sessionId,
376+
usage: params.usage,
377+
allowEmptySuccess: true,
378+
};
373379
}
374380
return null;
375381
}

src/agents/cli-runner.reliability.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,14 @@ function buildPreparedContext(params?: {
131131
openClawHistoryPrompt?: string;
132132
provider?: string;
133133
model?: string;
134+
output?: "json" | "jsonl" | "text";
134135
}): PreparedCliRunContext {
135136
const provider = params?.provider ?? "codex-cli";
136137
const model = params?.model ?? "gpt-5.4";
137138
const backend = {
138139
command: "codex",
139140
args: ["exec", "--json"],
140-
output: "text" as const,
141+
output: params?.output ?? ("text" as const),
141142
input: "arg" as const,
142143
modelArg: "--model",
143144
sessionMode: "existing" as const,
@@ -1712,6 +1713,47 @@ describe("runCliAgent reliability", () => {
17121713
expect(hookRunner.runLlmOutput).not.toHaveBeenCalled();
17131714
});
17141715

1716+
it("accepts an empty Claude CLI result as a successful no-reply turn", async () => {
1717+
const hookRunner = {
1718+
hasHooks: vi.fn((hookName: string) => hookName === "llm_output"),
1719+
runLlmInput: vi.fn(async () => undefined),
1720+
runLlmOutput: vi.fn(async () => undefined),
1721+
runAgentEnd: vi.fn(async () => undefined),
1722+
};
1723+
setHookRunnerForTest(hookRunner);
1724+
1725+
supervisorSpawnMock.mockResolvedValueOnce(
1726+
createManagedRun({
1727+
reason: "exit",
1728+
exitCode: 0,
1729+
exitSignal: null,
1730+
durationMs: 50,
1731+
stdout: `${JSON.stringify({
1732+
type: "result",
1733+
subtype: "success",
1734+
is_error: false,
1735+
result: "",
1736+
session_id: "claude-session-1",
1737+
})}\n`,
1738+
stderr: "",
1739+
timedOut: false,
1740+
noOutputTimedOut: false,
1741+
}),
1742+
);
1743+
1744+
const result = await runPreparedCliAgent(
1745+
buildPreparedContext({
1746+
provider: "claude-cli",
1747+
model: "claude-sonnet-4-6",
1748+
output: "jsonl",
1749+
}),
1750+
);
1751+
1752+
expect(result.payloads).toBeUndefined();
1753+
expect(result.meta.executionTrace.fallbackUsed).toBe(false);
1754+
expect(hookRunner.runLlmOutput).not.toHaveBeenCalled();
1755+
});
1756+
17151757
it("emits agent_end with failure details when the CLI run fails", async () => {
17161758
let releaseAgentEnd: () => void = () => undefined;
17171759
const agentEndSettled = new Promise<void>((resolve) => {

src/agents/cli-runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,7 @@ export async function runPreparedCliAgent(
534534
};
535535
const output = await executePreparedCliRun(attemptContext, cliSessionIdToUse);
536536
const assistantText = output.text.trim();
537-
if (!assistantText) {
537+
if (!assistantText && !output.allowEmptySuccess) {
538538
throw new FailoverError("CLI backend returned an empty response.", {
539539
reason: "empty_response",
540540
provider: params.provider,

0 commit comments

Comments
 (0)