fix(openai): skip reasoning items in Responses API replay (#181)

Kyle Carberry created

When Store is enabled, replaying reasoning items (OfReasoning) in
the Responses API input causes a validation error:

  Item 'rs_xxx' of type 'reasoning' was provided without its
  required following item.

The API stores reasoning server-side and cannot pair a reconstructed
reasoning item with the output item that originally followed it.
The fix skips reasoning parts during replay, letting the conversation
continue with visible assistant content (text / tool calls).

Change summary

providers/openai/openai_test.go              | 88 ++++++++++++++++++++--
providers/openai/responses_language_model.go | 15 +++
2 files changed, 93 insertions(+), 10 deletions(-)

Detailed changes

providers/openai/openai_test.go 🔗

@@ -3105,7 +3105,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) {
 			},
 		}
 
-		input, warnings := toResponsesPrompt(prompt, "system")
+		input, warnings := toResponsesPrompt(prompt, "system", false)
 
 		require.Len(t, input, 1, "should only have user message")
 		require.Len(t, warnings, 1)
@@ -3131,7 +3131,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) {
 			},
 		}
 
-		input, warnings := toResponsesPrompt(prompt, "system")
+		input, warnings := toResponsesPrompt(prompt, "system", false)
 
 		require.Len(t, input, 2, "should have both user and assistant messages")
 		require.Empty(t, warnings)
@@ -3159,7 +3159,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) {
 			},
 		}
 
-		input, warnings := toResponsesPrompt(prompt, "system")
+		input, warnings := toResponsesPrompt(prompt, "system", false)
 
 		require.Len(t, input, 2, "should have both user and assistant messages")
 		require.Empty(t, warnings)
@@ -3180,7 +3180,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) {
 			},
 		}
 
-		input, warnings := toResponsesPrompt(prompt, "system")
+		input, warnings := toResponsesPrompt(prompt, "system", false)
 
 		require.Empty(t, input)
 		require.Len(t, warnings, 2) // One for unsupported type, one for empty message
@@ -3202,7 +3202,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) {
 			},
 		}
 
-		input, warnings := toResponsesPrompt(prompt, "system")
+		input, warnings := toResponsesPrompt(prompt, "system", false)
 
 		require.Len(t, input, 1)
 		require.Empty(t, warnings)
@@ -3223,7 +3223,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) {
 			},
 		}
 
-		input, warnings := toResponsesPrompt(prompt, "system")
+		input, warnings := toResponsesPrompt(prompt, "system", false)
 
 		require.Len(t, input, 1)
 		require.Empty(t, warnings)
@@ -3244,7 +3244,7 @@ func TestResponsesToPrompt_DropsEmptyMessages(t *testing.T) {
 			},
 		}
 
-		input, warnings := toResponsesPrompt(prompt, "system")
+		input, warnings := toResponsesPrompt(prompt, "system", false)
 
 		require.Len(t, input, 1)
 		require.Empty(t, warnings)
@@ -3874,7 +3874,7 @@ func TestResponsesToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) {
 		},
 	}
 
-	input, warnings := toResponsesPrompt(prompt, "system instructions")
+	input, warnings := toResponsesPrompt(prompt, "system instructions", false)
 
 	require.Empty(t, warnings)
 
@@ -3886,6 +3886,78 @@ func TestResponsesToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) {
 		"expected user + item_reference + assistant text")
 }
 
+func TestResponsesToPrompt_ReasoningWithStore(t *testing.T) {
+	t.Parallel()
+
+	encryptedContent := "gAAAAABpvAwtDPh5dSXW86hwbwoTo4DJHANQ"
+	reasoningItemID := "rs_08d030b87966238b0069bc095b7e5c81"
+
+	reasoningPart := fantasy.ReasoningPart{
+		Text: "Let me think about this...",
+		ProviderOptions: fantasy.ProviderOptions{
+			Name: &ResponsesReasoningMetadata{
+				ItemID:           reasoningItemID,
+				EncryptedContent: &encryptedContent,
+				Summary:          []string{},
+			},
+		},
+	}
+
+	prompt := fantasy.Prompt{
+		{
+			Role: fantasy.MessageRoleUser,
+			Content: []fantasy.MessagePart{
+				fantasy.TextPart{Text: "What is 2+2?"},
+			},
+		},
+		{
+			Role: fantasy.MessageRoleAssistant,
+			Content: []fantasy.MessagePart{
+				reasoningPart,
+				fantasy.TextPart{Text: "4"},
+			},
+		},
+		{
+			Role: fantasy.MessageRoleUser,
+			Content: []fantasy.MessagePart{
+				fantasy.TextPart{Text: "And 3+3?"},
+			},
+		},
+	}
+
+	t.Run("store true skips reasoning", func(t *testing.T) {
+		t.Parallel()
+
+		input, warnings := toResponsesPrompt(prompt, "system", true)
+		require.Empty(t, warnings)
+
+		// With store=true: user, assistant text (reasoning
+		// skipped), follow-up user.
+		require.Len(t, input, 3)
+
+		// Verify no reasoning item leaked through.
+		for _, item := range input {
+			require.Nil(t, item.OfReasoning,
+				"reasoning items must not appear when store=true")
+		}
+	})
+
+	t.Run("store false includes reasoning", func(t *testing.T) {
+		t.Parallel()
+
+		input, warnings := toResponsesPrompt(prompt, "system", false)
+		require.Empty(t, warnings)
+
+		// With store=false: user, reasoning, assistant text,
+		// follow-up user.
+		require.Len(t, input, 4)
+
+		// Second item should be the reasoning.
+		require.NotNil(t, input[1].OfReasoning)
+		require.Equal(t, reasoningItemID, input[1].OfReasoning.ID)
+	})
+}
+
 func TestResponsesStream_WebSearchResponse(t *testing.T) {
 	t.Parallel()
 

providers/openai/responses_language_model.go 🔗

@@ -176,7 +176,8 @@ func (o responsesLanguageModel) prepareParams(call fantasy.Call) (*responses.Res
 		params.PreviousResponseID = param.NewOpt(*openaiOptions.PreviousResponseID)
 	}
 
-	input, inputWarnings := toResponsesPrompt(call.Prompt, modelConfig.systemMessageMode)
+	storeEnabled := openaiOptions != nil && openaiOptions.Store != nil && *openaiOptions.Store
+	input, inputWarnings := toResponsesPrompt(call.Prompt, modelConfig.systemMessageMode, storeEnabled)
 	warnings = append(warnings, inputWarnings...)
 
 	var include []IncludeType
@@ -391,7 +392,7 @@ func responsesUsage(resp responses.Response) fantasy.Usage {
 	return usage
 }
 
-func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (responses.ResponseInputParam, []fantasy.CallWarning) {
+func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string, store bool) (responses.ResponseInputParam, []fantasy.CallWarning) {
 	var input responses.ResponseInputParam
 	var warnings []fantasy.CallWarning
 
@@ -560,6 +561,16 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string) (respons
 					// recognised Responses API input type; skip.
 					continue
 				case fantasy.ContentTypeReasoning:
+					if store {
+						// When Store is enabled the API already has the
+						// reasoning persisted server-side. Replaying the
+						// full OfReasoning item causes a validation error
+						// ("reasoning was provided without its required
+						// following item") because the API cannot pair the
+						// reconstructed reasoning with the output item
+						// that followed it.
+						continue
+					}
 					reasoningMetadata := GetReasoningMetadata(c.Options())
 					if reasoningMetadata == nil || reasoningMetadata.ItemID == "" {
 						continue