From 729acd769605522635586ac1ed60bdd3f1b76b86 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 14 Oct 2026 11:26:45 +0000 Subject: [PATCH] fix(providers/openai): blank line between reasoning summary parts OpenAI reasoning summary parts each open with a bold title line. Streaming separated them with a single newline, which Markdown treats as a soft break, so every title after the first rendered at the end of the previous paragraph. A blank line now precedes each part. The first part gets one too. Consumers that join consecutive reasoning items, such as the Coder chat UI, would otherwise glue the next item's title onto the previous sentence. Generate joins the parts with a blank line to match. --- providers/openai/responses_language_model.go | 8 +- .../responses_reasoning_summary_test.go | 78 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 providers/openai/responses_reasoning_summary_test.go diff --git a/providers/openai/responses_language_model.go b/providers/openai/responses_language_model.go index ccbf2caa0..02471834f 100644 --- a/providers/openai/responses_language_model.go +++ b/providers/openai/responses_language_model.go @@ -1196,7 +1196,7 @@ func (o responsesLanguageModel) Generate(ctx context.Context, call fantasy.Call) } content = append(content, fantasy.ReasoningContent{ - Text: strings.Join(metadata.Summary, "\n"), + Text: strings.Join(metadata.Summary, "\n\n"), ProviderMetadata: fantasy.ProviderMetadata{ Name: metadata, }, @@ -1526,10 +1526,14 @@ func (o responsesLanguageModel) Stream(ctx context.Context, call fantasy.Call) ( if state != nil { state.metadata.Summary = append(state.metadata.Summary, "") activeReasoning[added.ItemID] = state + // Summary parts open with a bold title that Markdown keeps + // inline after a single newline. The blank line also goes + // before the first part, so consumers that join consecutive + // reasoning items still get a paragraph break between them. if !yield(fantasy.StreamPart{ Type: fantasy.StreamPartTypeReasoningDelta, ID: added.ItemID, - Delta: "\n", + Delta: "\n\n", ProviderMetadata: fantasy.ProviderMetadata{ Name: state.metadata, }, diff --git a/providers/openai/responses_reasoning_summary_test.go b/providers/openai/responses_reasoning_summary_test.go new file mode 100644 index 000000000..5e0e8ce10 --- /dev/null +++ b/providers/openai/responses_reasoning_summary_test.go @@ -0,0 +1,78 @@ +package openai + +import ( + "context" + "testing" + + "charm.land/fantasy" + "github.com/stretchr/testify/require" +) + +func TestResponsesStream_ReasoningSummaryPartsStartWithBlankLine(t *testing.T) { + t.Parallel() + + partAdded := func(itemID, index string) string { + return responsesSSEEvent("response.reasoning_summary_part.added", `{"type":"response.reasoning_summary_part.added","item_id":"`+itemID+`","output_index":0,"summary_index":`+index+`,"part":{"type":"summary_text","text":""}}`) + } + textDelta := func(itemID, index, delta string) string { + return responsesSSEEvent("response.reasoning_summary_text.delta", `{"type":"response.reasoning_summary_text.delta","item_id":"`+itemID+`","output_index":0,"summary_index":`+index+`,"delta":"`+delta+`"}`) + } + sms := newStreamingMockServer() + defer sms.close() + sms.chunks = []string{ + responsesSSEEvent("response.output_item.added", `{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[]}}`), + partAdded("rs_1", "0"), + textDelta("rs_1", "0", `**A**\n\na`), + partAdded("rs_1", "1"), + textDelta("rs_1", "1", `**B**\n\nb`), + responsesSSEEvent("response.output_item.done", `{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"**A**\n\na"},{"type":"summary_text","text":"**B**\n\nb"}]}}`), + responsesSSEEvent("response.output_item.added", `{"type":"response.output_item.added","output_index":1,"item":{"id":"rs_2","type":"reasoning","summary":[]}}`), + partAdded("rs_2", "0"), + textDelta("rs_2", "0", `**C**\n\nc`), + responsesSSEEvent("response.output_item.done", `{"type":"response.output_item.done","output_index":1,"item":{"id":"rs_2","type":"reasoning","summary":[{"type":"summary_text","text":"**C**\n\nc"}]}}`), + responsesSSEEvent("response.completed", `{"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}`), + } + + stream, err := newResponsesProvider(t, sms.server.URL).Stream(context.Background(), fantasy.Call{Prompt: testPrompt}) + require.NoError(t, err) + parts, err := collectStreamParts(stream) + require.NoError(t, err) + + text := map[string]string{} + for _, part := range parts { + if part.Type == fantasy.StreamPartTypeReasoningDelta { + text[part.ID] += part.Delta + } + } + require.Equal(t, map[string]string{ + "rs_1": "\n\n**A**\n\na\n\n**B**\n\nb", + "rs_2": "\n\n**C**\n\nc", + }, text) +} + +func TestResponsesGenerate_ReasoningSummaryPartsJoinWithBlankLine(t *testing.T) { + t.Parallel() + + server := newMockServer() + defer server.close() + server.response = map[string]any{ + "id": "resp_1", + "object": "response", + "model": "gpt-4.1", + "status": "completed", + "output": []any{ + map[string]any{"type": "reasoning", "id": "rs_1", "summary": []any{ + map[string]any{"type": "summary_text", "text": "**A**\n\na"}, + map[string]any{"type": "summary_text", "text": "**B**\n\nb"}, + }}, + }, + "usage": map[string]any{"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + } + + resp, err := newResponsesProvider(t, server.server.URL).Generate(context.Background(), fantasy.Call{Prompt: testPrompt}) + require.NoError(t, err) + + reasoning := resp.Content.Reasoning() + require.Len(t, reasoning, 1) + require.Equal(t, "**A**\n\na\n\n**B**\n\nb", reasoning[0].Text) +}