Skip to content

fix(tui): surface thinking-only turns instead of silently ending#1727

Closed
gsrunion wants to merge 1 commit into
Hmbown:mainfrom
gsrunion:fix/surface-thinking-only-turn
Closed

fix(tui): surface thinking-only turns instead of silently ending#1727
gsrunion wants to merge 1 commit into
Hmbown:mainfrom
gsrunion:fix/surface-thinking-only-turn

Conversation

@gsrunion

Copy link
Copy Markdown

Summary

  • gpt-oss (and any model whose response gets mapped to reasoning_content with empty content and no tool_calls) returns chunks where the only thing the engine has at the end of the stream is a Thinking block.
  • The persist site at crates/tui/src/core/engine/turn_loop.rs:863-877 was an if-only branch on has_sendable_assistant_content — when false, the assistant message was silently not persisted, no event was emitted, and if tool_uses.is_empty() fell straight through to "finish the turn." The UI sat idle with no spinner clear and no error.
  • This PR adds an else if turn_error.is_none() && !cancel_token.is_cancelled() branch that emits a status event 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

  1. Configure provider_models.ollama = "gpt-oss:120b" (or any gpt-oss variant) and show_thinking = true in ~/.deepseek/config.toml.
  2. Run the TUI in agent mode against ollama.
  3. Periodically gpt-oss emits an analysis-channel-only response (ollama's harmony→OpenAI shim puts the content into reasoning_content and leaves content/tool_calls empty).
  4. Before this fix: spinner clears nothing, no error, no reply — UI looks hung.
  5. After this fix: status event surfaces with the notice above; user knows the turn ended and can send a follow-up.

I caught this on my own machine debugging a "hang" — session ~/.deepseek/sessions/<id>.json showed the user typing "are you stopped?" after gpt-oss returned a 200 with no persisted assistant message.

Test plan

  • cargo test -p deepseek-tui passes
  • cargo clippy --workspace --all-targets --all-features passes
  • Manual: reproduce the gpt-oss thinking-only response against ollama and confirm the status event renders
  • Manual: confirm a normal text/tool_use turn still persists and renders unchanged (the if branch is untouched)
  • Manual: confirm a stream error still surfaces its own error event (no double-notice — guarded by turn_error.is_none())
  • Manual: confirm a cancelled turn doesn't fire the new notice (guarded by !cancel_token.is_cancelled())

Notes

  • I couldn't run cargo check locally — my build host is missing libdbus-1-dev and I didn't want to install system packages without asking. CI should cover it. The patch uses the same Event::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.
  • This is option 1 of the three approaches I considered (minimum-surface-area fix). Option 2 (re-prompt with a "produce a final-channel response" nudge) and option 3 (persist a placeholder text block) are deliberately not in this PR — option 1 unblocks the perceived hang without making policy decisions about retry behavior.

🤖 Generated with Claude Code

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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +877 to 886
} 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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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).

Suggested change
} 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;
}

@gsrunion

Copy link
Copy Markdown
Author

Update: installed libdbus-1-dev + pkg-config locally and cargo check -p deepseek-tui now passes clean (Finished dev profile in 25.16s, no warnings on the touched file). The "couldn't compile locally" caveat in the description is resolved — only manual repro / clippy / full test suite remain on the test plan.

Hmbown added a commit that referenced this pull request May 17, 2026
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
@Hmbown Hmbown added this to the v0.8.40 milestone May 17, 2026
@Hmbown

Hmbown commented May 17, 2026

Copy link
Copy Markdown
Owner

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.

LeoLin990405 added a commit to LeoLin990405/DeepSeek-TUI that referenced this pull request May 17, 2026
…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.
@Hmbown Hmbown mentioned this pull request May 20, 2026
11 tasks
@Hmbown Hmbown modified the milestones: v0.8.40, v0.8.41 May 21, 2026
@Hmbown

Hmbown commented May 21, 2026

Copy link
Copy Markdown
Owner

Thanks for raising the thinking-only turn behavior. The release-safe fix was harvested into v0.8.40 release PR #1823 as commits 4ab7c53 and 351898d, and #1823 is now CI-green. Closing this older PR as superseded by the release branch; thank you for pushing this regression into view.

@Hmbown Hmbown closed this May 21, 2026
getong pushed a commit to getong/CodeWhale that referenced this pull request May 21, 2026
…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.
getong pushed a commit to getong/CodeWhale that referenced this pull request May 21, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants