fix(tui): surface thinking-only turns instead of silently ending#1727
fix(tui): surface thinking-only turns instead of silently ending#1727gsrunion wants to merge 1 commit into
Conversation
When a model returns a response with `reasoning_content` populated but empty `content` and no `tool_calls` (notably gpt-oss via ollama's harmony→OpenAI shim), the engine built an assistant message containing only a `Thinking` block, failed the `has_sendable_assistant_content` check, persisted nothing, and ended the turn — leaving the UI silently idle with no event to clear the spinner or explain why. Add an `else if` branch that emits a `status` event in that case, guarded by `turn_error.is_none()` and `!cancel_token.is_cancelled()` so the notice doesn't pile on when an error or cancellation has already been surfaced. No assistant message is persisted, matching prior behavior (some providers reject thinking-only assistant turns on the next request). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request adds a check to the TUI engine's turn loop to handle scenarios where a model returns reasoning content but no final answer or tool calls, ensuring the user is notified instead of the UI remaining idle. The reviewer suggested improving the accuracy of the notification by checking for pending steers and distinguishing between empty responses and reasoning-only responses.
| } else if turn_error.is_none() && !self.cancel_token.is_cancelled() { | ||
| // gpt-oss via ollama's harmony→OpenAI shim can return | ||
| // `reasoning_content` with empty `content` and no | ||
| // `tool_calls`, which would otherwise leave the UI silently | ||
| // idle while the engine quietly ends the turn. | ||
| let notice = "Model returned reasoning but no answer or tool call; \ | ||
| turn ended without output. Send a follow-up to retry."; | ||
| crate::logging::warn(notice); | ||
| let _ = self.tx_event.send(Event::status(notice.to_string())).await; | ||
| } |
There was a problem hiding this comment.
The notice "turn ended without output" might be premature if there are pending steers or running sub-agents that will keep the turn alive. In such cases, the engine will proceed to the next step rather than finishing the turn, making the "ended" message confusing to the user.
Additionally, while this fix targets models returning reasoning without content, it would be more accurate to distinguish between a response that contains only reasoning and a truly empty response (where current_thinking is also empty).
| } else if turn_error.is_none() && !self.cancel_token.is_cancelled() { | |
| // gpt-oss via ollama's harmony→OpenAI shim can return | |
| // `reasoning_content` with empty `content` and no | |
| // `tool_calls`, which would otherwise leave the UI silently | |
| // idle while the engine quietly ends the turn. | |
| let notice = "Model returned reasoning but no answer or tool call; \ | |
| turn ended without output. Send a follow-up to retry."; | |
| crate::logging::warn(notice); | |
| let _ = self.tx_event.send(Event::status(notice.to_string())).await; | |
| } | |
| } else if turn_error.is_none() && !self.cancel_token.is_cancelled() && pending_steers.is_empty() { | |
| // gpt-oss via ollama's harmony→OpenAI shim can return | |
| // `reasoning_content` with empty `content` and no | |
| // `tool_calls`, which would otherwise leave the UI silently | |
| // idle while the engine quietly ends the turn. | |
| let notice = if current_thinking.trim().is_empty() { | |
| "Model returned an empty response; turn ended without output. Send a follow-up to retry." | |
| } else { | |
| "Model returned reasoning but no answer or tool call; turn ended without output. Send a follow-up to retry." | |
| }; | |
| crate::logging::warn(notice); | |
| let _ = self.tx_event.send(Event::status(notice.to_string())).await; | |
| } |
|
Update: installed |
Bump workspace, inter-crate, and npm package versions 0.8.38 -> 0.8.39. Roll CHANGELOG [Unreleased] into [0.8.39] with all fixes: - Revert v0.8.38 /model picker rework (back to instant curated picker) - Restore approval grouping (lossy v0.8.37 logic for approvals, exact key for denials) - Thinking-only turn surface fix (#1727) - ACP server JSON-RPC id stringification (#1696) - Chat client: reasoning_content for generic providers (#1673) - Compaction: user text query preservation (#1704) - Engine: system prompt override survival (#1688) - Pager: G/End overshoot fix (#1706), mouse scroll (#1716) - Composer: scroll with text (#1677), multiline arrows (#1721) - macOS system theme detection (#1670) - rlm_open blank source fields (#1712) - Terminal resize paging fix (#1724) - Docker first-run permission (#1684) - README Rust 1.88+ requirement note (#1718) Tests: 3149 passed, 0 failed (deepseek-tui crate) clippy: clean on --all-targets --all-features
|
Keeping this in the v0.8.40 milestone as a candidate, but not merging it on narrative alone. Before it lands we need fixture coverage for a thinking-only/no final-answer stream, plus a check that normal text/tool-use turns and cancellation/error paths do not double-surface status. The report is useful; the next step is to make the repro deterministic. |
…own#1742) Address gemini-code-assist review on PR Hmbown#1742 (MEDIUM): the status was emitted at the assistant-persist site, but the same turn can still CONTINUE for pending steers or sub-agent completions -- the user would see a spurious 'turn ended without output' notice immediately before the turn resumed. Capture thinking_only_no_sendable at the persist site (no emission there) and decide at the end of the tool_uses.is_empty() path, just before the terminal break -- reachable only when there were no pending steers, no sub-agent completions, and we were not holding for running children. Extend should_emit_thinking_only_status with steers_pending and holding_for_subagents (false if either), recomputed live at the decision point as defense-in-depth. Unit test updated with the two new no-emit cases. Refs Hmbown#1727.
…own#1727) When a model streams a turn with only a reasoning block (empty content, no tool_calls -- e.g. gpt-oss via ollama's harmony->OpenAI shim mapping to reasoning_content), has_sendable_assistant_content is false: the if-only persist branch was skipped, NO event was emitted, and the turn fell through to break. The UI spinner hung with no reply and no error. Add an else-if at the same persist site that, only on a clean end (tool_uses empty, turn_error.is_none(), not cancelled), warns and emits an Event::status notice telling the user the turn ended and they can retry. No assistant message is persisted (prior behavior preserved); no retry/re-prompt/placeholder policy is added (out of scope). Guard ordering extracted into a pure should_emit_thinking_only_status() helper with a unit test, matching the existing should_hold_turn_for_subagents style. Maintainer audit Hmbown#1736 confirms Hmbown#1727 is not shipped in v0.8.39 and release-blocking. Refs Hmbown#1727, Hmbown#1736.
…own#1742) Address gemini-code-assist review on PR Hmbown#1742 (MEDIUM): the status was emitted at the assistant-persist site, but the same turn can still CONTINUE for pending steers or sub-agent completions -- the user would see a spurious 'turn ended without output' notice immediately before the turn resumed. Capture thinking_only_no_sendable at the persist site (no emission there) and decide at the end of the tool_uses.is_empty() path, just before the terminal break -- reachable only when there were no pending steers, no sub-agent completions, and we were not holding for running children. Extend should_emit_thinking_only_status with steers_pending and holding_for_subagents (false if either), recomputed live at the decision point as defense-in-depth. Unit test updated with the two new no-emit cases. Refs Hmbown#1727.
Summary
reasoning_contentwith emptycontentand notool_calls) returns chunks where the only thing the engine has at the end of the stream is aThinkingblock.crates/tui/src/core/engine/turn_loop.rs:863-877was anif-only branch onhas_sendable_assistant_content— when false, the assistant message was silently not persisted, no event was emitted, andif tool_uses.is_empty()fell straight through to "finish the turn." The UI sat idle with no spinner clear and no error.else if turn_error.is_none() && !cancel_token.is_cancelled()branch that emits astatusevent so the user sees "Model returned reasoning but no answer or tool call; turn ended without output. Send a follow-up to retry." instead of silence. No assistant message is persisted (matches prior behavior).Reproduction
provider_models.ollama = "gpt-oss:120b"(or any gpt-oss variant) andshow_thinking = truein~/.deepseek/config.toml.reasoning_contentand leavescontent/tool_callsempty).I caught this on my own machine debugging a "hang" — session
~/.deepseek/sessions/<id>.jsonshowed the user typing "are you stopped?" after gpt-oss returned a 200 with no persisted assistant message.Test plan
cargo test -p deepseek-tuipassescargo clippy --workspace --all-targets --all-featurespassesifbranch is untouched)turn_error.is_none())!cancel_token.is_cancelled())Notes
cargo checklocally — my build host is missinglibdbus-1-devand I didn't want to install system packages without asking. CI should cover it. The patch uses the sameEvent::status(String)/self.cancel_token.is_cancelled()/crate::logging::warn(&str)/turn_error.is_none()patterns already used a few lines above in the same function.🤖 Generated with Claude Code