Version
OpenClaw 2026.6.1 (claude-cli provider, Node v22.22.0, Linux, gitSha 9ce85a0). Manifest: harness [email protected]. Channel: Telegram group, default queue/delivery settings.
Summary
When a claude-cli turn completes normally (stop_reason: "end_turn") and the agent already delivered its reply via a tool call (e.g. the Telegram message tool), the final output.text is empty by design — the side effect is done. The cli-backend then throws FailoverError({ reason: "empty_response" }) at src/agents/cli-runner.ts:457. Because empty_response is a fallback-triggering reason, the model-fallback chain re-runs the same turn on the configured fallback model, which doesn't know the reply already shipped and produces a second, user-visible reply — a true duplicate in the channel.
This is the same root throw as #89008 (closed completed 2026-06-02), but a distinct trigger: in #89008 the assistant turn had only a thinking block; here the turn produced a tool_use block whose tool call already produced the user-visible reply. #89008's fix appears to cover thinking-only end_turn — the empty-then-fallback path is still live in 2026.6.1 for tool-call-then-empty-text turns.
The user-facing symptom is stronger than #89008's: not just a wrong-model reply, but a literal duplicate of the primary model's reply, posted by a different model with different voice/style, in a production group chat.
Reproduction signature
Real gateway log from a Telegram group inbound on 2026-06-07T19:18 EDT, 2026.6.1:
2026-06-07T19:18:40.135-04:00 [agent/cli-backend] claude live session turn: provider=claude-cli model=claude-sonnet-4-6 durationMs=16641 rawLines=57 outBytes=0 outHash=e3b0c44298fc
2026-06-07T19:18:40.175-04:00 [model-fallback/decision] model fallback decision: decision=candidate_failed requested=anthropic/claude-sonnet-4-6 candidate=anthropic/claude-sonnet-4-6 reason=empty_response next=deepseek/deepseek-v4-pro detail=CLI backend returned an empty response.
2026-06-07T19:18:46.776-04:00 [model-fallback/decision] model fallback decision: decision=candidate_succeeded requested=anthropic/claude-sonnet-4-6 candidate=deepseek/deepseek-v4-pro reason=unknown next=none
outHash=e3b0c44298fc = SHA256 of empty string — same fingerprint #89008 reported. End-user impact: two replies posted to the Telegram group, one from Sonnet (via the message tool on the primary turn), one from DeepSeek (via the fallback turn). The primary model's reply was a normal Telegram-tool delivery; the fallback model's reply was a near-paraphrase of the same content, breaking the agent illusion in a 3-user group.
After emptying the agent's fallbacks chain in openclaw.json (workaround applied 2026-06-07T19:31), later identical conditions show next=none and the duplicate disappears — confirming the empty-throw still fires, but with nothing to fall back to:
2026-06-07T19:52:07.417-04:00 [agent/cli-backend] claude live session turn: provider=claude-cli model=claude-sonnet-4-6 durationMs=41248 rawLines=183 outBytes=0 outHash=e3b0c44298fc
2026-06-07T19:52:07.483-04:00 [model-fallback/decision] model fallback decision: decision=candidate_failed requested=anthropic/claude-sonnet-4-6 candidate=anthropic/claude-sonnet-4-6 reason=empty_response next=none detail=CLI backend returned an empty response.
i.e. the primary turn still delivered correctly via the message tool (user saw one reply), but the backend continues to log a spurious empty_response. The post-2026.5.31 fix (#89008) does not cover this case.
Reproduction recipe
- Agent on the
claude-cli backend (any model that calls the message tool reliably in groups, e.g. claude-sonnet-4-6).
- Channel: Telegram group,
requireMention: false, default group delivery (sourceReplyDeliveryMode: "message_tool_only").
- Agent's
model.fallbacks non-empty (e.g. ["deepseek/deepseek-v4-pro"]).
- Send any inbound that prompts a normal reply.
- Observe: model calls the
message tool, Telegram delivers the reply, then 0.05–0.10s later a second reply from the fallback model is posted — exact same trace/session, two models, one inbound.
Suspected root
src/agents/cli-runner.ts:453-464:
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,
});
}
The check is text-only and doesn't consider whether the same turn already produced a tool call (which, for channel-tool deliveries like Telegram's message tool, IS the user-visible side effect). After #89008's fix, thinking-only completions appear to be tolerated, but a completion whose final assistant message contains only tool_use block(s) + no text still hits this throw.
Suggested fix
Treat a clean stop_reason completion with empty text but a non-zero tool-call count as a successful no-text turn, not empty_response. Either:
Workaround in place
agents.defaults.model.fallbacks: [] on the Telegram-facing agent. Strictly worse failure mode if claude-cli has a real outage (silent drop instead of fallback), but at least the production group stops getting double replies. Once upstream is patched, re-add the fallback chain.
Severity
Production message duplication in Telegram group chats. Silent — no error surfaces to the user; both replies look "real."
Cross-reference: closed but related #89008 (thinking-only), closed not-planned #84327 (Opus skipping message tool — separate Opus-side bug we'll file independently).
Version
OpenClaw
2026.6.1(claude-cli provider, Node v22.22.0, Linux, gitSha9ce85a0). Manifest: harness[email protected]. Channel: Telegram group, default queue/delivery settings.Summary
When a
claude-cliturn completes normally (stop_reason: "end_turn") and the agent already delivered its reply via a tool call (e.g. the Telegrammessagetool), the finaloutput.textis empty by design — the side effect is done. The cli-backend then throwsFailoverError({ reason: "empty_response" })atsrc/agents/cli-runner.ts:457. Becauseempty_responseis a fallback-triggering reason, the model-fallback chain re-runs the same turn on the configured fallback model, which doesn't know the reply already shipped and produces a second, user-visible reply — a true duplicate in the channel.This is the same root throw as #89008 (closed completed 2026-06-02), but a distinct trigger: in #89008 the assistant turn had only a
thinkingblock; here the turn produced atool_useblock whose tool call already produced the user-visible reply. #89008's fix appears to cover thinking-only end_turn — the empty-then-fallback path is still live in2026.6.1for tool-call-then-empty-text turns.The user-facing symptom is stronger than #89008's: not just a wrong-model reply, but a literal duplicate of the primary model's reply, posted by a different model with different voice/style, in a production group chat.
Reproduction signature
Real gateway log from a Telegram group inbound on 2026-06-07T19:18 EDT,
2026.6.1:outHash=e3b0c44298fc= SHA256 of empty string — same fingerprint #89008 reported. End-user impact: two replies posted to the Telegram group, one from Sonnet (via the message tool on the primary turn), one from DeepSeek (via the fallback turn). The primary model's reply was a normal Telegram-tool delivery; the fallback model's reply was a near-paraphrase of the same content, breaking the agent illusion in a 3-user group.After emptying the agent's
fallbackschain inopenclaw.json(workaround applied 2026-06-07T19:31), later identical conditions shownext=noneand the duplicate disappears — confirming the empty-throw still fires, but with nothing to fall back to:i.e. the primary turn still delivered correctly via the message tool (user saw one reply), but the backend continues to log a spurious
empty_response. The post-2026.5.31 fix (#89008) does not cover this case.Reproduction recipe
claude-clibackend (any model that calls the message tool reliably in groups, e.g.claude-sonnet-4-6).requireMention: false, default group delivery (sourceReplyDeliveryMode: "message_tool_only").model.fallbacksnon-empty (e.g.["deepseek/deepseek-v4-pro"]).messagetool, Telegram delivers the reply, then 0.05–0.10s later a second reply from the fallback model is posted — exact same trace/session, two models, one inbound.Suspected root
src/agents/cli-runner.ts:453-464:The check is text-only and doesn't consider whether the same turn already produced a tool call (which, for channel-tool deliveries like Telegram's
messagetool, IS the user-visible side effect). After #89008's fix, thinking-only completions appear to be tolerated, but a completion whose final assistant message contains onlytool_useblock(s) + no text still hits this throw.Suggested fix
Treat a clean
stop_reasoncompletion with empty text but a non-zero tool-call count as a successful no-text turn, notempty_response. Either:executePreparedCliRun's return shape and skip the throw whenoutput.toolUseCount > 0(or equivalently when at least one tool was emitted in the turn), orstop_reason ∈ {end_turn, stop, tool_use}and no text, return the empty no-reply rather than throwingempty_response. Genuine CLI failures (process error, parse failure, timeout) already throw via separate paths and keep their failover.Workaround in place
agents.defaults.model.fallbacks: []on the Telegram-facing agent. Strictly worse failure mode if claude-cli has a real outage (silent drop instead of fallback), but at least the production group stops getting double replies. Once upstream is patched, re-add the fallback chain.Severity
Production message duplication in Telegram group chats. Silent — no error surfaces to the user; both replies look "real."
Cross-reference: closed but related #89008 (thinking-only), closed not-planned #84327 (Opus skipping message tool — separate Opus-side bug we'll file independently).