AutoGPT is the vision of accessible AI for everyone, to use and to build on. Our mission is to provide the tools, so that you can focus on what matters.
fix(backend/copilot): close empty-completion gaps + add silence heartbeat (#13056)
## Why
Three Discord reports in `#While testing the new payment, billing`
describe AutoPilot turns that "just stop":
- **John (May 6, dev)** session `07da6f3e-a183-4315-8b5e-a1c99f3b3d29`
was root-caused as SECRT-2333 and patched in #13052 (orphan
`browser_act` tool_use blocks on Kimi K2.6 + SSE drop).
- **Toran (May 6, prod)** screenshot shows an expanded `Reasoning ^`
panel inline with no answer text, no fold. SessionId not shared yet, but
the FE shape matches a turn that finished thinking-only without ever
emitting text.
- **Ubbe** intermittent — sometimes a refresh reveals "Credits limit
reached".
Investigation surfaced gaps that #13052 explicitly does not close:
- **SDK**: `_should_surface_empty_completion` covers ghost-success and
orphan-tool cases, but not `has_started_reasoning AND not
has_started_text AND not _any_tool_results_seen AND subtype="success"` —
the genuine thinking-only-no-tools path. Falls through to plain
`StreamFinish`, and the FE's `splitReasoningAndResponse` then renders
the reasoning inline (no fold), visually identical to in-progress
streaming.
- **Baseline path** (`baseline/service.py`): only emits silent-finish
fallback when `iterations >= agent_max_turns` (line 1996-2012). Natural
finish with empty terminal text has no equivalent.
- **Silence between events**: `StreamStatus` is emitted at boundaries
("Contacting the model…", "Analyzing result…") but never *during* long
inter-event silence. The FE rotates through generic phrases for up to
several minutes before the next chunk lands.
## What
Single PR with three tightly coupled changes:
1. **SDK thinking-only-no-tools guard** — extend
`_should_surface_empty_completion` in `sdk/response_adapter.py` to fire
when the model emitted only `ThinkingBlock` content and ended
`subtype="success"` with no tools. Surfaces as the same
`StreamError(code="empty_completion") + StreamFinish` pair as the
existing two cases.
2. **Baseline empty-finish fallback** — add
`_NATURAL_FINISH_EMPTY_FALLBACK_TEXT` +
`_natural_finish_empty_notice_text` +
`_build_natural_finish_empty_fallback_events` mirroring the
budget-exhausted helpers, plus a new branch in the streaming loop
covering `loop_result.finished_naturally AND terminal_round_text == ""`.
3. **Silence watchdog** — new `backend/copilot/stream_heartbeat.py` with
`SilenceWatchdog` async context manager + `wrap_stream_with_heartbeat`
generator. Integrated in `executor/processor.py` between the raw driver
stream and `stream_registry.stream_and_publish`, so both SDK and
baseline paths get the heartbeat. Default schedule:
| Threshold | Message |
|-----------|---------|
| 10s | Working on it… |
| 30s | Still working — complex requests can take a moment… |
| 60s | This is taking longer than usual… |
The watchdog re-arms after every non-status event and self-suppresses
for 5s after any driver-emitted `StreamStatus` so it doesn't talk over
messages like "Optimizing conversation context…".
## How
### Files changed
- `backend/copilot/sdk/response_adapter.py` — third branch in
`_should_surface_empty_completion`.
- `backend/copilot/sdk/response_adapter_test.py` — 2 new tests (positive
+ regression).
- `backend/copilot/baseline/service.py` — new constant, helpers, and
silent-finish branch.
- `backend/copilot/baseline/service_unit_test.py` — 6 new tests.
- `backend/copilot/stream_heartbeat.py` (new) — `SilenceWatchdog`,
`wrap_stream_with_heartbeat`.
- `backend/copilot/stream_heartbeat_test.py` (new) — 10 tests covering
threshold firing, bump reset, escalation order, one-per-gap dedupe,
suppression, cleanup, error tolerance, wrapper passthrough, wrapper
interleaving, wrapper suppression.
- `backend/copilot/executor/processor.py` — wires the watchdog around
the driver stream.
### Out of scope
- Underlying `browser_act` "tool never executed" bug — separate
tool-execution layer concern, per #13052 PR description.
- Auto-retry on `code="empty_completion"` — flagged in #5d1cdc2bad as
future follow-up.
- Mid-stream credit-balance check / surfacing — orthogonal billing
concern.
- FE rendering tweak so finalized-no-text-after-reasoning visually
distinct from streaming — depends on UX agreement (cancel button, retry
CTA, error card).
- Per-tool progress events (Approach C from the design discussion) —
heavier change, deferred.
## Test plan
```
poetry run pytest backend/copilot/sdk/response_adapter_test.py # 66 pass (incl. 2 new)
poetry run pytest backend/copilot/baseline/service_unit_test.py::TestNaturalFinishEmptyNoticeText \
backend/copilot/baseline/service_unit_test.py::TestBuildNaturalFinishEmptyFallbackEvents \
backend/copilot/baseline/service_unit_test.py::TestBudgetExhaustedNoticeText \
backend/copilot/baseline/service_unit_test.py::TestBuildBudgetExhaustedFallbackEvents
# 14 pass (incl. 6 new)
poetry run pytest backend/copilot/stream_heartbeat_test.py # 10 pass (new)
poetry run pytest backend/copilot/sdk/service_test.py::TestStreamEndedWithoutResultMessage
# 3 pass (regression)
poetry run black --check + poetry run ruff check # clean
```
- [ ] Manual QA on dev: trigger a thinking-only turn (extended_thinking
model, simple yes/no question that the model answers in thinking) and
confirm the FE now shows an error card instead of a stuck reasoning
panel
- [ ] Manual QA on dev: trigger a long tool call (e.g. video gen) and
confirm escalating heartbeat messages appear at ~10s/30s/60s
- [ ] Verify Toran's session if/when sessionId is shared — does this PR
cover his shape too?
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: majdyz <zamil.majdy@agpt.co> U
Ubbe committed
dce0cd9a4f2357cb5ffc75a8199b310d9869cf17
Parent: 6dc836a
Committed by GitHub <noreply@github.com>
on 5/12/2026, 4:44:27 PM