Version
OpenClaw 2026.5.28 (claude-cli provider, Node v22.22.1, Linux). Confirmed still present in 2026.5.31-beta.3.
Summary
When a claude-cli turn completes normally (stop_reason: "end_turn") but the final assistant message contains only a thinking block and no text (a valid NO_REPLY), the CLI backend throws FailoverError({ reason: "empty_response" }). Because empty_response is a fallback-triggering reason, the model-fallback chain then re-runs the turn on the configured fallback model (e.g. an OpenRouter model). The fallback model produces a real reply — but on a different model than configured, with different behavior/instruction-following, and a user-visible ↪️ Model Fallback: … banner.
This is the same root trigger as the (closed) #82394 and #83231, but a distinct downstream consequence that those fixes did not cover:
A completed end_turn with no text is a legitimate "nothing to say", not a transient model failure — it should not trigger the fallback chain.
Reproduction signature
Gateway logs around an affected turn:
[agent/cli-backend] claude live session turn: provider=claude-cli model=claude-sonnet-4-6 durationMs=28899 rawLines=43 outBytes=0 outHash=e3b0c44298fc... # sha256 of ""
[model-fallback/decision] model fallback decision: decision=candidate_failed requested=claude-cli/claude-sonnet-4-6 candidate=claude-cli/claude-sonnet-4-6 reason=empty_response next=openrouter/<fallback>
[model-fallback/decision] model fallback decision: decision=candidate_succeeded requested=claude-cli/claude-sonnet-4-6 candidate=openrouter/<fallback>
The claude-cli transcript (~/.claude/projects/<cwd>/<session>.jsonl) for the affected turn shows the final assistant message as:
type=assistant stop_reason=end_turn content blocks=[thinking] text length=0
Clusters on long/heavy reasoning turns (20–45s, dozens–hundreds of stream lines) and under concurrency. Not auth (reason=auth is separate), not rate-limit/overloaded (no such signals), not model-specific (observed on both claude-sonnet-4-6 and claude-opus-4-7).
Code pointers
cli-runner-*.js executeCliAttempt:
const assistantText = output.text.trim();
if (!assistantText) throw new FailoverError("CLI backend returned an empty response.", { reason: "empty_response", ... });
const assistantTexts = assistantText ? [assistantText] : []; // already handles empty
const lastAssistant = assistantText.length > 0 ? buildCliHookAssistantMessage(...) : void 0; // already handles empty
The downstream is already written to tolerate empty text — only this throw forces the failover.
model-fallback-*.js still classifies empty_response as a fallback-eligible reason in 2026.5.31-beta.3 (alongside rate_limit/overloaded/timeout/unknown).
Suggested fix
When the underlying completion has a clean stop reason (end_turn/stop) and no text and no tool call, resolve the turn as a valid empty no-reply instead of throwing empty_response → fallback. Equivalently: exclude empty_response from the fallback-trigger path when stop_reason === "end_turn". Genuine CLI failures (process error, timeout, parse/format error) already throw via separate paths and should keep their failover.
Version
OpenClaw
2026.5.28(claude-cli provider, Node v22.22.1, Linux). Confirmed still present in2026.5.31-beta.3.Summary
When a
claude-cliturn completes normally (stop_reason: "end_turn") but the final assistant message contains only athinkingblock and no text (a valid NO_REPLY), the CLI backend throwsFailoverError({ reason: "empty_response" }). Becauseempty_responseis a fallback-triggering reason, the model-fallback chain then re-runs the turn on the configured fallback model (e.g. an OpenRouter model). The fallback model produces a real reply — but on a different model than configured, with different behavior/instruction-following, and a user-visible↪️ Model Fallback: …banner.This is the same root trigger as the (closed) #82394 and #83231, but a distinct downstream consequence that those fixes did not cover:
finalStatus: success→ user sees the generic "Something went wrong" stub.billingcooldown → profile abort.empty_response→ model-fallback re-run on a different model, yielding a wrong-model reply (not a stub, not a cooldown). See also pi-embedded-runner: empty-reasoning-only completions on openai-codex-responses bypass failover (assistantTexts=[], usage.output>0) #85364 (reasoning-only completions bypass failover) and Thinking-only LLM responses silently dropped (kimi-k2.5-thinking) #64570 (thinking-only silently dropped).A completed
end_turnwith no text is a legitimate "nothing to say", not a transient model failure — it should not trigger the fallback chain.Reproduction signature
Gateway logs around an affected turn:
The claude-cli transcript (
~/.claude/projects/<cwd>/<session>.jsonl) for the affected turn shows the final assistant message as:Clusters on long/heavy reasoning turns (20–45s, dozens–hundreds of stream lines) and under concurrency. Not auth (
reason=authis separate), not rate-limit/overloaded (no such signals), not model-specific (observed on bothclaude-sonnet-4-6andclaude-opus-4-7).Code pointers
cli-runner-*.jsexecuteCliAttempt:model-fallback-*.jsstill classifiesempty_responseas a fallback-eligible reason in2026.5.31-beta.3(alongsiderate_limit/overloaded/timeout/unknown).Suggested fix
When the underlying completion has a clean stop reason (
end_turn/stop) and no text and no tool call, resolve the turn as a valid empty no-reply instead of throwingempty_response→ fallback. Equivalently: excludeempty_responsefrom the fallback-trigger path whenstop_reason === "end_turn". Genuine CLI failures (process error, timeout, parse/format error) already throw via separate paths and should keep their failover.