From db1559f71b8278bfd122cd6d58eafdad5a794620 Mon Sep 17 00:00:00 2001 From: euvre <93761161+euvre@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:58:24 +0800 Subject: [PATCH] fix: prevent thinking content from leaking into visible answer in shared chat (Go mode) (#17225) --- internal/service/bot_completion.go | 19 ++++++++++------ internal/service/think_tag.go | 7 ++++++ internal/service/think_tag_test.go | 36 ++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/internal/service/bot_completion.go b/internal/service/bot_completion.go index 6bd066f43c..c181dc5bb9 100644 --- a/internal/service/bot_completion.go +++ b/internal/service/bot_completion.go @@ -429,11 +429,14 @@ func (s *BotService) ChatbotCompletion( } // 5. Translate pipeline results into chatbot frames. The - // pipeline streams deltas; the python iframe contract sends the - // full accumulated answer in every frame, so we accumulate here - // (the front-end accepts both shapes, but full-text frames are - // byte-parity with python). The final pipeline result carries - // the decorated full answer plus the retrieval reference. + // pipeline streams deltas; the python iframe contract also yields + // deltas (async_chat yields the value from _stream_with_think_delta). + // We accumulate the full answer locally for persistence and for the + // final frame, but forward each result's own delta in the SSE frame + // so the front-end can build the -wrapped answer correctly. + // Sending accumulated full text on every frame interacts badly with + // the front-end's start_to_think/end_to_think marker append, causing + // reasoning content to leak into the visible answer. out := make(chan ChatbotSSEFrame, 16) go func() { defer close(out) @@ -468,8 +471,10 @@ func (s *BotService) ChatbotCompletion( continue } if res.StartToThink || res.EndToThink { + // Marker frames carry no text; the front-end appends + // / to the accumulated answer. out <- ChatbotSSEFrame{ - Data: fullAnswer, + Data: "", Reference: map[string]any{}, SessionID: session.ID, StartToThink: res.StartToThink, @@ -489,7 +494,7 @@ func (s *BotService) ChatbotCompletion( finalRef = res.Reference } out <- ChatbotSSEFrame{ - Data: fullAnswer, + Data: res.Answer, Reference: map[string]any{}, SessionID: session.ID, } diff --git a/internal/service/think_tag.go b/internal/service/think_tag.go index db8736a793..f0fdb71466 100644 --- a/internal/service/think_tag.go +++ b/internal/service/think_tag.go @@ -53,22 +53,29 @@ type ThinkStreamState struct { } // EnterReasoning marks the start of a reasoning block (model-level, not tag-based). +// It also writes a marker into fullText so that ExtractVisibleAnswer can +// strip the reasoning segment from the final answer when the model exposes +// reasoning through a separate channel (e.g. delta.reasoning_content). // Returns true when this is a new transition (reasoning was not already active). func (s *ThinkStreamState) EnterReasoning() bool { if s.inReasoning { return false } s.inReasoning = true + s.fullText += "" return true } // ExitReasoning marks the end of a reasoning block (model-level, not tag-based). +// It writes a matching marker into fullText so the reasoning segment is +// closed before the visible answer text begins. // Returns true when this is a new transition (reasoning was active). func (s *ThinkStreamState) ExitReasoning() bool { if !s.inReasoning { return false } s.inReasoning = false + s.fullText += "" return true } diff --git a/internal/service/think_tag_test.go b/internal/service/think_tag_test.go index e87008b58a..ff85c4c563 100644 --- a/internal/service/think_tag_test.go +++ b/internal/service/think_tag_test.go @@ -301,3 +301,39 @@ func TestStreamThinkTagDelta_DeferredClose(t *testing.T) { t.Errorf("second marker = %q", markers[1]) } } + +// TestReasoningChannelWrapsThinkTags verifies that model-level reasoning +// (arriving through a separate reason channel rather than tags in +// the text) is bracketed by in fullText. This prevents the +// reasoning content from leaking into the final visible answer. +func TestReasoningChannelWrapsThinkTags(t *testing.T) { + state := &ThinkStreamState{} + + if !state.EnterReasoning() { + t.Fatal("EnterReasoning should return true on first call") + } + // Simulate the chat pipeline feeding reasoning chunks through NextThinkDelta. + deltas := NextThinkDelta(state, "reasoning step 1", 0) + if len(deltas) != 1 || deltas[0].Value != "reasoning step 1" { + t.Fatalf("unexpected reasoning deltas: %+v", deltas) + } + deltas = NextThinkDelta(state, " reasoning step 2", 0) + if len(deltas) != 1 || deltas[0].Value != " reasoning step 2" { + t.Fatalf("unexpected reasoning deltas: %+v", deltas) + } + + if state.ExitReasoning() != true { + t.Fatal("ExitReasoning should return true when reasoning was active") + } + + // Visible answer arrives after reasoning ends. + deltas = BufferAnswerDelta(state, "visible answer", 0) + if len(deltas) != 1 || deltas[0].Value != "visible answer" { + t.Fatalf("unexpected answer deltas: %+v", deltas) + } + + wantVisible := "visible answer" + if got := ExtractVisibleAnswer(state.fullText); got != wantVisible { + t.Errorf("ExtractVisibleAnswer(%q) = %q, want %q", state.fullText, got, wantVisible) + } +}