From 7e3cdc83710b7494852e95aaa5b1a5db1a8fb02a Mon Sep 17 00:00:00 2001 From: nguyenha935 Date: Thu, 2 Jul 2026 16:24:25 +0700 Subject: [PATCH] fix(providers): preserve streamed content whitespace (#1324) Co-authored-by: GoClaw Operator --- internal/providers/adapter_openai.go | 6 +-- internal/providers/adapter_openai_test.go | 14 ++++++ .../providers/control_output_normalizer.go | 1 - .../control_output_normalizer_test.go | 46 +++++++++++++++++++ 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/internal/providers/adapter_openai.go b/internal/providers/adapter_openai.go index d8334dc5..6e744441 100644 --- a/internal/providers/adapter_openai.go +++ b/internal/providers/adapter_openai.go @@ -112,10 +112,8 @@ func (a *OpenAIAdapter) FromStreamChunk(data []byte) (*StreamChunk, error) { // Text content if delta.Content != "" { - normalized := normalizeControlOutput(delta.Content, "", nil) - sc.Content = normalized.Content - sc.Thinking = strings.TrimSpace(strings.Join([]string{sc.Thinking, normalized.Thinking}, "\n")) - hasContent = sc.Content != "" || sc.Thinking != "" + sc.Content = delta.Content + hasContent = true } if !hasContent { diff --git a/internal/providers/adapter_openai_test.go b/internal/providers/adapter_openai_test.go index c7cfcb4f..b237ed4a 100644 --- a/internal/providers/adapter_openai_test.go +++ b/internal/providers/adapter_openai_test.go @@ -185,6 +185,20 @@ func TestOpenAIAdapter_FromStreamChunk(t *testing.T) { } }) + t.Run("content delta preserves leading whitespace", func(t *testing.T) { + raw := []byte(`{"choices":[{"delta":{"content":" xin lỗi"}}]}`) + sc, err := a.FromStreamChunk(raw) + if err != nil { + t.Fatalf("err: %v", err) + } + if sc == nil { + t.Fatal("want stream chunk, got nil") + } + if sc.Content != " xin lỗi" { + t.Errorf("want leading whitespace preserved, got %q", sc.Content) + } + }) + t.Run("reasoning_content delta", func(t *testing.T) { raw := []byte(`{"choices":[{"delta":{"reasoning_content":"thinking..."}}]}`) sc, err := a.FromStreamChunk(raw) diff --git a/internal/providers/control_output_normalizer.go b/internal/providers/control_output_normalizer.go index 095f8b33..c4c0a206 100644 --- a/internal/providers/control_output_normalizer.go +++ b/internal/providers/control_output_normalizer.go @@ -70,7 +70,6 @@ func normalizeControlOutput(content, thinking string, tools []ToolDefinition) co out.Content, extracted = extractThinkingTags(out.Content) out.Thinking = joinThinking(out.Thinking, extracted) - out.Content = strings.TrimSpace(out.Content) return out } diff --git a/internal/providers/control_output_normalizer_test.go b/internal/providers/control_output_normalizer_test.go index b830ecaf..c671e089 100644 --- a/internal/providers/control_output_normalizer_test.go +++ b/internal/providers/control_output_normalizer_test.go @@ -286,6 +286,52 @@ func TestOpenAIProviderChatStream_NormalizesSplitKimiTextToolCallBeforeEmittingC } } +func TestOpenAIProviderChatStream_PreservesWhitespaceAcrossContentDeltas(t *testing.T) { + events := []string{ + `data: {"choices":[{"delta":{"content":"Dạ"}}]}` + "\n\n", + `data: {"choices":[{"delta":{"content":" em"}}]}` + "\n\n", + `data: {"choices":[{"delta":{"content":" xin"}}]}` + "\n\n", + `data: {"choices":[{"delta":{"content":" lỗi"}}]}` + "\n\n", + `data: {"choices":[{"delta":{"content":" anh"}}]}` + "\n\n", + `data: {"choices":[{"finish_reason":"stop","delta":{}}]}` + "\n\n", + } + srv := newOpenAISSEServer(t, events) + defer srv.Close() + + p := NewOpenAIProvider("openai-compatible", "sk", srv.URL, "model") + var streamed strings.Builder + resp, err := p.ChatStream(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "say it"}}, + }, func(chunk StreamChunk) { + streamed.WriteString(chunk.Content) + }) + if err != nil { + t.Fatalf("ChatStream: %v", err) + } + + const want = "Dạ em xin lỗi anh" + if resp.Content != want { + t.Fatalf("Content = %q, want %q", resp.Content, want) + } + if got := streamed.String(); got != want { + t.Fatalf("streamed content = %q, want %q", got, want) + } +} + +func TestControlOutputNormalizer_PreservesWhitespaceAroundNormalText(t *testing.T) { + n := newControlOutputNormalizer(nil) + var got strings.Builder + got.WriteString(n.Append("Dạ").Content) + got.WriteString(n.Append(" em").Content) + got.WriteString(n.Append(" xin").Content) + got.WriteString(n.Append(" lỗi").Content) + got.WriteString(n.Finish().Content) + + if got.String() != "Dạ em xin lỗi" { + t.Fatalf("normalized stream content = %q, want %q", got.String(), "Dạ em xin lỗi") + } +} + func newOpenAIJSONServer(t *testing.T, body string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {