fix(providers/openai): skip ephemeral replay items

Michael Suchacz created

Change summary

providers/openai/openai_test.go              | 43 ++++++++++-----
providers/openai/responses_language_model.go | 60 ++++++---------------
2 files changed, 45 insertions(+), 58 deletions(-)

Detailed changes

providers/openai/openai_test.go 🔗

@@ -3874,16 +3874,29 @@ func TestResponsesToPrompt_WebSearchProviderExecutedToolResults(t *testing.T) {
 		},
 	}
 
-	input, warnings := toResponsesPrompt(prompt, "system instructions", false)
+	t.Run("store false skips item reference", func(t *testing.T) {
+		t.Parallel()
+
+		input, warnings := toResponsesPrompt(prompt, "system instructions", false)
+
+		require.Empty(t, warnings)
+		require.Len(t, input, 2,
+			"expected user + assistant text when store=false")
+		require.Nil(t, input[0].OfItemReference)
+		require.Nil(t, input[1].OfItemReference)
+	})
+
+	t.Run("store true uses item reference", func(t *testing.T) {
+		t.Parallel()
 
-	require.Empty(t, warnings)
+		input, warnings := toResponsesPrompt(prompt, "system instructions", true)
 
-	// Expected input items: user message, item_reference (for
-	// provider-executed tool call; the ToolResultPart is skipped),
-	// and assistant text message. System instructions are passed
-	// via params.Instructions, not as an input item.
-	require.Len(t, input, 3,
-		"expected user + item_reference + assistant text")
+		require.Empty(t, warnings)
+		require.Len(t, input, 3,
+			"expected user + item_reference + assistant text when store=true")
+		require.NotNil(t, input[1].OfItemReference)
+		require.Equal(t, "ws_01", input[1].OfItemReference.ID)
+	})
 }
 
 func TestResponsesToPrompt_ReasoningWithStore(t *testing.T) {
@@ -3942,19 +3955,19 @@ func TestResponsesToPrompt_ReasoningWithStore(t *testing.T) {
 		}
 	})
 
-	t.Run("store false includes reasoning", func(t *testing.T) {
+	t.Run("store false skips 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)
+		// With store=false: user, assistant text, follow-up user.
+		require.Len(t, input, 3)
 
-		// Second item should be the reasoning.
-		require.NotNil(t, input[1].OfReasoning)
-		require.Equal(t, reasoningItemID, input[1].OfReasoning.ID)
+		for _, item := range input {
+			require.Nil(t, item.OfReasoning,
+				"reasoning items must not appear when store=false")
+		}
 	})
 }
 

providers/openai/responses_language_model.go 🔗

@@ -537,10 +537,16 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string, store bo
 					}
 
 					if toolCallPart.ProviderExecuted {
-						// Round-trip provider-executed tools via
-						// item_reference, letting the API resolve
-						// the stored output item by ID.
-						input = append(input, responses.ResponseInputItemParamOfItemReference(toolCallPart.ToolCallID))
+						if store {
+							// Round-trip provider-executed tools via
+							// item_reference, letting the API resolve
+							// the stored output item by ID.
+							input = append(input, responses.ResponseInputItemParamOfItemReference(toolCallPart.ToolCallID))
+						}
+						// When store is disabled, server-side items are
+						// ephemeral and cannot be referenced. Skip the
+						// tool call; results are already omitted for
+						// provider-executed tools.
 						continue
 					}
 
@@ -559,45 +565,13 @@ func toResponsesPrompt(prompt fantasy.Prompt, systemMessageMode string, store bo
 					// 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
-					}
-					if len(reasoningMetadata.Summary) == 0 && reasoningMetadata.EncryptedContent == nil {
-						warnings = append(warnings, fantasy.CallWarning{
-							Type:    fantasy.CallWarningTypeOther,
-							Message: "assistant message reasoning part does is empty",
-						})
-						continue
-					}
-					// we want to always send an empty array
-					summary := make([]responses.ResponseReasoningItemSummaryParam, 0, len(reasoningMetadata.Summary))
-					for _, s := range reasoningMetadata.Summary {
-						summary = append(summary, responses.ResponseReasoningItemSummaryParam{
-							Type: "summary_text",
-							Text: s,
-						})
-					}
-					reasoning := &responses.ResponseReasoningItemParam{
-						ID:      reasoningMetadata.ItemID,
-						Summary: summary,
-					}
-					if reasoningMetadata.EncryptedContent != nil {
-						reasoning.EncryptedContent = param.NewOpt(*reasoningMetadata.EncryptedContent)
-					}
-					input = append(input, responses.ResponseInputItemUnionParam{
-						OfReasoning: reasoning,
-					})
+					// Reasoning items are always skipped during replay.
+					// When store is enabled, the API already has them
+					// persisted server-side. When store is disabled, the
+					// item IDs are ephemeral and referencing them causes
+					// "Item not found" errors. In both cases, replaying
+					// reasoning inline is not supported by the API.
+					continue
 				}
 			}