fix: prevent thinking content from leaking into visible answer in shared chat (Go mode) (#17225)

This commit is contained in:
euvre
2026-07-23 09:58:24 +08:00
committed by GitHub
parent 285f3e4c7e
commit db1559f71b
3 changed files with 55 additions and 7 deletions

View File

@@ -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 <think>-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
// <think> / </think> 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,
}

View File

@@ -53,22 +53,29 @@ type ThinkStreamState struct {
}
// EnterReasoning marks the start of a reasoning block (model-level, not tag-based).
// It also writes a <think> 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 += "<think>"
return true
}
// ExitReasoning marks the end of a reasoning block (model-level, not tag-based).
// It writes a matching </think> 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 += "</think>"
return true
}

View File

@@ -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 <think> tags in
// the text) is bracketed by <think></think> 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)
}
}